Refactoring your GitHub Actions workflow into a script
31 October 2025 - (revised version of a post originally published 18 February 2020)
Is your GitHub Actions workflow file getting a bit large and perhaps a little difficult to follow? Or maybe you want to share logic between multiple workflows in the same repo and want to keep things ‘DRY’? In this article, we’ll compare using different scripting engines as well as newer built-in GitHub Actions solutions to extract code from a workflow.
The problem
Here’s a workflow that deploys files to an Amazon S3 bucket and then invalidates a CloudFront Distribution using the AWS CLI. This is a common situation for anyone deploying a static website to AWS.
name: Deployment Workflow
jobs: deploy: runs-on: ubuntu-latest steps: ... - name: Deploy to S3 run: aws s3 sync ./build/ s3://my-test-bucket --delete - name: Invalidate CloudFront run: aws cloudfront create-invalidation --distribution-id MYCLOUDFRONTID --paths /*This example has only two commands, and those two commands don’t have too many command-line options. However, real-world solutions will often be more complex.
Ultimately, it would be great to pull this into a dedicated GitHub Action (see the official GitHub guide for more details). There’s quite a lot of overhead with this approach, and if you’re only working on a single repo, it’s probably easiest to keep everything in that repo — this means either moving the logic into a script or looking at built-in solutions offered by GitHub Actions for workflow step reuse.
So how should our solution work?
Ideally the solution should tick the following boxes:
- Fast - CI/CD is all about quick feedback; we don’t want to make things noticeably slower. Also, time is money with GitHub Actions!
- Named parameters - we’re trying to make our workflow file easier to read by extracting implementation details. Let’s build on this by making sure the parameters we’re sending to the extracted steps/actions are properly labelled in the workflow file.
- Clean and maintainable - we want to make things easier to work with, so let’s make sure the solution we build doesn’t end up adding more complexity.
1. A Bash script
deploy.sh
#!/bin/bash
# Read named command line arguments into an args variable
declare -A args
while (( "$#" )); do if [[ $1 == --* ]] && [ "$2" ]; then args[${1:2}]=$2 shift fi shiftdone
# Do the deployment
aws s3 sync ./build/ "s3://${args[bucketname]}" --delete
if [ -n "${args[cloudfrontid]}" ]; then aws cloudfront create-invalidation --distribution-id "${args[cloudfrontid]}" --paths /*fiAnd inside the workflow yml file:
... steps: ... - name: Deploy run: ./scripts/deploy.sh --bucketname my-test-bucket --cloudfrontid MYCLOUDFRONTIDPros:
- One file and no dependencies
- The AWS commands inside the Bash file are nice and clear
Cons:
- It’s a Bash script with logic — not for everyone. That code to parse the command line is pretty impenetrable to anyone who isn’t a Bash ninja…
2. A Python script
deploy.py
import argparseimport os
parser = argparse.ArgumentParser(description='Deploy')parser.add_argument('--bucketname', dest='bucketname', required=True)parser.add_argument('--cloudfrontid', dest='cloudfrontid')
args = parser.parse_args()
os.system(f'aws s3 sync ./build/ s3://{args.bucketname} --delete')
if args.cloudfrontid: os.system(f'aws cloudfront create-invalidation --distribution-id {args.cloudfrontid} --paths /*')And inside the workflow yml file:
---- name: Deploy run: python3 ./scripts/deploy.py --bucketname my-test-bucket --cloudfrontid MYCLOUDFRONTIDPros:
- One file and no dependencies
- The code to parse the command line arguments is nice and explicit
Cons:
- The shell commands aren’t as clear as the Bash script (because of the explicit
os.systemcall), but overall, it’s pretty readable.
3. A PowerShell script
deploy.ps1
param( [Parameter(Mandatory)][string]$BucketName, [string]$CloudfrontID)
aws s3 sync ./build/ "s3://$BucketName" --deleteif ($CloudfrontID) { aws cloudfront create-invalidation --distribution-id $CloudfrontID --paths "/*"}And the entry in the workflow yml file:
---- name: Deploy run: pwsh ./scripts/deploy.ps1 -BucketName my-test-bucket -CloudfrontID MYCLOUDFRONTIDPros:
- One file and no dependencies
- The command-line argument expectations are clear
Cons:
- Historically it was a little slower to get going on Ubuntu, but should be faster now and likely no latency at all on a Windows runner!
4. A Node script
deploy.js
const { execSync } = require('node:child_process');
const args = process.argv.slice(2);const bucketname = args[args.indexOf('--bucketname') + 1];const cloudfrontid = args[args.indexOf('--cloudfrontid') + 1];
execSync(`aws s3 sync ./build/ s3://${bucketname} --delete`, { stdio: 'inherit' });
if (cloudfrontid) { execSync( `aws cloudfront create-invalidation --distribution-id ${cloudfrontid} --paths "/*"`, { stdio: 'inherit' }, );}And the workflow yml file:
---- name: Deploy run: node ./scripts/deploy.js --bucketname my-test-bucket --cloudfrontid MYCLOUDFRONTIDPros:
- The command-line argument expectations are clear
Cons:
execSyncadds some visual overhead versus just calling the command. In addition, an extra parameter is needed to make sure we see the command’s output in the logs.- The command-line argument parsing is a little awkward but still readable. This could be improved by using a package like ‘yargs’, but that would add extra dependencies.
5. A Node based GitHub Action in the same repo
For a step-by-step guide to using this method, see the article ‘Create your own local js GitHub Action with just two files’
deploy.js
const core = require('@actions/core');const { exec } = require('@actions/exec');
async function deploy() { const bucketName = core.getInput('bucket-name'); await exec(`aws s3 sync ./build/ s3://${bucketName} --delete`);
const cloudfrontID = core.getInput('cloudfront-id'); if (cloudfrontID) { await exec( `aws cloudfront create-invalidation --distribution-id ${cloudfrontID} --paths "/*"`, ); }}
deploy().catch(error => core.setFailed(error.message));action.yml
name: 'Deploy'description: 'Deploy action'inputs: bucket-name: description: 'S3 Bucket' required: true cloudfront-id: description: 'CloudFront ID'runs: using: 'node24' main: 'deploy.js'And the workflow yml file:
... - name: Deploy uses: ./actions/deploy with: bucket-name: my-test-bucket cloudfront-id: MYCLOUDFRONTIDPros:
- Parameters are nicely documented by the action.yml file
- The entry in the workflow file is clearer than cramming everything onto one line (as we do with the other scripts)
- Can be made into a standalone GitHub Action in the future
Cons:
- Dependent on the GitHub toolkit packages. If you’re working on a Node project, then just add these packages to your package.json file. Otherwise, you can include the package files in the repo — see the GitHub Actions documentation for an example.
- The async/await handling adds some extra complexity.
- Requires an additional action.yml file and we also need a certain directory structure — but on the other hand, this provides a nice separation of concerns, separating the parameter definition from the code.
6. GitHub Composite Actions
Not a script as such, but GitHub Composite Actions provide another good option: multiple workflow steps can be extracted and parameterized into a single action file and then called from the main workflow. Here’s the extracted composite action:
action.yml
name: 'Deploy'description: 'Deploy to S3 and invalidate CloudFront'inputs: bucket-name: description: 'S3 Bucket name' required: true cloudfront-id: description: 'CloudFront Distribution ID' required: false
runs: using: 'composite' steps: - name: Deploy to S3 run: aws s3 sync ./build s3://${{ inputs.bucket-name }} --delete - name: Invalidate CloudFront run: aws cloudfront create-invalidation --distribution-id ${{ inputs.cloudfront-id }} --paths "/*"And the workflow yml file:
---- name: Deploy uses: ./actions/deploy with: bucket-name: my-test-bucket cloudfront-id: MYCLOUDFRONTIDPros:
- Parameters are nicely documented by the action.yml file
- Calling the Action from the workflow file is clear
- Easy to extract the steps from an existing workflow file — no changes needed
Cons:
- Limited logic (compared to scripts) — it’s difficult to use anything beyond if statements
- Specific to GitHub, unlike scripts, which are more portable
7. BONUS - GitHub Reusable Workflows
Also worth a mention are GitHub Reusable Workflows — although these don’t allow you to extract individual steps, they do allow you to share all of the job’s step logic between entire workflows. Many situations of step reuse may be better served by reusing the entire workflow (rather than just a single step).
TL;DR
-
GitHub Composite Actions offer the most idiomatic solution for extracting individual workflow steps. However, scripts still provide more flexibility when complex logic is needed.
-
GitHub Reusable Workflows could also be an option for sharing entire job logic across workflows.
-
The Python script offers a good balance of clarity and maintainability.
-
Go with Bash or PowerShell if that’s your thing!
-
If you’re working on a Node project, then creating a local GitHub Action is a nice option. It integrates well with the workflow YAML file, and in the future, you’ve got options to upgrade to a fully-fledged action in its own repo.
See this tutorial for a step-by-step guide to create a local JavaScript GitHub Action.