Claude Code Plugins

Community-maintained marketplace

Feedback

github-actions-dev

@aRustyDev/ai
0
0

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'.

Install Skill

1Download skill
2Enable skills in Claude

Open claude.ai/settings/capabilities and find the "Skills" section

3Upload to Claude

Click "Upload skill" and select the downloaded ZIP file

Note: Please verify skill by going through its instructions before using it.

SKILL.md

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

  1. Public repository
  2. action.yml in repository root
  3. README.md with documentation
  4. 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