Blog / DevOps

How to Set Up CI/CD for a Small Open Source Project With GitHub Actions

CI isn't bureaucracy for a two-person project — it's the thing that lets you safely accept a pull request from a stranger you've never met. A working setup, built from a blank workflow file, in the order it actually gets built.

A maintainer merges a pull request from a first-time contributor, someone they've never interacted with before, without personally re-running the entire test suite by hand first. That should feel like a small act of trust — and it is one, but it's a trust placed in the CI pipeline that already ran automatically, not blind trust in a stranger's code. That's the actual point of CI on an open source project: it's not process for its own sake, it's the specific thing that makes it safe to accept contributions from people you'll never meet.

The minimum workflow that's actually worth having

At minimum, CI should run automatically on every pull request and confirm two things: the project still builds, and its existing tests still pass. That alone catches the overwhelming majority of "wait, how did that break main" moments before they ever reach main, and it costs almost nothing to set up.

name: CI
on:
  pull_request:
  push:
    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 run lint
      - run: npm test

Why this matters more here than on an internal team

An internal team can get away with an informal, spoken agreement — "everyone just runs tests before merging" — because there's shared context, shared incentive, and a small enough group that social pressure alone mostly works. An open source project doesn't have that luxury: pull requests arrive from contributors who've never seen your internal conventions and have no particular reason to know them. CI is what enforces your standards on every single contribution automatically, without you personally reviewing each one for things a machine could've caught in thirty seconds — which is exactly what makes it safe to say yes to a stranger's first PR without a knot in your stomach.

Understanding events and triggers before you need something unusual

The `on:` block at the top of a workflow is deciding more than it looks like it is, and it's worth understanding the distinction between its common triggers before you copy one from an example without thinking about it. `pull_request` runs your workflow against the merge of the PR branch into the target branch, which is almost always what you want for testing a contribution before it lands. `push` runs against a specific branch directly, which is the right trigger for a deploy job that should only fire once code is already on `main`, not for every commit on every contributor's in-progress branch. Mixing these up is a common source of confusion — a deploy job accidentally set to `pull_request` will try to deploy code from a PR that hasn't been reviewed yet, which is very much not the intended behavior.

Layering deployment on top

Once the test job is solid, continuous deployment on merge to main is a natural next step — build, then ship, gated by the exact same checks that already ran.

deploy:
  needs: test
  if: github.ref == 'refs/heads/main'
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v4
    - run: npm ci && npm run build
    - uses: peaceiris/actions-gh-pages@v3
      with:
        github_token: ${{ secrets.GITHUB_TOKEN }}
        publish_dir: ./dist

The `needs: test` line is doing more work than its one line suggests — it means the deploy job simply won't run at all unless the test job already succeeded, so a broken build genuinely cannot reach production just because someone merged a PR anyway. That guarantee is worth more than it looks like on the page; it's the difference between "CI runs" and "CI actually protects anything."

Testing across more than one environment: matrix builds

Once a project has contributors running different Node versions, different operating systems, or different Python versions than you personally use, a single test job that only ever runs on your setup stops catching a real, common category of bug — code that works on your machine and fails on a contributor's. A matrix build runs the same job multiple times, once per combination of versions or platforms you list, and it's a small addition to a workflow that already exists rather than a new one to build from scratch.

strategy:
  matrix:
    node-version: [18, 20, 22]
steps:
  - uses: actions/setup-node@v4
    with:
      node-version: ${{ matrix.node-version }}

This isn't worth adding on day one of a brand-new project — it's worth adding the first time a bug report comes in that says "works for me on Node 18, fails on Node 20," which is exactly the class of bug a matrix build exists to catch before a user ever has to.

Secrets and environments, handled properly

Any workflow step that needs a real credential — an API key, a deploy token, npm publish credentials — should read it from GitHub's repository or environment secrets, set once in the repo's settings, never typed directly into the workflow YAML file, which is as visible to anyone browsing the repo as any other file. GitHub Actions automatically masks known secret values in log output, but that protection only exists for values actually stored as secrets — a credential pasted directly into a workflow step as plain text gets none of that masking and shows up in logs in the clear.

For anything that deploys to a genuinely sensitive target — a production environment, a package registry, an app store — GitHub's environment protection rules let you require manual approval before a workflow step runs, which is worth setting up the moment "an automated deploy" and "something a mistake could genuinely damage" start describing the same pipeline.

A few rules worth following at this project size

  • Cache dependencies — `actions/setup-node`'s built-in caching, or `actions/cache` more generally — so CI isn't reinstalling the entire dependency tree from scratch on every single run. This is usually the single biggest speed win available, and it's a one-line addition.
  • Keep secrets in repository or environment secrets, never hardcoded into the workflow file, even for something you consider "just a demo deploy" — workflow files are as visible as any other file in the repo.
  • Turn on branch protection requiring the CI check to pass before merge is even allowed. A workflow that runs but doesn't actually block a merge only tells you about problems after they've already shipped, which defeats most of the point.
  • Pin action versions to a specific major version (`@v4`, not `@main`) so a third-party action's breaking change doesn't silently break your pipeline the next time it runs, with no changes on your end to point to.
CI on a small open source project isn't about achieving perfection. It's about making it safe to accept a contribution from someone you've never met, without personally re-verifying everything a machine could have checked in thirty seconds.

What to add once the project actually needs it

Start with lint, test, and deploy — that covers the overwhelming majority of small projects for a long time. Add matrix testing across multiple runtime versions once you actually have contributors running different environments than you, and only reach for release automation — changelogs, version tags, package publishing — once the project has real external users who depend on version numbers meaning something consistent. Building any of that before you need it is effort spent on a problem you don't have yet.

Want to build something like this?

NebuCoders is free to join — no application, no cost.

Read next