|
| 1 | +--- |
| 2 | +title: 'GitHub Actions: Create a Composite Action in Python' |
| 3 | +author: Josh Johanning |
| 4 | +date: 2024-06-18 19:30:00 -0500 |
| 5 | +description: Create a composite action in Python 🐍 to reduce code duplication and improve maintainability |
| 6 | +categories: [GitHub, Actions] |
| 7 | +tags: [GitHub, GitHub Actions, Python, Composite Actions] |
| 8 | +media_subpath: /assets/screenshots/2024-06-18-github-composite-action-python |
| 9 | +image: |
| 10 | + path: composite-action-light.png |
| 11 | + width: 100% |
| 12 | + height: 100% |
| 13 | + alt: A composite action in GitHub Actions written in Python |
| 14 | +--- |
| 15 | + |
| 16 | +## Overview |
| 17 | + |
| 18 | +In GitHub Actions, a [composite action is a type of action](https://docs.github.com/en/actions/creating-actions/about-custom-actions#types-of-actions) that allows you to combine multiple steps into a single action. This can help reduce code duplication and improve maintainability of your workflow files. In a composite action, you can combine multiple run steps, multiple marketplace actions, or a combination of both! Composite actions are my favorite type of action because of their flexibility to run *anything* in any language/framework/etc. on any host. If it can run programmatically, you can build it as a composite action. In this post, we'll create a composite action in Python in a way that can be used in Actions as well as preserving the ability to test/run the script locally. |
| 19 | + |
| 20 | +## Composite Action in Python |
| 21 | + |
| 22 | +In a composite action, we have to specify the [shell](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#defaultsrunshell) for each run step. Most commonly, I use `shell: bash`, but there is an option for `shell: python` directly. If it was a 2 line script, sure, use `shell: python`, but for anything more complex, I prefer to use `shell: bash` and call the Python script from shell. This allows me to use the same Python script in the composite action as well as run it locally for testing. |
| 23 | + |
| 24 | +Let's show some examples. |
| 25 | + |
| 26 | +### Preferred Python Composite Action |
| 27 | + |
| 28 | +{% raw %} |
| 29 | + |
| 30 | +This is the preferred way to create a Python composite action. Note how we are storing the Python script in a separate file and not directly inline. This allows you to run the Python script locally for testing as well as using in GitHub Actions as a composite action. And if you ever switch CI systems, it would be easy to port since the only "Actions" specific code is small bit in the `action.yml`{: .filepath} file. |
| 31 | + |
| 32 | +Here's the example: |
| 33 | + |
| 34 | +```yml |
| 35 | +name: 'Python composite action' |
| 36 | +description: 'call a python script from a composite action' |
| 37 | +inputs: |
| 38 | + directory: |
| 39 | + description: 'directory path as an example input' |
| 40 | + required: true |
| 41 | + default: '${{ github.workspace }}' |
| 42 | + token: |
| 43 | + description: 'github auth token (PAT, github token, or GitHub app token)' |
| 44 | + required: true |
| 45 | + default: '${{ github.token }}' |
| 46 | +runs: |
| 47 | + using: "composite" |
| 48 | + steps: |
| 49 | + - name: run python |
| 50 | + shell: bash |
| 51 | + run: | |
| 52 | + python3 ${{ github.action_path }}/main.py ${{ inputs.directory }} ${{ inputs.token }} |
| 53 | +``` |
| 54 | +
|
| 55 | +The magic 🪄 is that we are calling the Python script from the shell using the `${{ github.action_path }}` [environment variable](https://help.github.com/en/actions/configuring-and-managing-workflows/using-environment-variables). This variable maps to the local directory the action is cloned to so that we can reference files from the repo. |
| 56 | + |
| 57 | +You can then run/test/debug/develop the Python script locally as you would any other Python script: |
| 58 | + |
| 59 | +```bash |
| 60 | +python3 main.py /path/to/directory ghp_abcdefg1234567890 |
| 61 | +``` |
| 62 | +{: .nolineno} |
| 63 | + |
| 64 | +Store the Python script in the composite action repository. If this was the only action in the repository, it probably makes the most sense to put in the script in the root along with the `action.yml`{: .filepath} file (this is how I'm doing it in the example above): |
| 65 | + |
| 66 | +```text |
| 67 | +. |
| 68 | +├── README.md |
| 69 | +├── action.yml |
| 70 | +└── main.py |
| 71 | +``` |
| 72 | +{: .nolineno} |
| 73 | + |
| 74 | +If you had multiple composite actions in the same repository, you could structure it like so: |
| 75 | + |
| 76 | +```text |
| 77 | +. |
| 78 | +├── README.md |
| 79 | +├── python-action-1/ |
| 80 | +│ ├── README.md |
| 81 | +│ ├── action.yml |
| 82 | +│ └── main.py |
| 83 | +├── python-action-2/ |
| 84 | +│ ├── README.md |
| 85 | +│ ├── action.yml |
| 86 | +│ └── main.py |
| 87 | +└── python-other-action/ |
| 88 | + ├── README.md |
| 89 | + ├── action.yml |
| 90 | + └── main.py |
| 91 | +``` |
| 92 | +{: .nolineno} |
| 93 | + |
| 94 | +> Note that the entire repository will be versioned together when creating/referencing tags, so you may only want to do this if the actions are closely related. |
| 95 | +{: .prompt-tip } |
| 96 | + |
| 97 | +You could also do something like this, creating separate folders for the actions (since only one `action.yml`{: .filepath} can exist in a single directory) and then use a combined `./src`{: .filepath} folder for the Python scripts: |
| 98 | + |
| 99 | +```text |
| 100 | +. |
| 101 | +├── README.md |
| 102 | +├── python-action-1/ |
| 103 | +│ ├── README.md |
| 104 | +│ └── action.yml |
| 105 | +├── python-action-2/ |
| 106 | +│ ├── README.md |
| 107 | +│ └── action.yml |
| 108 | +├── python-other-action/ |
| 109 | +│ ├── README.md |
| 110 | +│ └── action.yml |
| 111 | +└── src/ |
| 112 | + ├── action-1.py |
| 113 | + ├── action-2.py |
| 114 | + └── other-action.py |
| 115 | +``` |
| 116 | +{: .nolineno} |
| 117 | + |
| 118 | +Or, of course, a combination of whatever makes the most sense for your use case. 😎 |
| 119 | + |
| 120 | +### Non-optimal Python Composite Action |
| 121 | + |
| 122 | +Ideally, you wouldn't do this. We cannot run or test this locally, and especially for a longer script, it makes the `action.yml`{: .filepath} file harder to read and maintain. |
| 123 | + |
| 124 | +```yml |
| 125 | +name: 'Python composite action' |
| 126 | +description: 'call a python script from a composite action' |
| 127 | +inputs: |
| 128 | + directory: |
| 129 | + description: 'directory path as an example input' |
| 130 | + required: true |
| 131 | + default: '${{ github.workspace }}' |
| 132 | + token: |
| 133 | + description: 'github auth token (PAT, github token, or GitHub app token)' |
| 134 | + required: true |
| 135 | + default: '${{ github.token }}' |
| 136 | +runs: |
| 137 | + using: "composite" |
| 138 | + steps: |
| 139 | + - name: run python |
| 140 | + shell: bash |
| 141 | + run: | |
| 142 | + import sys |
| 143 | +
|
| 144 | + def main(filePath, creds): |
| 145 | + print("Hello World") |
| 146 | + print(f"File Path: {filePath}") |
| 147 | +
|
| 148 | + if __name__ == "__main__": |
| 149 | + if len(sys.argv) != 3: |
| 150 | + print("Usage: python3 myfile.py <filePath> <creds>") |
| 151 | + sys.exit(1) |
| 152 | + filePath = sys.argv[1] |
| 153 | + creds = sys.argv[2] |
| 154 | + main(${{ inputs.directory }}, ${{ inputs.token }}) |
| 155 | +``` |
| 156 | + |
| 157 | +This certainly works, but you can see that it's not as flexible/portable as the [preferred method above](#preferred-python-composite-action). For one, if you wanted to run this locally, you would have to copy/paste and then swap the hardcoded GitHub Actions-isms, like in this example: `${{ inputs.directory }}` and `${{ inputs.token }}`. It's also harder to read and maintain. 😬 |
| 158 | + |
| 159 | +{% endraw %} |
| 160 | + |
| 161 | +## Summary |
| 162 | + |
| 163 | +Composite Actions are great! The barrier to entry for creating custom actions are much less than that of JavaScript actions, and in general, I [don't typically recommend Docker-based actions](/posts/github-actions-docker-actions-private-registry/#overview). This post shows a great, real-world example of creating a composite action in our preferred language. I would even use this method to create composite actions written in Bash. Instead of running `python3 main.py` you would just call the Bash script via `./main.sh`. 🐍 🚀 |
0 commit comments