Back to Software Engineering & Delivery Mastery Series GitHub Actions Bootcamp

Module 8: Building Custom Actions

June 2, 2026 Wasil Zafar 32 min read

Build your own GitHub Actions from scratch — composite actions for simple orchestration, JavaScript actions for speed and flexibility, Docker actions for full environment control — then publish them to Marketplace with automated releases.

Table of Contents

  1. Types of Custom Actions
  2. Composite Actions
  3. JavaScript Actions
  4. Docker-based Actions
  5. Inputs and Outputs
  6. Official GitHub Templates
  7. Publishing to Marketplace
  8. Automating Action Releases
  9. Exercises

Types of Custom Actions

GitHub Actions supports three runtime types for custom actions: Composite, JavaScript, and Docker. Each trades off portability, startup speed, and flexibility differently. Understanding these trade-offs is essential before building your own action.

Action Resolution Flow
flowchart TD
    W[Workflow Step: uses] --> L{Local Path?}
    L -->|Yes ./path| LOCAL[Local Action
in repository] L -->|No| R{Remote Reference?} R -->|owner/repo@ref| REMOTE[GitHub Repository] R -->|No| M{Marketplace?} M -->|Published action| MP[GitHub Marketplace] REMOTE --> RESOLVE[Download action.yml] MP --> RESOLVE LOCAL --> RESOLVE RESOLVE --> TYPE{using: ?} TYPE -->|composite| COMP[Run composite steps] TYPE -->|node20| JS[Run Node.js dist/index.js] TYPE -->|docker| DOCK[Build/Pull Docker image] style W fill:#132440,color:#fff style RESOLVE fill:#3B9797,color:#fff style COMP fill:#16476A,color:#fff style JS fill:#16476A,color:#fff style DOCK fill:#16476A,color:#fff
Feature Composite JavaScript (Node.js) Docker
Runtime Runner shell (bash/pwsh) Node.js 20 Container
Startup Speed Instant (no overhead) Fast (~1-2s) Slow (build/pull image)
Runner Compatibility All (Linux, macOS, Windows) All (Linux, macOS, Windows) Linux only
Complexity Low (just YAML) Medium (Node.js + bundling) High (Dockerfile + entrypoint)
Language Shell commands + other actions JavaScript/TypeScript Any (Python, Go, Rust, etc.)
Dependencies Whatever runner provides Bundled via ncc Full control (Dockerfile)
Access to Runner Full filesystem access Full filesystem access Isolated (mounted workspace)
Best For Orchestrating existing actions API calls, GitHub integrations Custom toolchains, any language

When to Use Each Type

Decision Framework: Start with composite actions for simple multi-step orchestration. Graduate to JavaScript when you need the GitHub API or complex logic. Use Docker only when you need a specific runtime environment (Python, Go, Rust) or system-level dependencies that aren't available on runners.
  • Composite: Wrapping a sequence of shell commands and existing actions into a reusable unit. Perfect for team-specific "setup" or "deploy" actions.
  • JavaScript: Interacting with the GitHub API, processing webhook payloads, making HTTP requests, complex string/JSON manipulation. The @actions/github toolkit gives authenticated Octokit access out of the box.
  • Docker: Running tools not available on GitHub runners, using languages other than JavaScript, or when you need a reproducible environment with specific system packages.

Creating Composite Actions

Composite actions are the simplest type — they're pure YAML that combine multiple run commands and uses steps into a single reusable action. They execute directly on the runner with no additional runtime overhead.

# .github/actions/setup-node-project/action.yml
name: 'Setup Node Project'
description: 'Install Node.js, restore cache, and install dependencies'
inputs:
  node-version:
    description: 'Node.js version to use'
    required: false
    default: '20'
  working-directory:
    description: 'Directory containing package.json'
    required: false
    default: '.'
outputs:
  cache-hit:
    description: 'Whether the cache was hit'
    value: ${{ steps.cache.outputs.cache-hit }}

runs:
  using: 'composite'
  steps:
    - name: Setup Node.js
      uses: actions/setup-node@v4
      with:
        node-version: ${{ inputs.node-version }}

    - name: Cache node_modules
      id: cache
      uses: actions/cache@v4
      with:
        path: ${{ inputs.working-directory }}/node_modules
        key: node-${{ runner.os }}-${{ hashFiles(format('{0}/package-lock.json', inputs.working-directory)) }}
        restore-keys: |
          node-${{ runner.os }}-

    - name: Install dependencies
      if: steps.cache.outputs.cache-hit != 'true'
      shell: bash
      working-directory: ${{ inputs.working-directory }}
      run: npm ci

    - name: Show installed version
      shell: bash
      run: |
        echo "Node: $(node --version)"
        echo "npm: $(npm --version)"
        echo "Cache hit: ${{ steps.cache.outputs.cache-hit }}"

Key rules for composite actions:

  • Every run step must specify shell: (bash, pwsh, python, etc.)
  • You can use uses: to call other actions inside a composite action
  • Inputs are accessed via ${{ inputs.name }}, not ${{ github.event.inputs.name }}
  • Outputs must reference a step ID: value: ${{ steps.step-id.outputs.key }}
  • Composite actions cannot use secrets context directly — pass secrets as inputs

Sharing Composite Actions Between Repos

To share a composite action across repositories, publish it in a dedicated repo or a shared .github repository:

# In your workflow, reference the shared action:
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      # Reference action from another repository
      - uses: your-org/shared-actions/setup-node-project@v1
        with:
          node-version: '20'

      # Or reference a local action in the same repo
      - uses: ./.github/actions/setup-node-project
        with:
          node-version: '18'
Monorepo Pattern: Store multiple actions in a single repo under separate directories (e.g., your-org/actions/setup-node, your-org/actions/deploy-k8s). Reference them as your-org/actions/setup-node@v1. This centralizes maintenance while keeping actions logically separate.

Building JavaScript Actions

JavaScript actions run on Node.js 20 and have access to the full @actions toolkit — a set of packages for interacting with the runner, GitHub API, filesystem, and process execution. They start fast (no container overhead) and work on all runner OSes.

The core toolkit packages:

  • @actions/core — Inputs, outputs, logging, annotations, secrets masking, summary
  • @actions/github — Authenticated Octokit client for the GitHub API
  • @actions/exec — Execute shell commands with streaming output
  • @actions/io — File operations (cp, mv, rmRF, mkdirP, which)
  • @actions/tool-cache — Download and cache tools
  • @actions/artifact — Upload/download workflow artifacts
  • @actions/cache — Save/restore runner cache
// src/index.js — A JavaScript action that labels PRs by file paths
const core = require('@actions/core');
const github = require('@actions/github');

async function run() {
  try {
    // Get inputs defined in action.yml
    const token = core.getInput('github-token', { required: true });
    const configPath = core.getInput('config-path') || '.github/labeler.yml';

    // Create authenticated GitHub client
    const octokit = github.getOctokit(token);
    const { context } = github;

    // Only run on pull requests
    if (!context.payload.pull_request) {
      core.info('Not a pull request event, skipping.');
      return;
    }

    const prNumber = context.payload.pull_request.number;
    core.info(`Processing PR #${prNumber}`);

    // Get changed files
    const { data: files } = await octokit.rest.pulls.listFiles({
      ...context.repo,
      pull_number: prNumber,
    });

    const changedPaths = files.map(f => f.filename);
    core.info(`Changed files: ${changedPaths.join(', ')}`);

    // Determine labels based on paths
    const labels = [];
    if (changedPaths.some(p => p.startsWith('src/'))) labels.push('source');
    if (changedPaths.some(p => p.startsWith('docs/'))) labels.push('documentation');
    if (changedPaths.some(p => p.startsWith('test/'))) labels.push('tests');
    if (changedPaths.some(p => p.endsWith('.yml') || p.endsWith('.yaml'))) labels.push('config');

    if (labels.length > 0) {
      await octokit.rest.issues.addLabels({
        ...context.repo,
        issue_number: prNumber,
        labels,
      });
      core.info(`Added labels: ${labels.join(', ')}`);
    }

    // Set outputs for downstream steps
    core.setOutput('labels-added', labels.join(','));
    core.setOutput('files-changed', changedPaths.length.toString());

    // Write to job summary
    await core.summary
      .addHeading('PR Labeler Results')
      .addTable([
        [{ data: 'Metric', header: true }, { data: 'Value', header: true }],
        ['Files Changed', changedPaths.length.toString()],
        ['Labels Added', labels.join(', ') || 'None'],
      ])
      .write();

  } catch (error) {
    core.setFailed(`Action failed: ${error.message}`);
  }
}

run();

The corresponding action.yml:

# action.yml
name: 'PR Path Labeler'
description: 'Automatically labels pull requests based on changed file paths'
inputs:
  github-token:
    description: 'GitHub token for API access'
    required: true
    default: ${{ github.token }}
  config-path:
    description: 'Path to labeler configuration file'
    required: false
    default: '.github/labeler.yml'
outputs:
  labels-added:
    description: 'Comma-separated list of labels that were added'
  files-changed:
    description: 'Number of files changed in the PR'
runs:
  using: 'node20'
  main: 'dist/index.js'

Bundling with ncc and TypeScript Support

JavaScript actions must ship with all dependencies bundled — you cannot rely on npm install at runtime. Use @vercel/ncc to compile everything into a single file:

{
  "name": "pr-path-labeler",
  "version": "1.0.0",
  "private": true,
  "main": "dist/index.js",
  "scripts": {
    "build": "ncc build src/index.js -o dist --source-map --license licenses.txt",
    "test": "jest",
    "lint": "eslint src/",
    "all": "npm run lint && npm run test && npm run build"
  },
  "dependencies": {
    "@actions/core": "^1.10.1",
    "@actions/github": "^6.0.0"
  },
  "devDependencies": {
    "@vercel/ncc": "^0.38.1",
    "jest": "^29.7.0",
    "eslint": "^8.56.0"
  }
}
# Build the action — produces dist/index.js with all deps bundled
npm run build

# The dist/ directory MUST be committed to the repository
git add dist/
git commit -m "build: compile action"

For TypeScript, use @vercel/ncc with tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "lib": ["ES2022"],
    "outDir": "./lib",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true
  },
  "exclude": ["node_modules", "dist", "__tests__"]
}
# For TypeScript actions, ncc handles compilation automatically:
npx ncc build src/index.ts -o dist --source-map --license licenses.txt
Supply Chain Security: Always pin actions by full commit SHA, not by tag. Tags are mutable — a compromised maintainer can move a tag to malicious code. Use uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 (the SHA for v4.1.0) instead of uses: actions/checkout@v4. Tools like pinact and Dependabot can automate SHA pinning across your workflows.

Building Docker-based Actions

Docker actions run inside a container, giving you complete control over the execution environment. Use them when you need specific system packages, languages other than JavaScript, or reproducible toolchains. The trade-off is startup time (building/pulling images) and Linux-only runner support.

# Dockerfile
FROM python:3.12-slim

# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
    git \
    curl \
    jq \
    && rm -rf /var/lib/apt/lists/*

# Install Python dependencies
COPY requirements.txt /requirements.txt
RUN pip install --no-cache-dir -r /requirements.txt

# Copy action code
COPY entrypoint.sh /entrypoint.sh
COPY src/ /src/

RUN chmod +x /entrypoint.sh

ENTRYPOINT ["/entrypoint.sh"]
# action.yml for Docker action
name: 'Python Code Analyzer'
description: 'Runs static analysis on Python code using custom rules'
inputs:
  source-path:
    description: 'Path to Python source files'
    required: false
    default: 'src/'
  severity:
    description: 'Minimum severity level to report (info, warning, error)'
    required: false
    default: 'warning'
  config-file:
    description: 'Path to analyzer configuration'
    required: false
outputs:
  issues-found:
    description: 'Number of issues found'
  report-path:
    description: 'Path to the generated report file'
runs:
  using: 'docker'
  image: 'Dockerfile'
  args:
    - ${{ inputs.source-path }}
    - ${{ inputs.severity }}
  env:
    ANALYZER_CONFIG: ${{ inputs.config-file }}

Entrypoint Scripts

The entrypoint script receives inputs via positional arguments (args) or environment variables (env). It communicates outputs back to GitHub via workflow commands written to $GITHUB_OUTPUT:

#!/bin/bash
# entrypoint.sh
set -euo pipefail

SOURCE_PATH="${1:-src/}"
SEVERITY="${2:-warning}"

echo "::group::Running Python Analyzer"
echo "Source: ${SOURCE_PATH}"
echo "Severity: ${SEVERITY}"
echo "Config: ${ANALYZER_CONFIG:-default}"

# Run the analysis
REPORT_FILE="/tmp/analysis-report.json"
python /src/analyzer.py \
  --source "${SOURCE_PATH}" \
  --severity "${SEVERITY}" \
  --config "${ANALYZER_CONFIG:-}" \
  --output "${REPORT_FILE}"

# Count issues
ISSUES=$(jq '.issues | length' "${REPORT_FILE}")
echo "Found ${ISSUES} issues"
echo "::endgroup::"

# Copy report to workspace (mounted at /github/workspace)
cp "${REPORT_FILE}" "${GITHUB_WORKSPACE}/analysis-report.json"

# Set outputs using GITHUB_OUTPUT file
echo "issues-found=${ISSUES}" >> "${GITHUB_OUTPUT}"
echo "report-path=analysis-report.json" >> "${GITHUB_OUTPUT}"

# Fail if critical issues found
CRITICAL=$(jq '[.issues[] | select(.severity == "error")] | length' "${REPORT_FILE}")
if [ "${CRITICAL}" -gt 0 ]; then
  echo "::error::Found ${CRITICAL} critical issues!"
  exit 1
fi

You can also use a pre-built image instead of building from a Dockerfile:

# action.yml using pre-built image (faster startup)
runs:
  using: 'docker'
  image: 'docker://ghcr.io/your-org/python-analyzer:1.2.0'
  args:
    - ${{ inputs.source-path }}
    - ${{ inputs.severity }}
Performance Tip: Pre-built images from a registry (GHCR, Docker Hub) are significantly faster than building from a Dockerfile on every run. Use image: 'docker://ghcr.io/org/image:tag' in production. Reserve image: 'Dockerfile' for development and testing.

Adding Inputs and Outputs to Custom Actions

Inputs and outputs are the public API of your action. Well-designed inputs have sensible defaults, clear descriptions, and explicit required flags. Outputs communicate results to downstream steps.

# action.yml — Complete inputs/outputs example
name: 'Deploy to Environment'
description: 'Deploy application to a specified environment with rollback support'
inputs:
  environment:
    description: 'Target environment (staging, production)'
    required: true
  version:
    description: 'Version tag to deploy'
    required: true
  dry-run:
    description: 'Simulate deployment without making changes'
    required: false
    default: 'false'
  timeout:
    description: 'Deployment timeout in seconds'
    required: false
    default: '300'
  rollback-on-failure:
    description: 'Automatically rollback if health check fails'
    required: false
    default: 'true'
outputs:
  deployment-id:
    description: 'Unique identifier for this deployment'
  deployment-url:
    description: 'URL of the deployed application'
  previous-version:
    description: 'Version that was running before this deployment'
  status:
    description: 'Deployment status (success, failed, rolled-back)'

Setting Outputs from Each Action Type

From Composite Actions — reference step outputs:

# Composite action setting outputs
runs:
  using: 'composite'
  steps:
    - name: Deploy
      id: deploy
      shell: bash
      run: |
        DEPLOY_ID=$(uuidgen)
        echo "deployment-id=${DEPLOY_ID}" >> $GITHUB_OUTPUT
        echo "deployment-url=https://${{ inputs.environment }}.example.com" >> $GITHUB_OUTPUT
        echo "status=success" >> $GITHUB_OUTPUT

# In outputs section, reference the step:
outputs:
  deployment-id:
    description: 'Deployment ID'
    value: ${{ steps.deploy.outputs.deployment-id }}
  deployment-url:
    description: 'Deployment URL'
    value: ${{ steps.deploy.outputs.deployment-url }}

From JavaScript Actions — use core.setOutput():

const core = require('@actions/core');

// Set outputs that consumers can reference
core.setOutput('deployment-id', deployId);
core.setOutput('deployment-url', `https://${environment}.example.com`);
core.setOutput('previous-version', previousVersion);
core.setOutput('status', 'success');

From Docker Actions — write to $GITHUB_OUTPUT:

#!/bin/bash
# Docker entrypoint — set outputs via GITHUB_OUTPUT
DEPLOY_ID=$(uuidgen)
echo "deployment-id=${DEPLOY_ID}" >> "${GITHUB_OUTPUT}"
echo "deployment-url=https://${ENVIRONMENT}.example.com" >> "${GITHUB_OUTPUT}"
echo "status=success" >> "${GITHUB_OUTPUT}"

Consuming outputs in a workflow:

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Deploy
        id: deploy
        uses: ./
        with:
          environment: staging
          version: ${{ github.sha }}

      - name: Report
        run: |
          echo "Deployed: ${{ steps.deploy.outputs.deployment-id }}"
          echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
          echo "Previous: ${{ steps.deploy.outputs.previous-version }}"

Using Official GitHub Templates

GitHub provides starter templates that include project structure, CI workflows for testing the action itself, and proper TypeScript/Docker configurations. Use these to bootstrap new actions quickly:

Template Language Description
actions/javascript-action JavaScript Minimal JS action with @actions/core, Jest tests, ncc build
actions/typescript-action TypeScript Full TypeScript setup, ESLint, Jest, ncc, source maps
actions/docker-action Shell/Any Dockerfile-based action with entrypoint.sh
actions/container-action Any Pre-built container image action template
# Create a new action from the TypeScript template
gh repo create my-custom-action --template actions/typescript-action --public --clone
cd my-custom-action

# Install dependencies
npm install

# Develop your action
# Edit src/main.ts with your logic

# Build and test
npm run all

# Commit the dist/ folder (required for actions)
git add -A
git commit -m "feat: initial action implementation"
git push

The TypeScript template includes:

  • src/main.ts — Entry point with try/catch and core.setFailed()
  • __tests__/main.test.ts — Jest test setup with mocked inputs
  • .github/workflows/ci.yml — CI that builds and tests the action
  • .github/workflows/check-dist.yml — Ensures dist/ is up to date
  • tsconfig.json, .eslintrc.json — Proper TypeScript/linting config
  • action.yml — Starter metadata with example input/output

Publishing Actions to GitHub Marketplace

To publish an action to the GitHub Marketplace, your action.yml needs specific metadata fields for branding and discoverability. The action must live in a public repository at the root level (not in a subdirectory for Marketplace listing).

# action.yml with full Marketplace metadata
name: 'Deploy Notify'
author: 'Your Organization'
description: |
  Send deployment notifications to Slack, Teams, or Discord.
  Supports custom templates, thread replies, and status updates.

branding:
  icon: 'bell'
  color: 'blue'

inputs:
  webhook-url:
    description: 'Webhook URL for the notification service'
    required: true
  status:
    description: 'Deployment status (success, failure, cancelled)'
    required: true
  environment:
    description: 'Target environment name'
    required: false
    default: 'production'
  message-template:
    description: 'Custom message template (supports $VARS)'
    required: false

outputs:
  message-id:
    description: 'ID of the sent notification message'
  thread-ts:
    description: 'Thread timestamp for reply threading (Slack)'

runs:
  using: 'node20'
  main: 'dist/index.js'

The branding field controls how your action appears in Marketplace:

  • icon: Any Feather icon name (e.g., bell, cloud, zap, shield)
  • color: One of white, yellow, blue, green, orange, red, purple, gray-dark

Versioning Strategy

Actions follow semantic versioning with a major version tag pattern that consumers expect:

# Create a specific version release
git tag -a v1.2.3 -m "Release v1.2.3: Add Discord support"
git push origin v1.2.3

# Update the major version tag (consumers use this)
# Delete and recreate the v1 tag pointing to latest v1.x.x
git tag -fa v1 -m "Update v1 tag to v1.2.3"
git push origin v1 --force

# Consumers reference the major version:
# uses: your-org/deploy-notify@v1
# This always gets the latest v1.x.x release
Versioning Best Practice: Maintain both specific (v1.2.3) and major (v1) tags. Consumers pin to major version (@v1) for automatic non-breaking updates. When you make breaking changes, create v2 — existing consumers stay on v1 until they explicitly upgrade.

Publishing steps:

  1. Ensure action.yml has name, description, and branding fields
  2. Create a GitHub release with a semantic version tag
  3. On the release page, check "Publish this action to the GitHub Marketplace"
  4. Select a primary and secondary category
  5. Publish — the action becomes searchable on github.com/marketplace

Automating Action Releases

Manually managing tags and releases doesn't scale. Use release-please or similar tools to automate versioning based on conventional commits, and a separate workflow to update major version tags.

# .github/workflows/release.yml
name: Release

on:
  push:
    branches: [main]

permissions:
  contents: write
  pull-requests: write

jobs:
  release-please:
    runs-on: ubuntu-latest
    outputs:
      release_created: ${{ steps.release.outputs.release_created }}
      tag_name: ${{ steps.release.outputs.tag_name }}
      major: ${{ steps.release.outputs.major }}
      minor: ${{ steps.release.outputs.minor }}
    steps:
      - uses: googleapis/release-please-action@v4
        id: release
        with:
          release-type: node

  update-major-tag:
    needs: release-please
    if: needs.release-please.outputs.release_created == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Update major version tag
        env:
          MAJOR: ${{ needs.release-please.outputs.major }}
          TAG: ${{ needs.release-please.outputs.tag_name }}
        run: |
          git config user.name "github-actions[bot]"
          git config user.email "github-actions[bot]@users.noreply.github.com"

          # Delete the existing major tag (locally and remotely)
          git tag -d "v${MAJOR}" 2>/dev/null || true
          git push origin ":refs/tags/v${MAJOR}" 2>/dev/null || true

          # Create new major tag pointing to this release
          git tag -a "v${MAJOR}" -m "Update v${MAJOR} tag to ${TAG}"
          git push origin "v${MAJOR}"

          echo "Updated v${MAJOR} tag to point to ${TAG}"

CI for Actions — Testing the Action Itself

Your action needs its own CI pipeline. Test it by actually running it in a workflow:

# .github/workflows/ci.yml — Test the action itself
name: CI

on:
  pull_request:
  push:
    branches: [main]

jobs:
  # Unit tests
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npm test

  # Lint and type-check
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npm run lint

  # Verify dist/ is up to date
  check-dist:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npm run build
      - name: Check for uncommitted changes
        run: |
          if [ "$(git diff --ignore-space-at-eol dist/ | wc -l)" -gt "0" ]; then
            echo "::error::dist/ is out of date. Run 'npm run build' and commit."
            git diff dist/
            exit 1
          fi

  # Integration test — run the action for real
  integration:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Run the action
        id: test-action
        uses: ./
        with:
          environment: 'test'
          version: 'v0.0.0-test'
          dry-run: 'true'

      - name: Verify outputs
        run: |
          echo "Status: ${{ steps.test-action.outputs.status }}"
          if [ "${{ steps.test-action.outputs.status }}" != "success" ]; then
            echo "::error::Expected status 'success', got '${{ steps.test-action.outputs.status }}'"
            exit 1
          fi

  # Cross-platform test
  cross-platform:
    strategy:
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      - uses: ./
        with:
          environment: 'test'
          version: 'v0.0.0-test'
          dry-run: 'true'
Testing Best Practices for Actions:
  • Unit tests: Mock @actions/core and @actions/github — test logic in isolation
  • check-dist: Ensure the compiled dist/ matches source — catches forgotten builds
  • Integration tests: Use uses: ./ to test the action in the same repo
  • Cross-platform: Test on all supported OSes if using composite or JS actions

Exercises

Exercise 1 Composite Action
Build a "Setup and Lint" Composite Action

Create a composite action at .github/actions/setup-lint/action.yml that accepts a language input (python, javascript, go) and runs the appropriate linter. For Python use ruff, for JavaScript use eslint, for Go use golangci-lint. The action should output the number of issues found and set a failure annotation if issues exceed a configurable threshold.

composite inputs/outputs multi-language
Exercise 2 JavaScript Action
Build a PR Size Labeler in TypeScript

Create a TypeScript action that labels PRs based on the total lines changed: size/XS (<10), size/S (10-49), size/M (50-199), size/L (200-499), size/XL (500+). Use @actions/github to fetch PR files and add labels. Include Jest tests that mock the GitHub API. Bundle with ncc and verify the check-dist workflow catches stale builds.

TypeScript @actions/github ncc Jest
Exercise 3 Docker Action
Build a Python Security Scanner Docker Action

Create a Docker-based action that runs bandit (Python security linter) on a configurable source path. Accept inputs for severity level, confidence level, and exclusion patterns. Output a JSON report as an artifact and set the step to failed if high-severity issues are found. Use a multi-stage Dockerfile to minimize image size. Publish the image to GHCR and reference it with docker://ghcr.io/... for faster runs.

Docker Python GHCR security
Exercise 4 Publishing & Releases
Automate Action Releases with release-please

Take any action from exercises 1-3 and add the full release automation pipeline: (1) Configure release-please with conventional commit parsing, (2) Add a workflow that updates the major version tag on each release, (3) Add a check-dist workflow that fails if dist/ is stale, (4) Create a release that publishes to Marketplace with proper branding. Verify that consumers referencing @v1 get your latest patch release.

release-please semver Marketplace CI/CD

Next in the Series

In Module 9: Security and Hardening, we'll cover securing your workflows — permissions, OIDC for cloud authentication, secret management, supply chain hardening with artifact attestations, and defending against injection attacks in untrusted inputs.