| name | github-actions-dev |
| description | Developing custom GitHub Actions (JavaScript, TypeScript, Docker, Composite). Use this skill when the user asks to 'create a GitHub Action', 'build a custom action', 'publish action to marketplace', 'write action.yml', or 'develop reusable action'. |
GitHub Actions Development
Overview
Guide for developing custom GitHub Actions - the reusable units that are called with uses: in workflows. Covers JavaScript/TypeScript actions, Docker actions, and composite actions.
Action Types
| Type | Best For | Runtime |
|---|---|---|
| JavaScript/TypeScript | Fast startup, GitHub API integration | Node.js 20 |
| Docker | Custom environments, any language | Container |
| Composite | Orchestrating other actions | None (YAML) |
Project Structure
JavaScript/TypeScript Action
my-action/
├── action.yml # Action metadata
├── src/
│ ├── main.ts # Entry point
│ ├── input.ts # Input parsing
│ └── utils.ts # Helpers
├── dist/
│ └── index.js # Bundled output (committed)
├── __tests__/
│ └── main.test.ts # Tests
├── package.json
├── tsconfig.json
└── README.md
Docker Action
my-docker-action/
├── action.yml
├── Dockerfile
├── entrypoint.sh
└── README.md
Composite Action
my-composite-action/
├── action.yml # Contains all steps
└── README.md
action.yml Reference
JavaScript/TypeScript Action
name: 'My Action'
description: 'Does something useful'
author: 'Your Name'
branding:
icon: 'check-circle'
color: 'green'
inputs:
token:
description: 'GitHub token'
required: true
config-path:
description: 'Path to config file'
required: false
default: '.github/config.yml'
outputs:
result:
description: 'The result of the action'
artifact-url:
description: 'URL to uploaded artifact'
runs:
using: 'node20'
main: 'dist/index.js'
post: 'dist/cleanup.js' # Optional cleanup
post-if: 'always()' # When to run cleanup
Docker Action
name: 'My Docker Action'
description: 'Runs in a container'
inputs:
args:
description: 'Arguments to pass'
required: true
runs:
using: 'docker'
image: 'Dockerfile'
args:
- ${{ inputs.args }}
env:
CUSTOM_VAR: 'value'
Composite Action
name: 'My Composite Action'
description: 'Combines multiple steps'
inputs:
node-version:
description: 'Node.js version'
default: '20'
outputs:
cache-hit:
description: 'Whether cache was hit'
value: ${{ steps.cache.outputs.cache-hit }}
runs:
using: 'composite'
steps:
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
- id: cache
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }}
- run: npm ci
shell: bash
if: steps.cache.outputs.cache-hit != 'true'
JavaScript/TypeScript Development
Setup
# Initialize project
mkdir my-action && cd my-action
npm init -y
# Install action toolkit
npm install @actions/core @actions/github @actions/exec @actions/io @actions/cache
# Dev dependencies
npm install -D typescript @types/node @vercel/ncc jest @types/jest ts-jest
package.json
{
"name": "my-action",
"version": "1.0.0",
"main": "dist/index.js",
"scripts": {
"build": "ncc build src/main.ts -o dist --source-map --license licenses.txt",
"test": "jest",
"all": "npm run build && npm test"
}
}
tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"outDir": "./lib",
"rootDir": "./src",
"strict": true,
"noImplicitAny": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"exclude": ["node_modules", "dist", "__tests__"]
}
Main Entry Point
// src/main.ts
import * as core from '@actions/core';
import * as github from '@actions/github';
async function run(): Promise<void> {
try {
// Get inputs
const token = core.getInput('token', { required: true });
const configPath = core.getInput('config-path');
// Debug logging (only visible with ACTIONS_STEP_DEBUG)
core.debug(`Config path: ${configPath}`);
// Get GitHub context
const { owner, repo } = github.context.repo;
core.info(`Running on ${owner}/${repo}`);
// Create authenticated client
const octokit = github.getOctokit(token);
// Do work...
const result = await doWork(octokit, configPath);
// Set outputs
core.setOutput('result', result);
// Export variable for subsequent steps
core.exportVariable('MY_ACTION_RESULT', result);
} catch (error) {
if (error instanceof Error) {
core.setFailed(error.message);
}
}
}
run();
Action Toolkit APIs
import * as core from '@actions/core';
import * as github from '@actions/github';
import * as exec from '@actions/exec';
import * as io from '@actions/io';
import * as cache from '@actions/cache';
// --- @actions/core ---
// Inputs
const required = core.getInput('name', { required: true });
const optional = core.getInput('name'); // Empty string if not set
const multiline = core.getMultilineInput('items');
const boolean = core.getBooleanInput('flag');
// Outputs
core.setOutput('key', 'value');
// Logging
core.debug('Debug message'); // Only with ACTIONS_STEP_DEBUG
core.info('Info message');
core.notice('Notice annotation');
core.warning('Warning annotation');
core.error('Error annotation');
// Grouping
core.startGroup('Group name');
core.info('Inside group');
core.endGroup();
// Or with async
await core.group('Group name', async () => {
await someAsyncWork();
});
// Masking secrets
core.setSecret(sensitiveValue);
// Failure
core.setFailed('Action failed');
// --- @actions/github ---
// Context
const { owner, repo } = github.context.repo;
const sha = github.context.sha;
const ref = github.context.ref;
const actor = github.context.actor;
const eventName = github.context.eventName;
const payload = github.context.payload;
// Octokit client
const octokit = github.getOctokit(token);
const { data: issue } = await octokit.rest.issues.get({
owner,
repo,
issue_number: 1
});
// --- @actions/exec ---
// Run command
const exitCode = await exec.exec('npm', ['install']);
// Capture output
let output = '';
await exec.exec('git', ['rev-parse', 'HEAD'], {
listeners: {
stdout: (data) => { output += data.toString(); }
}
});
// --- @actions/io ---
// File operations
await io.mkdirP('/path/to/dir');
await io.cp('src', 'dest', { recursive: true });
await io.mv('old', 'new');
await io.rmRF('/path/to/remove');
const toolPath = await io.which('node', true); // Throws if not found
// --- @actions/cache ---
// Cache dependencies
const paths = ['node_modules'];
const key = `node-${process.env.RUNNER_OS}-${hashFiles('package-lock.json')}`;
const restoreKeys = [`node-${process.env.RUNNER_OS}-`];
const cacheKey = await cache.restoreCache(paths, key, restoreKeys);
if (!cacheKey) {
// Cache miss, install deps
await exec.exec('npm', ['ci']);
await cache.saveCache(paths, key);
}
Docker Action Development
Dockerfile
FROM node:20-alpine
LABEL maintainer="Your Name <email@example.com>"
LABEL com.github.actions.name="My Docker Action"
LABEL com.github.actions.description="Description"
LABEL com.github.actions.icon="check-circle"
LABEL com.github.actions.color="green"
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
entrypoint.sh
#!/bin/sh -l
# Inputs are passed as environment variables
# INPUT_<NAME> in uppercase
echo "Token: $INPUT_TOKEN"
echo "Config: $INPUT_CONFIG_PATH"
# Do work...
RESULT="success"
# Set output (write to $GITHUB_OUTPUT)
echo "result=$RESULT" >> $GITHUB_OUTPUT
# Set environment variable for subsequent steps
echo "MY_VAR=value" >> $GITHUB_ENV
Testing Actions
Unit Tests
// __tests__/main.test.ts
import * as core from '@actions/core';
import * as github from '@actions/github';
import { run } from '../src/main';
// Mock the toolkit
jest.mock('@actions/core');
jest.mock('@actions/github');
describe('action', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('sets output on success', async () => {
// Arrange
(core.getInput as jest.Mock).mockImplementation((name: string) => {
if (name === 'token') return 'fake-token';
return '';
});
// Act
await run();
// Assert
expect(core.setOutput).toHaveBeenCalledWith('result', expect.any(String));
expect(core.setFailed).not.toHaveBeenCalled();
});
it('fails when token missing', async () => {
(core.getInput as jest.Mock).mockImplementation(() => {
throw new Error('Input required: token');
});
await run();
expect(core.setFailed).toHaveBeenCalledWith('Input required: token');
});
});
Integration Testing
# .github/workflows/test.yml
name: Test Action
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run action
id: test
uses: ./
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Verify output
run: |
if [ "${{ steps.test.outputs.result }}" != "expected" ]; then
echo "Unexpected output"
exit 1
fi
Local Testing with Act
# Test the action locally
act -j test -s GITHUB_TOKEN="$(gh auth token)"
# With specific event
act push -j test
Publishing to Marketplace
Requirements
- Public repository
action.ymlin repository root- README.md with documentation
- Semantic versioning with tags
Release Process
# Tag release
git tag -a v1.0.0 -m "Release v1.0.0"
git push origin v1.0.0
# Create major version tag (for users: uses: org/action@v1)
git tag -fa v1 -m "Update v1 tag"
git push origin v1 --force
Release Workflow
# .github/workflows/release.yml
name: Release
on:
release:
types: [published]
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Update major version tag
run: |
VERSION=${GITHUB_REF#refs/tags/}
MAJOR=${VERSION%%.*}
git tag -fa $MAJOR -m "Update $MAJOR tag"
git push origin $MAJOR --force
Best Practices
Input Validation
function validateInputs(): Config {
const token = core.getInput('token', { required: true });
if (!token.startsWith('ghp_') && !token.startsWith('ghs_')) {
throw new Error('Invalid token format');
}
const timeout = parseInt(core.getInput('timeout') || '30', 10);
if (isNaN(timeout) || timeout < 1 || timeout > 300) {
throw new Error('Timeout must be between 1 and 300');
}
return { token, timeout };
}
Error Handling
try {
await run();
} catch (error) {
if (error instanceof Error) {
// Add error annotation to file if available
core.error(error.message, {
file: 'src/main.ts',
startLine: 10
});
core.setFailed(error.message);
} else {
core.setFailed('An unexpected error occurred');
}
}
Idempotency
Design actions to be safely re-run:
- Check if work already done before doing it
- Use conditional creation (if not exists)
- Clean up partial state on failure
See Also
- Reference: toolkit-api.md
- Reference: publishing.md
- Assets: templates/