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

1 comment
(Developer Tutorials) - Stop relying on bloated third-party actions. Here's how to build your own custom GitHub Action from scratch using JavaScript and Docker, with real-world patterns from our Vietnamese dev team at ECOA AI.

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

Let’s be real: the GitHub Marketplace is full of actions that are either over-engineered, abandoned, or just plain broken. I’ve spent countless hours debugging third-party actions that silently fail on Node 20 or have dependencies that don’t even compile anymore.

That’s why we built our own.

Why Vietnam Outsourcing Is Winning the Offshore Development Race in 2025

Why Vietnam Outsourcing Is Winning the Offshore Development Race in 2025

TL;DR: Vietnam outsourcing is surging due to competitive costs, a young tech workforce, and strong government support. It… ...

At ECOA AI, our team of Vietnamese developers maintains a suite of custom GitHub Actions for our CI/CD pipelines. We’re talking about actions that handle code review automation, dependency caching, and even AI-powered PR analysis. And honestly? The control we get is worth every hour spent writing them.

Here’s the thing: Building a custom GitHub Action isn’t rocket science. It’s actually surprisingly straightforward once you understand the three types and the core patterns.

5 Docker Optimization Tips for Real Projects Nobody Tells You

5 Docker Optimization Tips for Real Projects Nobody Tells You

Docker has changed how we deploy applications, but not everyone knows how to optimize Docker for real projects… ...

I’ll show you exactly how we do it.

Why Build Your Own GitHub Action?

Before we dive into code, ask yourself: *Why not just use an existing one?*

Fair question. Here’s when you should roll your own:

  • You need specific business logic that no existing action handles
  • You’re tired of security audits for every third-party dependency
  • You want full control over error handling and logging
  • Your team needs consistent behavior across multiple repositories

We recently built an action for a US-based fintech client that automatically validates PR descriptions against their compliance requirements. No marketplace action does that. Took us about 4 hours to build and test.

The Three Types of GitHub Actions

GitHub supports three action types. Pick the one that fits your use case:

Type Language Best For Execution Speed
JavaScript Node.js Simple logic, API calls Fast (runs on runner)
Docker Container Any language Complex environments, specific dependencies Medium (image pull overhead)
Composite Shell/YAML Multi-step workflows, reusable configs Fast (native runner)

For this tutorial, we’ll build a JavaScript action — it’s the most common and the fastest to get started with.

Step 1: Project Setup

Create your action directory and initialize it:

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

Your `package.json` should look like this:

json
{
  "name": "my-custom-action",
  "version": "1.0.0",
  "description": "A custom GitHub Action for automated PR labeling",
  "main": "index.js",
  "scripts": {
    "build": "ncc build index.js -o dist",
    "test": "jest"
  },
  "dependencies": {
    "@actions/core": "^1.10.1",
    "@actions/github": "^6.0.0",
    "@actions/exec": "^1.1.1"
  },
  "devDependencies": {
    "@vercel/ncc": "^0.38.1",
    "jest": "^29.7.0"
  }
}

Pro tip: Use `@vercel/ncc` to compile your action into a single file. This avoids `node_modules` issues and keeps your action fast.

Step 2: Write the Action Metadata

Every action needs an `action.yml` file. This is your action’s manifest:

yaml
name: 'PR Labeler'
description: 'Automatically labels PRs based on file changes'
author: 'ECOA AI Team'
branding:
  icon: 'tag'
  color: 'blue'

inputs:
  github-token:
    description: 'GitHub token for API access'
    required: true
    default: ${{ github.token }}
  label-prefix:
    description: 'Prefix for auto-generated labels'
    required: false
    default: 'auto-'

outputs:
  labels-added:
    description: 'Comma-separated list of labels added'

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

Notice the `runs` section. We’re using `node20` because that’s what GitHub supports in 2026. Don’t use Node 16 — it’s been deprecated.

Step 3: The Core Logic

Here’s where the magic happens. Our `index.js` file:

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

async function run() {
  try {
    // Get inputs
    const token = core.getInput('github-token', { required: true });
    const prefix = core.getInput('label-prefix');
    
    // Get the PR context
    const { pull_request, repository } = github.context.payload;
    
    if (!pull_request) {
      core.setFailed('This action only works on pull_request events');
      return;
    }
    
    // Initialize Octokit
    const octokit = github.getOctokit(token);
    
    // Analyze changed files
    const { data: files } = await octokit.rest.pulls.listFiles({
      owner: repository.owner.login,
      repo: repository.name,
      pull_number: pull_request.number,
    });
    
    // Determine labels based on file changes
    const labels = [];
    const changes = {
      frontend: false,
      backend: false,
      database: false,
      docs: false,
      tests: false,
    };
    
    files.forEach(file => {
      if (file.filename.startsWith('frontend/') || 
          file.filename.match(/\.(jsx?|tsx?|css|scss)$/)) {
        changes.frontend = true;
      }
      if (file.filename.startsWith('backend/') || 
          file.filename.match(/\.(py|rb|go|java)$/)) {
        changes.backend = true;
      }
      if (file.filename.match(/\.(sql|prisma|migration)/)) {
        changes.database = true;
      }
      if (file.filename.startsWith('docs/') || 
          file.filename.match(/\.(md|rst)$/)) {
        changes.docs = true;
      }
      if (file.filename.match(/\.(test\.|spec\.|__tests__)/)) {
        changes.tests = true;
      }
    });
    
    // Add labels
    Object.entries(changes).forEach(([area, hasChanges]) => {
      if (hasChanges) {
        labels.push(`${prefix}${area}`);
      }
    });
    
    // Apply labels to PR
    if (labels.length > 0) {
      await octokit.rest.issues.addLabels({
        owner: repository.owner.login,
        repo: repository.name,
        issue_number: pull_request.number,
        labels: labels,
      });
    }
    
    // Set output
    core.setOutput('labels-added', labels.join(','));
    
    console.log(`Added ${labels.length} labels: ${labels.join(', ')}`);
    
  } catch (error) {
    core.setFailed(error.message);
  }
}

run();

What’s happening here? This action analyzes the files changed in a PR and automatically applies labels like `auto-frontend`, `auto-backend`, `auto-db`, etc. It’s simple but incredibly useful for teams that want consistent labeling.

Step 4: Build and Test Locally

Before pushing to GitHub, test locally:

bash
# Build the action
npm run build

# Test with Jest
npm test

Here’s a basic test file:

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

jest.mock('@actions/core');
jest.mock('@actions/github');

describe('PR Labeler Action', () => {
  beforeEach(() => {
    jest.clearAllMocks();
    
    // Mock GitHub context
    github.context.payload = {
      pull_request: { number: 42 },
      repository: { owner: { login: 'test-org' }, name: 'test-repo' }
    };
  });

  test('should detect frontend changes', async () => {
    // Mock file list
    github.getOctokit.mockReturnValue({
      rest: {
        pulls: {
          listFiles: jest.fn().mockResolvedValue({
            data: [{ filename: 'frontend/components/Button.tsx' }]
          })
        },
        issues: {
          addLabels: jest.fn().mockResolvedValue({})
        }
      }
    });
    
    // Run action
    await require('./index');
    
    // Verify labels were added
    expect(core.setOutput).toHaveBeenCalledWith(
      'labels-added',
      expect.stringContaining('auto-frontend')
    );
  });
});

Step 5: Create the Workflow

Now let’s use our action in a real workflow. Create `.github/workflows/pr-labeler.yml`:

yaml
name: PR Labeler
on:
  pull_request:
    types: [opened, synchronize]

jobs:
  label:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Run custom PR labeler
        id: labeler
        uses: ./ # Or your published action: your-org/my-custom-action@v1
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
          label-prefix: 'auto-'
      
      - name: Show labels
        run: echo "Labels added: ${{ steps.labeler.outputs.labels-added }}"

Step 6: Publish to Marketplace (Optional)

Want to share your action? Here’s the checklist:

  1. Add a `LICENSE` file — MIT is standard
  2. Create a release with semantic versioning
  3. Add a `README.md` with usage examples
  4. Submit to GitHub Marketplace via the “Actions” tab

*But honestly?* Most teams keep their actions private. We do. Our internal actions handle things like AI-powered code review, deployment validation, and dependency security scanning. No need to share those.

Real-World Patterns We Use at ECOA AI

Our Vietnamese developers in Ho Chi Minh City and Can Tho use custom GitHub Actions daily. Here are three patterns that work well:

Pattern 1: The Monorepo Scoper

yaml
name: Monorepo CI
on:
  pull_request:
    paths:
      - 'packages/web/**'
      - 'packages/api/**'

jobs:
  detect-changes:
    runs-on: ubuntu-latest
    outputs:
      web: ${{ steps.changes.outputs.web }}
      api: ${{ steps.changes.outputs.api }}
    steps:
      - uses: dorny/paths-filter@v3
        id: changes
        with:
          filters: |
            web:
              - 'packages/web/**'
            api:
              - 'packages/api/**'

Pattern 2: The AI PR Reviewer

We built an action that calls our ECOA AI Platform ACP to review PRs. It analyzes code quality, suggests improvements, and even detects potential security issues. The action runs as a non-blocking check, so developers get feedback without waiting.

Pattern 3: The Deployment Validator

Before any production deployment, our action runs a series of validation checks:

  • Dependency vulnerability scan (using `npm audit` and `pip-audit`)
  • Bundle size analysis (fails if > 500KB)
  • API contract validation (against OpenAPI specs)
  • Migration dry runs (for database changes)

Common Pitfalls to Avoid

After building dozens of actions, here’s what I’ve learned:

  1. Don’t hardcode secrets — Use `${{ secrets.YOUR_SECRET }}` in workflows
  2. Handle rate limits — GitHub API has a 5000 requests/hour limit
  3. Use `ncc` for bundling — `node_modules` in actions is a mess
  4. Version your releases — Use `v1`, `v1.2`, not `latest`
  5. Test with `act` — The local GitHub Actions runner is a lifesaver
bash
# Install act
brew install act

# Run workflow locally
act pull_request -j label

The Bottom Line

Building custom GitHub Actions isn’t just about avoiding third-party bloat. It’s about owning your CI/CD pipeline completely. When something breaks at 2 AM, you want to know exactly how your action works — not dig through someone else’s spaghetti code.

Our team in Vietnam builds custom actions for every client project now. It’s become part of our standard toolkit. And honestly? The ROI is massive.

*Think about it:* How many hours has your team wasted debugging a third-party action that should “just work”? Probably more than the 4 hours it takes to build your own.

Ready to build yours? Start with a simple labeler or notification action. You’ll be surprised how quickly you can iterate once you have the pattern down.

Frequently Asked Questions

Can I build a GitHub Action in Python?

Yes. Use a Docker container action and set your base image to `python:3.12-slim`. Write your logic in Python, then reference it in `action.yml` with `runs.using: ‘docker’` and `runs.image: ‘Dockerfile’`. The tradeoff is slower execution due to image pull times.

How do I debug a GitHub Action that’s failing silently?

Add `ACTIONS_STEP_DEBUG=true` as a repository secret to enable diagnostic logging. You can also use `core.debug()` in your JavaScript action for custom debug output. For Docker actions, `set -x` in your shell script helps.

What’s the maximum execution time for a GitHub Action?

Free tier actions timeout after 6 hours. Paid plans get up to 35 hours for self-hosted runners. For most CI tasks, you’ll finish well under 10 minutes. If your action runs longer, consider breaking it into smaller jobs.

Do I need to publish my action to use it in private repos?

No. You can reference a local action with `uses: ./` or point to a private repository with `uses: your-org/your-repo@v1`. GitHub Actions work perfectly with private repositories without Marketplace publication.

Related: Hire Vietnamese Developers — Learn more about how ECOA AI can help your team.

Related: developers in Vietnam — Learn more about how ECOA AI can help your team.

Related: Vietnam development team — Learn more about how ECOA AI can help your team.

Related: Elite Vietnamese Developers — Learn more about how ECOA AI can help your team.

Related reading: Outsourcing Software in 2025: Why Vietnam Is Winning the Offshore Engineering War

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.