How to Build a Custom GitHub Action: A Step-by-Step Developer Tutorial for 2026

1 comment
(Developer Tutorials) - Stop copy-pasting CI/CD scripts. Learn how to build a production-grade custom GitHub Action using TypeScript, Docker, and composite runs. Real code, real edge cases, and a deployment checklist that actually works.

How to Build a Custom GitHub Action: A Step-by-Step Developer Tutorial for 2026

Let’s be real. You’ve probably copy-pasted a GitHub Action from the Marketplace more times than you can count. It works—until it doesn’t. Then you’re stuck debugging someone else’s black box, wondering why your deployment pipeline just ate an hour of your life.

I’ve been there. Recently, while working with a team in Ho Chi Minh City on a high-throughput data pipeline, we hit a wall. The off-the-shelf actions couldn’t handle our custom retry logic and multi-region deployment strategy. So we built our own.

Outsourcing Software in 2025: The Hard Truths and Hidden Wins

Outsourcing Software in 2025: The Hard Truths and Hidden Wins

TL;DR: Outsourcing software done right can cut costs by 40-60% and speed up delivery 2x. But the failure… ...

Here’s the thing: building a custom GitHub Action isn’t rocket science. But doing it *right*—with proper error handling, idempotency, and production-grade logging—that’s where most tutorials fall short.

This guide will walk you through three approaches: JavaScript/TypeScript, Docker container actions, and composite run steps. By the end, you’ll have a working action you can publish to the Marketplace or keep private for your team.

Outsourcing Software in 2025: The CTO’s Guide to Offshore Engineering Success

Outsourcing Software in 2025: The CTO’s Guide to Offshore Engineering Success

TL;DR – Outsourcing software isn’t about cutting corners—it’s about strategic leverage. The best CTOs use offshore teams to… ...

Why Bother Building a Custom Action?

Before we dive into code, ask yourself: *Why not just use a bash script in a workflow step?*

Fair question. Here’s the short answer:

  • Reusability: Package complex logic into a single step across dozens of repos
  • Versioning: Pin to `v1`, `v2` and roll back instantly
  • Input validation: Type-safe parameters with defaults and descriptions
  • Secrets management: Native integration with GitHub’s secrets store
  • Community trust: If you’re building for open source, a well-documented action builds credibility

But honestly, the biggest win? Consistency. When your team in Can Tho and your team in Berlin both use the same action, you eliminate the “it works on my machine” problem.

Prerequisites

You’ll need:

  • A GitHub account (obviously)
  • Node.js 20+ installed locally
  • Docker (if you’re building a container action)
  • Basic familiarity with YAML and JavaScript/TypeScript

That’s it. No fancy tools. No paid subscriptions.

Approach 1: JavaScript/TypeScript Action (The Most Common)

This is what 80% of Marketplace actions use. It’s fast to develop, easy to test, and runs directly on the runner without Docker overhead.

Step 1: Scaffold the Project

bash
mkdir my-custom-action
cd my-custom-action
npm init -y
npm install @actions/core @actions/github

The `@actions/core` package gives you access to inputs, outputs, and logging. `@actions/github` handles authentication and API calls.

Step 2: Write the Action Logic

Create `src/index.js`:

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

async function run() {
  try {
    // Get inputs
    const name = core.getInput('who-to-greet', { required: true });
    const greeting = core.getInput('greeting') || 'Hello';

    // Log output
    core.info(`${greeting}, ${name}!`);

    // Set output for downstream steps
    core.setOutput('greeting-message', `${greeting}, ${name}!`);

    // Access context (repo, actor, etc.)
    const context = github.context;
    core.info(`Triggered by: ${context.actor}`);
  } catch (error) {
    core.setFailed(error.message);
  }
}

run();

Notice the `try/catch` and `core.setFailed()`. This is non-negotiable. An action that fails silently is worse than no action at all.

Step 3: Create the Action Metadata File

Create `action.yml` in the root:

yaml
name: 'My Custom Greeting Action'
description: 'Greets a user with a custom message'
author: 'Your Name'
branding:
  icon: 'smile'
  color: 'green'

inputs:
  who-to-greet:
    description: 'Who to greet'
    required: true
    default: 'World'
  greeting:
    description: 'Custom greeting word'
    required: false
    default: 'Hello'

outputs:
  greeting-message:
    description: 'The full greeting message'

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

Pro tip: Always point to a `dist/` folder, not `src/`. GitHub Actions don’t run `npm install` by default. You need to bundle your dependencies.

Step 4: Bundle with ncc

bash
npm install -g @vercel/ncc
ncc build src/index.js --license licenses.txt

This creates a single `dist/index.js` file with all dependencies inlined. No `node_modules` drama.

Step 5: Test Locally

You can’t run GitHub Actions locally without tools like `act`. Install it:

bash
brew install act

Create a test workflow `.github/workflows/test.yml`:

yaml
name: Test Custom Action
on: [push]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: ./
        with:
          who-to-greet: 'ECOA AI Team'
          greeting: 'Xin chào'

Run it:

bash
act push

You should see: `Xin chào, ECOA AI Team!`

Approach 2: Docker Container Action (For Complex Environments)

Sometimes JavaScript isn’t enough. Maybe you need Python, specific system libraries, or a full runtime environment. That’s where Docker actions shine.

Step 1: Create a Dockerfile

dockerfile
FROM python:3.12-slim

COPY requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir -r /app/requirements.txt

COPY entrypoint.py /app/entrypoint.py

ENTRYPOINT ["python", "/app/entrypoint.py"]

Step 2: Write the Entrypoint

python
import os
import sys

def main():
    name = os.getenv('INPUT_WHO-TO-GREET', 'World')
    greeting = os.getenv('INPUT_GREETING', 'Hello')

    print(f"{greeting}, {name}!")

    # Set output
    with open(os.environ['GITHUB_OUTPUT'], 'a') as f:
        f.write(f"greeting-message={greeting}, {name}!\n")

if __name__ == '__main__':
    main()

Notice how we read inputs from environment variables and write outputs to `GITHUB_OUTPUT`. That’s the Docker action pattern.

Step 3: Update action.yml

yaml
runs:
  using: 'docker'
  image: 'Dockerfile'

That’s it. The runner builds the Docker image on every run. Warning: This adds 30-60 seconds to your workflow. Use it only when you need it.

Approach 3: Composite Run Steps (The Lightweight Option)

Don’t need a full action? Composite actions let you chain multiple `run` steps into a reusable block.

yaml
name: 'Deploy to Staging'
description: 'Deploys to staging with health checks'
inputs:
  environment:
    description: 'Target environment'
    required: true
runs:
  using: 'composite'
  steps:
    - run: echo "Deploying to ${{ inputs.environment }}"
      shell: bash
    - run: |
        curl -f http://staging.example.com/health
      shell: bash
    - run: echo "Deployment complete"
      shell: bash

No build step. No dependencies. Just YAML.

Production Checklist

Before you publish, run through this list:

  • Idempotency: Running the action twice produces the same result
  • Error handling: Every external call wrapped in try/catch
  • Logging: Use `core.info()`, `core.warning()`, `core.error()`—not `console.log()`
  • Input validation: Set `required: true` and provide sensible defaults
  • Outputs: Document what each output contains
  • Versioning: Tag releases with `v1.0.0`, `v1`, `v1.1.0`
  • Testing: Test with `act` and in a real workflow

Publishing to the Marketplace

  1. Push your code to a public repo
  2. Go to your repo’s Releases page
  3. Create a new release with a semantic version tag (e.g., `v1.0.0`)
  4. GitHub automatically detects the `action.yml` and offers to publish

Pro tip: Use a `v1` tag that points to the latest `v1.x.x` release. Users pin to `v1` and get minor updates automatically.

Real-World Example: Our Deployment Action

Here’s what we built for that Ho Chi Minh City team:

yaml
name: 'ECOA Multi-Region Deploy'
description: 'Deploys to multiple regions with health checks and rollback'
inputs:
  regions:
    description: 'Comma-separated list of regions'
    required: true
  docker-tag:
    description: 'Docker image tag'
    required: true
runs:
  using: 'composite'
  steps:
    - run: |
        for region in $(echo ${{ inputs.regions }} | tr "," "\n"); do
          echo "Deploying to $region..."
          # Deployment logic here
        done
      shell: bash

It cut our deployment time from 45 minutes to 12 minutes. Not bad for a few hours of work.

Frequently Asked Questions

Can I use TypeScript instead of JavaScript?

Absolutely. Use `tsc` to compile to JavaScript, then bundle with `ncc`. Just make sure your `action.yml` points to the compiled output, not the `.ts` file.

How do I handle secrets in a custom action?

Use `core.getInput(‘my-secret’)` and pass the secret from the workflow using `${{ secrets.MY_SECRET }}`. Never log secrets. Use `core.setSecret()` to mask them from logs.

What’s the difference between `node20` and `node16` in action.yml?

GitHub Actions supports `node20` as of late 2024. Node16 is deprecated and will stop working in 2025. Always use `node20` for new actions.

Can I build a private action for my organization?

Yes. Keep the repo private and reference it with `uses: your-org/your-repo@v1`. No need to publish to the Marketplace.

Related reading: Hire Vietnamese Developers: The Strategic Edge for Scalable Tech Teams

Related reading: Vietnam Outsourcing: Why Southeast Asia’s Rising Tech Hub is Winning Over Silicon Valley

Leave a Comment

Your email address will not be published. Required fields are marked *

Ready to Build with AI-Powered Developers?

Hire Vietnamese engineers augmented by ECOA AI Platform + Claude Code. 5x faster, 40% cheaper.