Skip to main content

Command Palette

Search for a command to run...

GitHub Actions – What I Learned Building Auto-Deploy

Published
6 min read
GitHub Actions – What I Learned Building Auto-Deploy
S

Skilled in managing carrier-grade ISP infrastructure, enterprise environments, and server operations. Enthusiastic about optimizing high-performance networks and exploring emerging technologies. Committed to continuous learning and driven to leverage cloud solutions and automation tools to enhance innovation and efficiency.

Last week, I finally got tired of manually publishing my Node.js CLI tool to npm every time I made changes. You know the drill: run tests locally, bump version, npm publish, hope nothing breaks. I'd been putting off automation because I thought it would be complicated, but GitHub Actions made it surprisingly straightforward.

Here's what I built and what I learned along the way.

What I Wanted to Automate

Every time I push code to main:

  • Run my tests automatically

  • If tests pass and I create a release, publish to npm without me lifting a finger

  • Stop me from accidentally publishing broken code

Sounds simple, but there were definitely some gotchas.

The Workflow That Actually Works

Here's the complete workflow I ended up with:

yaml

name: Node.js CLI CI/CD

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  workflow_dispatch:
  release:
    types: [published]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v3

      - name: Set up Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'

      - name: Install dependencies
        working-directory: ./nodeappcli
        run: npm install

      - name: Run tests
        working-directory: ./nodeappcli
        run: npm test

  publish:
    needs: test
    runs-on: ubuntu-latest
    if: github.event_name == 'release' && github.event.action == 'published'
    steps:
      - name: Checkout code
        uses: actions/checkout@v3

      - name: Set up Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'
          registry-url: 'https://registry.npmjs.org/'

      - name: Install dependencies
        working-directory: ./nodeappcli
        run: npm install

      - name: Publish to NPM
        working-directory: ./nodeappcli
        run: npm publish --access public
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

Breaking Down What's Actually Happening

The Triggers (on:)

I set up four different ways this workflow can start:

push to main - Every time I push code, tests run automatically. This catches bugs before I even think about releasing.

pull_request - Tests also run on PRs. This was crucial because I wanted to know if a PR would break things before merging.

workflow_dispatch - This adds a manual "Run workflow" button in GitHub's UI. I use this for testing the workflow itself without pushing code.

release: types: [published] - This is the magic trigger. Only when I actually publish a release on GitHub does the publish job kick in.

Two Jobs, Two Purposes

Job 1: test

This runs on every push and PR. It's my safety net.

yaml

test:
  runs-on: ubuntu-latest

The runs-on: ubuntu-latest means GitHub spins up a fresh Ubuntu virtual machine for this job. It's clean every time - no leftover files or cached weirdness.

Why actions/checkout@v3? - This was my first gotcha. The runner starts with an empty workspace. You have to explicitly tell it to grab your code from the repo. I wasted an hour debugging "command not found" errors before I realized I forgot this step.

The working-directory thing - My CLI lives in a subfolder, so I need to tell npm where to run commands. If your package.json is at the root, you can skip this.

Job 2: publish

This is where it gets interesting:

yaml

publish:
  needs: test
  if: github.event_name == 'release' && github.event.action == 'published'

needs: test - This is critical. The publish job won't even start unless the test job passes. No green tests, no publish. This saved me from publishing broken code at least twice already.

The if condition - Double protection. This job only runs when a release is published, not on regular pushes. So I can push code all day, tests run, but nothing gets published until I'm ready.

Publishing to npm

yaml

- name: Set up Node.js
  uses: actions/setup-node@v3
  with:
    node-version: '18'
    registry-url: 'https://registry.npmjs.org/'

The registry-url is important - it configures Node to talk to npm. Without it, the publish step doesn't know where to send your package.

yaml

run: npm publish --access public
env:
  NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

--access public - Needed for scoped packages (@yourusername/package-name) to be public. I got a 402 error the first time because I forgot this flag.

Secrets - The NPM_TOKEN is stored in GitHub Settings → Secrets → Actions. Never hardcode tokens in your workflow file. GitHub will yell at you if you try, and rightfully so.

Key Concepts in Plain English

Now that you've seen it in action, here's what these terms actually mean:

Workflow - The entire YAML file. It's your automation blueprint. Lives in .github/workflows/ in your repo.

Event/Trigger - What kicks off the workflow. Could be a push, a PR, a release, a schedule (cron), or clicking a button.

Job - A set of steps that run together on the same machine. Jobs run in parallel by default unless you use needs to create dependencies.

Step - A single command or action. Steps run sequentially within a job.

Runner - The actual machine running your code. GitHub provides free runners (Ubuntu, Windows, macOS), or you can host your own.

Actions - Pre-built reusable steps from the GitHub Marketplace. Like actions/checkout or actions/setup-node. They save you from writing bash scripts for common tasks.

Secrets - Encrypted environment variables for sensitive data. They're hidden in logs and can only be accessed through ${{ secrets.NAME }}.

Lessons I Learned the Hard Way

1. Always Start with actions/checkout

If your steps are failing with "file not found" errors, you probably forgot to check out your code. The runner doesn't automatically have your files.

2. Jobs Start Fresh Every Time

Each job gets a brand new virtual machine. If you install dependencies in the test job, you have to install them again in the publish job. They don't share filesystems.

Pro tip: Use caching to speed this up (I'll cover that in a future post).

3. Test Locally with act

There's a tool called act that lets you run GitHub Actions locally. It would've saved me dozens of "commit, push, wait, check logs, repeat" cycles.

4. The needs Keyword Is Your Friend

Use it to control job order and prevent disasters. If your tests fail, you don't want deploy running anyway.

5. Secrets Are Per-Environment

If you're using GitHub Environments (staging, production), secrets can be environment-specific. Took me a while to figure out why my staging deploy was using prod credentials.

What I Didn't Cover (But Probably Should)

Caching dependencies - actions/cache can save minutes on every run by caching node_modules.

Matrix builds - Testing against Node 16, 18, and 20 simultaneously.

Artifacts - Passing build outputs between jobs (like compiled binaries).

Pull request comments - Having the bot comment test results on your PR.

Conditional steps - Running steps only on certain branches or events.

I'll dig into these in future posts as I expand my setup.

The Flow in Practice

Here's what actually happens when I want to release:

  1. I push code to main → Tests run automatically

  2. If tests pass, I create a new release on GitHub (with a version tag)

  3. GitHub triggers the workflow again, but this time the publish job runs

  4. It checks out code, installs deps, runs tests again (safety first), then publishes to npm

  5. I get a notification - package is live

Total hands-on time: about 30 seconds to create the release. Everything else is automated.

Getting Started with Your Own Workflow

If you want to try this:

  1. Create .github/workflows/ci-cd.yml in your repo

  2. Copy the workflow above (adjust paths and Node version as needed)

  3. Get an npm token from npmjs.com (Account Settings → Access Tokens)

  4. Add it to GitHub (Repo Settings → Secrets → Actions → New secret)

  5. Name it NPM_TOKEN

  6. Push a commit and watch it run

Final Thoughts

The best part? Once it's set up, you forget it exists - it just works quietly in the background.

Next up, I'm planning to add automatic changelog generation and Slack notifications when deploys succeed. If you've done something similar, I'd love to hear how you approached it.