Skip to content
Codeloom
CI/CD

GitHub Actions: Your First Workflow

Write your first GitHub Actions workflow from scratch — set up .github/workflows/ci.yml, run tests on every push and pull request, and add a status badge to your README.

·9 min read · By Codeloom
Beginner 12 min read

What you'll learn

  • How GitHub Actions is wired up: workflows, jobs, steps, and actions
  • How to write a working ci.yml file from scratch
  • How triggers (push, pull_request) decide when your workflow runs
  • How to check out code, set up Node, install, and run tests
  • How to add a status badge to your README so green/red is visible at a glance

Prerequisites

GitHub Actions is the easiest way to add real CI to a project. If your code already lives on GitHub, you do not need to sign up for anything, install a separate tool, or run your own servers. You add one YAML file, push it, and GitHub starts running your tests on every change. This post walks through writing that first file from scratch.

The mental model

Four words cover the whole system. Get these right and everything else falls into place.

Workflow. A YAML file under .github/workflows/ that describes an automated process. A repository can have many workflows — one for tests, one for releases, one for nightly maintenance.

Job. A unit of work inside a workflow, run on a fresh virtual machine called a runner. Jobs in the same workflow can run in parallel by default.

Step. A single command or action inside a job. Steps run sequentially. If one fails, the rest of the job stops.

Action. A reusable, pre-packaged step you can pull in from the marketplace — for example, “check out my code” or “set up Node.js.” Actions save you from rewriting common boilerplate.

That is the entire vocabulary. Everything below is just YAML around those four ideas.

Where the file lives

GitHub Actions looks for workflow files at exactly this path:

.github/workflows/<anything>.yml

The folder name matters. The filename does not — GitHub picks up every .yml (or .yaml) file in that directory. By convention, the main test workflow is called ci.yml.

From the root of your repository:

mkdir -p .github/workflows
touch .github/workflows/ci.yml

Now open ci.yml in your editor.

A first working workflow

Here is a complete, real workflow for a Node.js project. Paste it into ci.yml:

name: CI

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

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - name: Check out repository
        uses: actions/checkout@v4

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test

Commit and push it:

git add .github/workflows/ci.yml
git commit -m "Add CI workflow"
git push

On GitHub, open the Actions tab in your repository. You should see a new run in progress. Wait a minute or two, and it will turn green (or red, with a useful error). That is CI.

Reading the file, line by line

The file is small but every line earns its place.

name: CI

The friendly name that shows up in the Actions tab. Optional, but worth setting.

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

The triggers. This workflow runs when someone pushes to main and when a pull request is opened or updated against main. Other common triggers:

  • schedule — cron-style, for nightly jobs
  • workflow_dispatch — a manual “Run workflow” button in the UI
  • release — when a GitHub release is published

You can list multiple triggers under on:.

jobs:
  test:
    runs-on: ubuntu-latest

One job called test, running on a fresh Ubuntu virtual machine GitHub provides for free. Other options include windows-latest and macos-latest if your project needs them.

    steps:
      - name: Check out repository
        uses: actions/checkout@v4

The first step uses the official checkout action to clone your repo onto the runner. Without this, the runner has no code to work with — it is a blank Linux machine. actions/checkout@v4 is the version-pinned name of the action.

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

Installs Node 20 on the runner and enables caching for npm. The cache means subsequent runs do not re-download the entire dependency tree — they pull a cached copy when package-lock.json has not changed.

      - name: Install dependencies
        run: npm ci

run: executes a shell command. npm ci is the CI-friendly install — it requires a package-lock.json and refuses to silently update it, which is exactly what you want in a build environment.

      - name: Run tests
        run: npm test

The actual test step. If this command exits non-zero, the job fails and the pull request is marked red.

Try it yourself. Push the workflow to a repo and watch it run. Then deliberately break a test, commit, and push again. Open the Actions tab — you will see the failing run, with the exact line of output that broke. Fix the test, push, and watch the run go green.

Triggers in more detail

The on: block is more flexible than the example shows. A few patterns you will use often.

Run on every push to any branch:

on: push

Run only when specific files change:

on:
  push:
    paths:
      - 'src/**'
      - 'package.json'

Useful for monorepos where you only want to test the parts that changed.

Run on a schedule:

on:
  schedule:
    - cron: '0 6 * * *'   # every day at 06:00 UTC

Run manually from the UI:

on:
  workflow_dispatch:

Adds a “Run workflow” button to the Actions tab. Great for one-off maintenance jobs.

You can combine any of these.

Adding more steps

Most real projects need a few more steps than test-only. A slightly fuller workflow:

      - name: Lint
        run: npm run lint

      - name: Type check
        run: npm run typecheck

      - name: Run tests
        run: npm test

      - name: Build
        run: npm run build

Each step is independent — if lint fails, you see exactly that, not a vague “something broke.” Order matters: cheap steps (lint, type check) should run before expensive ones (full test suite, build) so failures surface fast.

Matrix builds: test on multiple versions

A common need is “make sure this works on Node 18, 20, and 22.” The matrix strategy fans out a job across versions automatically:

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 }}
          cache: 'npm'
      - run: npm ci
      - run: npm test

GitHub runs three parallel jobs, one per version, each with ${{ matrix.node-version }} substituted in. Every value gets its own row in the Actions UI.

Status badges

A status badge is a small image you can drop into your README that shows the latest CI result at a glance. GitHub generates the URL for you.

From the Actions tab, click your workflow, then click Create status badge in the top right. You will get markdown that looks like this:

![CI](https://github.com/yourname/your-repo/actions/workflows/ci.yml/badge.svg)

Paste it near the top of README.md and push. The badge auto-updates: green when the latest run passed, red when it failed. This is one of those tiny touches that signals “this project is maintained.”

Try it yourself. Add the status badge to your README. Push. Refresh the repo home page on GitHub. You should see a green badge under the title. Click it — it links straight to the latest run.

Useful actions to know

The action marketplace has thousands of entries, but a handful cover 90% of needs.

  • actions/checkout — clone your code. Almost every job uses it.
  • actions/setup-node — install a specific Node version (and similarly setup-python, setup-go, setup-java).
  • actions/cache — cache arbitrary directories between runs. Useful for build artifacts the language-specific setup actions do not handle.
  • actions/upload-artifact / actions/download-artifact — pass files between jobs in the same workflow, or save them for later inspection.
  • docker/build-push-action — build and push a Docker image to a registry. Useful once you cross over into deployment.

Always pin actions to a major version (@v4) so they do not change shape under you without warning.

Secrets

You will eventually need credentials — a Docker Hub password, a deploy token, an API key. Never put these in the YAML file. Go to Settings → Secrets and variables → Actions in your repository and add them there.

In the workflow, reference them like environment variables:

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

GitHub injects the secret value at runtime and masks it in logs. You can see the masking in action — try printing a secret and it will show up as ***.

A note on cost and limits

GitHub Actions is generous but not infinite. For public repositories, it is essentially free. For private repositories, you get a monthly quota of minutes that depends on your plan. Long-running or matrix-heavy workflows can chew through that quickly, so keep an eye on usage under Settings → Billing.

The cheapest minute is the one you do not run. Cache aggressively, skip unchanged paths, and avoid retrying the same expensive step on flaky tests.

Recap

You now have:

  • A working .github/workflows/ci.yml that runs on every push and pull request
  • A mental model of workflow, job, step, action that transfers to any CI tool
  • The vocabulary for triggers, runners, matrix builds, and secrets
  • A status badge on your README so the project’s health is visible at a glance

That is real CI, running on every change, for free.

Next steps

CI/CD takes you to the gates of production. Once your build artifact is a container image, the next question becomes “how do I run many of these in production reliably?” That is what Kubernetes was built for.

→ Next: What Is Kubernetes?

Questions or feedback? Email codeloomdevv@gmail.com.