jonelantha's avatar

jonelantha: Blog

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:

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
shift
done
# 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 /*
fi

And inside the workflow yml file:

...
steps:
...
- name: Deploy
run: ./scripts/deploy.sh --bucketname my-test-bucket --cloudfrontid MYCLOUDFRONTID

Pros:

Cons:

2. A Python script

deploy.py

import argparse
import 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 MYCLOUDFRONTID

Pros:

Cons:

3. A PowerShell script

deploy.ps1

Terminal window
param(
[Parameter(Mandatory)][string]$BucketName,
[string]$CloudfrontID
)
aws s3 sync ./build/ "s3://$BucketName" --delete
if ($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 MYCLOUDFRONTID

Pros:

Cons:

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 MYCLOUDFRONTID

Pros:

Cons:

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: MYCLOUDFRONTID

Pros:

Cons:

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: MYCLOUDFRONTID

Pros:

Cons:

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