GitHub Actions – What I Learned Building Auto-Deploy

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:
I push code to main → Tests run automatically
If tests pass, I create a new release on GitHub (with a version tag)
GitHub triggers the workflow again, but this time the
publishjob runsIt checks out code, installs deps, runs tests again (safety first), then publishes to npm
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:
Create
.github/workflows/ci-cd.ymlin your repoCopy the workflow above (adjust paths and Node version as needed)
Get an npm token from npmjs.com (Account Settings → Access Tokens)
Add it to GitHub (Repo Settings → Secrets → Actions → New secret)
Name it
NPM_TOKENPush 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.


