$ cat github-actions/part-05.md
Building Custom Actions in GitHub Actions: Composite, JavaScript & Docker

After covering the fundamentals, now I want to share how to create your own reusable actions. This is where GitHub Actions becomes really powerful you can package your logic once and use it everywhere.
GitHub Actions supports three types of custom actions. I built all three in my home lab, and I’ll show you exactly how they work.
Why Create Custom Actions?
Before diving into code, here’s why I started building custom actions:
- DRY principle: Stop copying the same steps across workflows
- Encapsulation: Hide complexity behind simple interfaces
- Reusability: Use the same action in multiple projects
- Versioning: Update action logic without changing workflows
Type 1: Composite Actions
Composite actions combine multiple steps into one reusable action. Think of it as a function that wraps several workflow steps.
Here’s my composite action that sets up Node.js and caches dependencies:
File: actions/composite-cache-deps/action.yaml
name: Cache Node and NPM Dependencies
description: This action allows to cache both Node and NPM dependencies based on the package-lock.json file.
inputs:
node_version:
description: The Node.js version to use
required: true
default: '20.x'
working-dir:
description: The working directory where package-lock.json is located
required: false
default: '.'
runs:
using: composite
steps:
- name: Setup node js
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node_version }}
- name: Cache NPM dependencies
uses: actions/cache@v4
with:
path: ${{inputs.working-dir}}/node_modules
key: node-modules-${{ inputs.target-env }}-${{ hashFiles(format('{0}/{1}', inputs.working-dir, 'package-lock.json')) }}
- name: Install NPM dependencies
if: steps.cache.outputs.cache-hit != 'true'
run: ${{ inputs.target-env == 'dev' && 'npm install' || 'npm install --omit=dev' }}
shell: bash
working-directory: ${{ inputs.working-dir }}
How It Works
1. Define inputs:
inputs:
node_version:
description: The Node.js version to use
required: true
default: '20.x'
These work like function parameters. Users pass values when they use your action.
2. Set the action type:
runs:
using: composite
The composite type tells GitHub this action contains multiple steps.
3. Write your steps:
steps:
- name: Setup node js
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node_version }}
Access inputs with ${{ inputs.INPUT_NAME }}.
Important: In composite actions, every run step MUST specify shell:
- name: Install NPM dependencies
run: npm install
shell: bash # This is required!
Using the Composite Action
Here’s my workflow that uses this action:
File: workflows/17-1-custom-actions-composite.yaml
name: 17 - 1 - Custom Actions - Composite
run-name: 17 - 1 - Custom Actions - Composite | env - ${{ inputs.target-env }}
on:
workflow_dispatch:
inputs:
target-env:
description: Which environment to target for dependency installation
type: choice
options:
- dev
- prod
env:
working-directory: react-app
jobs:
build:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ${{ env.working-directory }}
steps:
- uses: actions/checkout@v4
- name: Setup Node and NPM Dependencies
id: setup-deps
uses: ./.github/actions/composite-cache-deps
with:
node-version: 20.x
working-dir: ${{ env.working-directory }}
target-env: ${{ inputs.target-env }}
- name: Print setup deps output
run: |
echo "Installed dependencies: ${{ steps.setup-deps.outputs.installed-deps }}"
- name: Test
run: npm run test
- name: Build
run: npm run build
Key points:
The path uses: ./.github/actions/composite-cache-deps tells GitHub to use a local action from my repo.
I can pass inputs just like with marketplace actions:
with:
node-version: 20.x
working-dir: ${{ env.working-directory }}
This action runs 3 steps (setup Node, cache, install) but my workflow only shows 1 step.
Type 2: JavaScript Actions
JavaScript actions run Node.js code. They’re more powerful than composite actions because you can write complex logic, make API calls, and interact with GitHub.
I built a JavaScript action that checks for dependency updates and creates a pull request automatically.
File: actions/js-dependency-update/action.yaml
name: Update NPM Dependencies
description: Checks if there are updates to NPM packages, and creates a PR with the updated package*.json files.
runs:
using: "node20"
main: "index.js"
Much simpler! Just tell GitHub:
- using: “node20” – Run with Node.js 20
- main: “index.js” – This is the entry point
File: actions/js-dependency-update/index.js
const core = require('@actions/core');
const exec = require('@actions/exec');
const github = require('@actions/github');
const setupGit = async () => {
await exec.exec(`git config --global user.name "gh-automation"`);
await exec.exec(`git config --global user.email "gh-automation@email.com"`);
};
const validateBranchName = ({ branchName }) =>
/^[a-zA-Z0-9_\-\.\/]+$/.test(branchName);
const validateDirectoryName = ({ dirName }) =>
/^[a-zA-Z0-9_\-\/]+$/.test(dirName);
const setupLogger = ({ debug, prefix } = { debug: false, prefix: '' }) => ({
debug: (message) => {
if (debug) {
core.info(`DEBUG ${prefix}${prefix ? ' : ' : ''}${message}`);
}
},
info: (message) => {
core.info(`${prefix}${prefix ? ' : ' : ''}${message}`);
},
error: (message) => {
core.error(`${prefix}${prefix ? ' : ' : ''}${message}`);
},
});
async function run() {
const baseBranch = core.getInput('base-branch', { required: true });
const headBranch = core.getInput('head-branch', { required: true });
const ghToken = core.getInput('gh-token', { required: true });
const workingDir = core.getInput('working-directory', { required: true });
const debug = core.getBooleanInput('debug');
const logger = setupLogger({ debug, prefix: '[js-dependency-update]' });
const commonExecOpts = {
cwd: workingDir,
};
core.setSecret(ghToken);
logger.debug('Validating inputs base-branch, head-branch, working-directory');
if (!validateBranchName({ branchName: baseBranch })) {
core.setFailed(
'Invalid base-branch name. Branch names should include only characters, numbers, hyphens, underscores, dots, and forward slashes.'
);
return;
}
if (!validateBranchName({ branchName: headBranch })) {
core.setFailed(
'Invalid head-branch name. Branch names should include only characters, numbers, hyphens, underscores, dots, and forward slashes.'
);
return;
}
if (!validateDirectoryName({ dirName: workingDir })) {
core.setFailed(
'Invalid working directory name. Directory names should include only characters, numbers, hyphens, underscores, and forward slashes.'
);
return;
}
logger.debug(`Base branch is ${baseBranch}`);
logger.debug(`Head branch is ${headBranch}`);
logger.debug(`Working directory is ${workingDir}`);
logger.debug('Checking for package updates');
await exec.exec('npm update', [], {
...commonExecOpts,
});
const gitStatus = await exec.getExecOutput(
'git status -s package*.json',
[],
{
...commonExecOpts,
}
);
let updatesAvailable = false;
if (gitStatus.stdout.length > 0) {
updatesAvailable = true;
logger.debug('There are updates available!');
logger.debug('Setting up git');
await setupGit();
logger.debug('Committing and pushing package*.json changes');
await exec.exec(`git checkout -b ${headBranch}`, [], {
...commonExecOpts,
});
await exec.exec(`git add package.json package-lock.json`, [], {
...commonExecOpts,
});
await exec.exec(`git commit -m "chore: update dependencies`, [], {
...commonExecOpts,
});
await exec.exec(`git push -u origin ${headBranch} --force`, [], {
...commonExecOpts,
});
logger.debug('Fetching octokit API');
const octokit = github.getOctokit(ghToken);
try {
logger.debug(`Creating PR using head branch ${headBranch}`);
await octokit.rest.pulls.create({
owner: github.context.repo.owner,
repo: github.context.repo.repo,
title: `Update NPM dependencies`,
body: `This pull request updates NPM packages`,
base: baseBranch,
head: headBranch,
});
} catch (e) {
logger.error(
'Something went wrong while creating the PR. Check logs below.'
);
core.setFailed(e.message);
logger.error(e);
}
} else {
logger.info('No updates at this point in time.');
}
logger.debug(`Setting updates-available output to ${updatesAvailable}`);
core.setOutput('updates-available', updatesAvailable);
}
run();
Understanding the JavaScript Action
1. Using @actions toolkit:
const core = require('@actions/core');
const exec = require('@actions/exec');
const github = require('@actions/github');
GitHub provides these packages for common tasks:
- @actions/core – Get inputs, set outputs, log messages
- @actions/exec – Run shell commands
- @actions/github – Interact with GitHub API
2. Getting inputs:
const baseBranch = core.getInput('base-branch', { required: true });
const workingDir = core.getInput('working-directory', { required: true });
const debug = core.getBooleanInput('debug');
3. Setting outputs:
core.setOutput('updates-available', updatesAvailable);
4. Marking secrets:
core.setSecret(ghToken);
This masks the token in logs so it never appears in plain text.
5. Running commands:
await exec.exec('npm update', [], {
cwd: workingDir,
});
6. Using GitHub API:
const octokit = github.getOctokit(ghToken);
await octokit.rest.pulls.create({
owner: github.context.repo.owner,
repo: github.context.repo.repo,
title: `Update NPM dependencies`,
body: `This pull request updates NPM packages`,
base: baseBranch,
head: headBranch,
});
This creates a pull request programmatically!
File: actions/js-dependency-update/package.json
{
"name": "js-dependency-update",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"@actions/core": "^1.11.1",
"@actions/exec": "^1.1.1",
"@actions/github": "^6.0.1"
}
}
Using the JavaScript Action
File: workflows/17-2-custom-actions-js.yaml
name: 17 - 2 - Custom Actions - JS
run-name: 17 - 2 - Custom Actions - JS | BB - ${{ inputs.base-branch }} TB - ${{ inputs.target-branch }} WD - ${{ inputs.working-dir }}
on:
workflow_dispatch:
inputs:
base-branch:
description: 'Base branch for the dependency update'
type: string
default: main
target-branch:
description: 'Target branch for the dependency update'
type: string
default: update-dependencies
working-dir:
description: 'Working directory for the dependency update'
type: string
default: 17-custom-actions/react-app
debug:
description: 'Enable debug mode for the dependency update'
type: boolean
default: false
permissions:
contents: write
pull-requests: write
jobs:
dependency-update:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Check for dependency updates
id: update-deps
uses: ./.github/actions/js-dependency-update
with:
base-branch: ${{ inputs.base-branch }}
head-branch: ${{ inputs.target-branch }}
working-directory: ${{ inputs.working-dir }}
gh-token: ${{ secrets.GITHUB_TOKEN }}
debug: ${{ inputs.debug }}
- name: Print custom action output
run: |
echo "Updates available: ${{ steps.update-deps.outputs.updates-available }}"
Important: Notice the permissions section:
permissions:
contents: write
pull-requests: write
JavaScript actions that interact with GitHub need explicit permissions.
Type 3: Docker Actions
Docker actions run in containers. They’re perfect when you need specific tools or languages that aren’t available in GitHub runners.
I built a Docker action that pings a URL to check if it’s reachable.
File: actions/docker-ping-url/action.yaml
name: Ping URL
description: Ping URL until maximum trials have exceeded. If result is not 200 until then, fails the action.
inputs:
url:
description: URL to ping.
required: true
max_trials:
description: Maximum number of trials until action fails.
default: '10'
required: false
delay:
description: Delay in seconds between trials.
default: '5'
required: false
outputs:
url-reachable:
description: Whether the URL is reachable
runs:
using: docker
image: Dockerfile
Instead of composite or node20, we use docker and point to a Dockerfile.
File: actions/docker-ping-url/Dockerfile
FROM python:alpine3.19
WORKDIR /app
COPY requirements.txt .
RUN python -V
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD [ "python", "/app/main.py" ]
This creates a Python environment with the dependencies I need.
File: actions/docker-ping-url/requirements.txt
certifi==2023.11.17
charset-normalizer==3.3.2
idna==3.6
requests==2.31.0
urllib3==2.1.0
File: actions/docker-ping-url/main.py
import os
import requests
import time
def set_output(file_path, key, value):
with open(file_path, 'a') as file:
print(f'{key}={value}', file=file)
def ping_url(url, delay, max_trials):
trials = 0
while trials < max_trials:
try:
response = requests.get(url)
if response.status_code == 200:
print(f"Website {url} is reachable.")
return True
except requests.ConnectionError:
print(f"Website {url} is unreachable. Retrying in {delay} seconds...")
time.sleep(delay)
trials += 1
except requests.exceptions.MissingSchema:
print(f"Invalid URL format: {url}. Make sure the URL has a valid schema (e.g., http:// or https://)")
return False
return False
def run():
website_url = os.getenv("INPUT_URL")
delay = int(os.getenv("INPUT_DELAY"))
max_trials = int(os.getenv("INPUT_MAX_TRIALS"))
website_reachable = ping_url(website_url, delay, max_trials)
set_output(os.getenv('GITHUB_OUTPUT'), 'url-reachable', website_reachable)
if not website_reachable:
raise Exception(f"Website {website_url} is malformed or unreachable.")
print(f"Website {website_url} is reachable.")
if __name__ == "__main__":
run()
Understanding Docker Actions
1. Input variables become environment variables:
GitHub automatically converts inputs to environment variables with INPUT_ prefix:
- url input → INPUT_URL environment variable
- max_trials → INPUT_MAX_TRIALS
- delay → INPUT_DELAY
website_url = os.getenv("INPUT_URL")
delay = int(os.getenv("INPUT_DELAY"))
2. Setting outputs:
def set_output(file_path, key, value):
with open(file_path, 'a') as file:
print(f'{key}={value}', file=file)
set_output(os.getenv('GITHUB_OUTPUT'), 'url-reachable', website_reachable)
Same concept as JavaScript – write to $GITHUB_OUTPUT file.
3. The .dockerignore file:
File: actions/docker-ping-url/.dockerignore
venv
This prevents copying unnecessary files into the Docker image.
Using the Docker Action
File: workflows/17-3-custom-actions-docker.yaml
name: 17 - 3 - Custom Actions - Docker
on:
workflow_dispatch:
inputs:
url:
description: URL to ping for reachability test.
type: choice
options:
- 'https://www.google.com'
- 'https://www.invalid-aidfghaidgfiazgd.com'
- 'invalid-aidfghaidgfiazgd'
max_trials:
description: Maximum trials until action fails.
default: '10'
required: false
delay:
description: Delay in seconds between trials.
default: '5'
required: false
jobs:
ping-url:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Ping URL
id: ping-url
uses: ./.github/actions/docker-ping-url
with:
url: ${{ inputs.url }}
max_trials: ${{ inputs.max_trials }}
delay: ${{ inputs.delay }}
- name: Print output from ping url
run: |
echo "URL reachable: ${{ steps.ping-url.outputs.url-reachable }}"
I can test with valid and invalid URLs to see how the action behaves.
Comparing the Three Types
| Feature | Composite | JavaScript | Docker |
|---|---|---|---|
| Language | YAML (workflow steps) | JavaScript (Node.js) | Any language |
| Complexity | Simple | Medium | Complex |
| Speed | Fast | Fast | Slow (builds image) |
| Dependencies | Actions marketplace only | npm packages | Anything in Docker |
| Best for | Combining existing steps | GitHub API interaction | Custom tools/languages |
| Shell required? | Yes (in run steps) | No | N/A |
When to Use Each Type
Use Composite when:
- You’re combining existing actions/steps
- You don’t need custom logic
- Speed is important
- Example: Setup Node + Cache + Install dependencies
Use JavaScript when:
- You need to interact with GitHub API
- You want complex conditional logic
- You need to process data
- Example: Create PRs, manage issues, analyze workflows
Use Docker when:
- You need specific tools not in GitHub runners
- You’re using a language other than JavaScript
- You need exact dependency versions
- Example: Python scripts, Ruby gems, custom binaries
What I Learned Building These Actions
- Composite actions must specify shell: Every run: step needs shell: bash or shell: pwsh.
- JavaScript actions need node_modules: You must commit node_modules/ or users can’t run your action. Add this to .gitignore exception:
- Docker actions are slow: Building the Docker image takes time. First run can take 2-3 minutes. Subsequent runs are faster if image is cached.
- Inputs are always strings: Even if you pass a boolean or number, it arrives as a string. Convert it:
- Use core.setSecret(): Mask sensitive data in logs:
- Validate inputs early: Check inputs before doing work. My JavaScript action validates branch names and directory paths at the start.
- Outputs are optional: Not every action needs outputs. My composite action doesn’t have any because caching happens silently.
- Test locally first: For JavaScript actions, run node index.js with environment variables set. For Docker actions, build and run the image locally.
- Working directory matters: Composite actions inherit working directory from the job, but you can override it per step. JavaScript and Docker actions always run from the action directory.
- Error handling is critical: Always handle failures gracefully:
Real-World Example: Combining Custom Actions
Here’s how I use all three in one workflow:
name: Complete CI/CD with Custom Actions
on:
push:
branches: [main]
jobs:
setup-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Composite action
- name: Setup dependencies
uses: ./.github/actions/composite-cache-deps
with:
node-version: 20.x
working-dir: ./app
- name: Test
run: npm test
working-directory: ./app
check-dependencies:
runs-on: ubuntu-latest
needs: setup-and-test
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v4
# JavaScript action
- name: Update dependencies
uses: ./.github/actions/js-dependency-update
with:
base-branch: main
head-branch: update-deps
working-directory: ./app
gh-token: ${{ secrets.GITHUB_TOKEN }}
deploy-and-verify:
runs-on: ubuntu-latest
needs: setup-and-test
steps:
- uses: actions/checkout@v4
- name: Deploy application
run: echo "Deploying..."
# Docker action
- name: Verify deployment
uses: ./.github/actions/docker-ping-url
with:
url: https://my-app.example.com
max_trials: 15
delay: 10
This workflow:
- Uses composite action to cache and install dependencies
- Uses JavaScript action to check for updates and create PR
- Uses Docker action to verify deployment health