Back to blog
Jul 2026·3 min readAI-generated

Writing Your First GitHub Action

#ci-cd#github#devops

Most teams start with GitHub Actions by copy-pasting a workflow from another repo and hoping it still applies. That works until the day it doesn't, and you need to actually understand what triggers a run, what a job and a step are, and how to pass data between them. This walks through building a real workflow from nothing — running tests on every pull request and publishing a package on tagged releases.

The anatomy of a workflow

A workflow is a YAML file in .github/workflows/. It has three core pieces: triggers (on) decide when it runs, jobs are groups of steps that run on their own virtual machine, and steps are the individual commands or actions inside a job.

name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

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

actions/checkout@v4 clones your repo into the runner — without it, the runner is an empty VM with no access to your code. actions/setup-node installs a specific Node version. npm ci (not npm install) is important in CI: it installs exactly what's in package-lock.json and fails if the lockfile is out of sync, instead of silently updating it.

Running jobs in parallel with a matrix

If you need to test against multiple Node versions or operating systems, a matrix expands one job definition into several parallel runs:

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [18, 20, 22]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
      - run: npm ci
      - run: npm test

This runs three independent jobs, one per Node version, all in parallel. ${{ matrix.node-version }} is GitHub Actions' expression syntax — it interpolates the current matrix value into the step.

Caching dependencies

Every job starts on a fresh VM, so npm ci re-downloads every package on every run unless you cache them:

- uses: actions/setup-node@v4
  with:
    node-version: '20'
    cache: 'npm'

setup-node's built-in cache option handles this for npm, yarn, and pnpm — it keys the cache to your lockfile hash, so a cache hit only happens when dependencies haven't changed.

Passing data between steps

Steps in the same job share a filesystem but not variables — to pass a value from one step to the next, you write to a special output file:

steps:
  - id: get_version
    run: echo "version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT"

  - run: echo "Building version ${{ steps.get_version.outputs.version }}"

The id: get_version gives the step a name you can reference later as steps.get_version.outputs.version. This pattern — read something, write it to $GITHUB_OUTPUT, reference it downstream — is how most non-trivial workflows move data around.

Secrets

Never hardcode credentials in a workflow file — it's committed to the repo and visible to anyone with read access. Store them under repo Settings → Secrets and variables → Actions, and reference them via the secrets context:

- name: Publish package
  run: npm publish
  env:
    NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

Secrets are masked in logs automatically — if a step accidentally prints one, GitHub replaces it with *** in the output.

Triggering only on tagged releases

Combine on.push.tags with a conditional job to publish only when you push a version tag, while still running tests on every push:

name: Release

on:
  push:
    tags:
      - 'v*'

jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          registry-url: 'https://registry.npmjs.org'
          cache: 'npm'
      - run: npm ci
      - run: npm test
      - run: npm publish
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

Pushing a tag like v1.2.0 runs this workflow; a regular commit to main doesn't, since the trigger is scoped to tags, not branches.

if: conditions for finer control

Sometimes a trigger alone isn't precise enough. if: lets you gate a step or job on an expression:

- name: Notify on failure
  if: failure()
  run: echo "Build failed on ${{ github.ref }}"

failure() is one of several built-in functions (alongside success(), always(), cancelled()) that check the outcome of previous steps in the job — useful for sending a notification only when something breaks, without a separate workflow.

Debugging a workflow

Two things save the most time when a workflow misbehaves:

  • act — runs GitHub Actions workflows locally in Docker, so you can iterate without pushing a commit for every typo.
  • workflow_dispatch trigger — add on: workflow_dispatch to any workflow to get a "Run workflow" button in the GitHub UI, so you can re-run it manually without a new push:
on:
  workflow_dispatch:
  push:
    branches: [main]

Takeaway

A GitHub Actions workflow is just YAML describing triggers, jobs, and steps, run on ephemeral VMs that start with nothing but what actions/checkout gives them. Once you can read on:, jobs:, steps:, and the ${{ }} expression syntax fluently, every workflow you find online stops looking like magic and starts looking like a config file you can actually modify.

NewerUnderstanding Database Connection PoolingOlderUnderstanding the JavaScript Event Loop