GitHub Actions: Create a Composite Action in Python
Create a composite action in Python π to reduce code duplication and improve maintainability
Overview
In GitHub Actions, a composite action is a type of action 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.
Composite Action in Python
In a composite action, we have to specify the shell 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.
Letβs show some examples.
Preferred Python Composite Action
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
file.
Hereβs the example:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
name: 'Python composite action'
description: 'call a python script from a composite action'
inputs:
directory:
description: 'directory path as an example input'
required: true
default: '${{ github.workspace }}'
token:
description: 'github auth token (PAT, github token, or GitHub app token)'
required: true
default: '${{ github.token }}'
runs:
using: "composite"
steps:
- name: run python
shell: bash
run: |
python3 ${{ github.action_path }}/main.py ${{ inputs.directory }} ${{ inputs.token }}
The magic πͺ is that we are calling the Python script from the shell using the ${{ github.action_path }}
environment variable. This variable maps to the local directory the action is cloned to so that we can reference files from the repo.
You can then run/test/debug/develop the Python script locally as you would any other Python script:
1
python3 main.py /path/to/directory ghp_abcdefg1234567890
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
file (this is how Iβm doing it in the example above):
1
2
3
4
.
βββ README.md
βββ action.yml
βββ main.py
If you had multiple composite actions in the same repository, you could structure it like so:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
.
βββ README.md
βββ python-action-1/
β βββ README.md
β βββ action.yml
β βββ main.py
βββ python-action-2/
β βββ README.md
β βββ action.yml
β βββ main.py
βββ python-other-action/
βββ README.md
βββ action.yml
βββ main.py
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.
You could also do something like this, creating separate folders for the actions (since only one action.yml
can exist in a single directory) and then use a combined ./src
folder for the Python scripts:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
.
βββ README.md
βββ python-action-1/
β βββ README.md
β βββ action.yml
βββ python-action-2/
β βββ README.md
β βββ action.yml
βββ python-other-action/
β βββ README.md
β βββ action.yml
βββ src/
βββ action-1.py
βββ action-2.py
βββ other-action.py
Or, of course, a combination of whatever makes the most sense for your use case. π
Non-optimal Python Composite Action
Ideally, you wouldnβt do this. We cannot run or test this locally, and especially for a longer script, it makes the action.yml
file harder to read and maintain.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
name: 'Python composite action'
description: 'call a python script from a composite action'
inputs:
directory:
description: 'directory path as an example input'
required: true
default: '${{ github.workspace }}'
token:
description: 'github auth token (PAT, github token, or GitHub app token)'
required: true
default: '${{ github.token }}'
runs:
using: "composite"
steps:
- name: run python
shell: bash
run: |
import sys
def main(filePath, creds):
print("Hello World")
print(f"File Path: {filePath}")
if __name__ == "__main__":
if len(sys.argv) != 3:
print("Usage: python3 myfile.py <filePath> <creds>")
sys.exit(1)
filePath = sys.argv[1]
creds = sys.argv[2]
main(${{ inputs.directory }}, ${{ inputs.token }})
This certainly works, but you can see that itβs not as flexible/portable as the preferred method above. 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. π¬
Summary
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. 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
. π π