Skip to content

Commit b10e649

Browse files
committed
adding post on python composite actions
1 parent a48560d commit b10e649

File tree

3 files changed

+163
-0
lines changed

3 files changed

+163
-0
lines changed
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
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`. 🐍 🚀
Loading
Loading

0 commit comments

Comments
 (0)