~/blogs/engineering/automate-your-project-with-github-actions-testing-deployment-and-npm-publishing
$cat index.mdx|
cd blog/engineering2026-07-1311 min read

Automate Your Project with GitHub Actions: Testing, Deployment, and npm Publishing

A hands-on guide to GitHub Actions: local testing with act, automated test/deploy/npm-publish workflows, cron schedules, custom actions, and 2026 platform updates.

Automate Your Project with GitHub Actions: Testing, Deployment, and npm Publishing

If you've ever manually run tests, then manually deployed a site, then manually published an npm package, you already know how much time you're leaving on the table. GitHub Actions turns those three chores into events: push code, and the robot does the rest.

By the end of this guide you'll have a repo that tests every pull request automatically, redeploys your docs site on every merge to main, and publishes to npm whenever you cut a GitHub release, plus a way to test all of it locally before it ever touches the cloud.

Prerequisites

  • A project hosted on GitHub, with admin access to the repo (to add secrets)
  • Docker Desktop installed and running (needed for local testing)
  • act installed: brew install act on macOS, or see the install docs for other platforms
  • GitHub CLI (gh), optional, but handy for triggering workflows and checking status without leaving the terminal
  • Basic YAML familiarity. Workflows are just YAML files

Step 1: Understand what a workflow actually is

GitHub Actions reacts to events on your repository: a push, a pull request, an issue being opened, a release being published, a schedule firing. There's a long list. A workflow is a YAML file that says "when event X happens, run these jobs." Each job runs on a fresh runner (a cloud VM, container-based, spun up just for that run) and is broken into steps, the actual commands.

GitHub bills compute per minute, but the free tier is generous enough that most personal and small-team projects never see a bill.

To get started, create this directory structure at the root of your repo:

.github/
  workflows/
    test.yml

The filename inside workflows/ can be anything. GitHub picks up every .yml/.yaml file in that directory automatically.

Verify: ls .github/workflows/ should show your file(s). GitHub doesn't require a commit to recognize the directory; it reads whatever's in the repo at each push.

Step 2: Install act so you can test workflows locally

Iterating on a workflow by pushing, waiting for the cloud runner, reading logs, and pushing a fix is slow. act reads your .github/workflows files and runs them in local Docker containers built to match GitHub's own runner images: same environment, no push required.

Once Docker is running, install act and confirm it works:

act --version

Verify: you should see a version number. If Docker isn't running, act will fail immediately with a connection error. Start Docker Desktop and retry.

Later, running act bare emulates a push event and runs every workflow tied to it. You can target a specific event or workflow: act pull_request, or act -W .github/workflows/test.yml.

Step 3: Write a workflow that tests every push and pull request

This is the highest-value workflow you'll write. It catches broken code before it reaches main, and before a contributor's pull request gets merged.

Create .github/workflows/test.yml:

name: Test
 
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
 
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
 
      - uses: actions/setup-node@v4
        with:
          node-version: 22
 
      - run: npm ci
 
      - name: Run Playwright tests
        run: |
          npx playwright install --with-deps
          npx playwright test
 
      - name: Upload test report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 30

A few things worth calling out:

  • on defines the trigger events. Testing on both push to main and pull_request into main means you catch breakage from direct pushes and from contributors before you merge them.
  • uses: actions/checkout@v4 is almost always your first step. It pulls your repository's code into the runner; without it, the container is empty.
  • npm ci, not npm install. It does a clean install from the lockfile, which is what you want in an automated environment: reproducible, and it fails loudly if package.json and package-lock.json are out of sync.
  • npx playwright test rather than a locally-installed binary is the recommended pattern for CI, since it always resolves the version pinned in your package.json.
  • upload-artifact with if: always() saves the Playwright HTML report even when the run fails, so you can open it from the Actions tab and see exactly what broke. Artifacts auto-delete after the retention-days window (30 here), no manual cleanup needed.
  • Naming a step (name: Run Playwright tests) isn't required, but do it anyway. An unnamed step just shows as "Run npx playwright test" in the logs, which is fine for one-liners but gets confusing fast once a workflow has ten steps.

Verify locally before you push:

act push

act will pull the matching Docker image the first time (this can take a few minutes), then run the workflow. Confirm you see the test output and a Job succeeded line.

Verify on GitHub: push a branch and open a pull request. The "Test" workflow should appear as a check on the PR within a few seconds, with a spinner while it runs.

Step 4: Deploy a site automatically on every merge

If your project has a docs site or any static frontend, this workflow rebuilds and redeploys it the moment anything lands on main, the same experience Vercel or Netlify give you for free, but wired to your own hosting.

The example below assumes your site source lives in a docs/ subdirectory (common for monorepo-style docs sites) and deploys to Firebase Hosting. Swap the deploy step for whatever your host provides.

name: Deploy Docs
 
on:
  push:
    branches: [main]
 
defaults:
  run:
    working-directory: docs
 
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
 
      - uses: actions/setup-node@v4
        with:
          node-version: 22
 
      - run: npm ci
      - run: npm run build
 
      - name: Deploy to Firebase Hosting
        run: npx firebase-tools deploy --only hosting --token "$FIREBASE_TOKEN"
        env:
          FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }}

defaults.run.working-directory scopes every run step in the job to docs/, so npm ci and npm run build execute against the docs site's package.json, not your project root.

Never hardcode credentials. FIREBASE_TOKEN here comes from a repository secret, not a literal string. This workflow file gets committed to a public repo, and a hardcoded token in git history is a leaked token forever.

Before this will work, add the secret:

  1. On GitHub, go to your repo, then Settings > Secrets and variables > Actions
  2. Click New repository secret
  3. Name it FIREBASE_TOKEN, paste the value, save

Verify: merge a PR into main (or push directly), then check the Actions tab. You should see the "Deploy Docs" workflow run and complete. Load your live site and confirm the change is there.

Step 5: Publish to npm on every GitHub release

You don't want a new npm version published on every commit; a typo fix doesn't warrant a version bump. Tying the publish workflow to GitHub's release event means publishing only happens when you deliberately cut a release, with release notes attached.

name: Publish to npm
 
on:
  release:
    types: [published]
 
jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
 
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          registry-url: https://registry.npmjs.org
 
      - run: npm ci
      - run: npm publish
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

Add NPM_TOKEN as a repository secret the same way you added FIREBASE_TOKEN in Step 4. Generate it from your npm account under Access Tokens (an "Automation" token, which bypasses 2FA prompts for CI).

Verify: on GitHub, go to Releases > Draft a new release, tag a version, publish it. Watch the Actions tab for the "Publish to npm" run, then check npmjs.com/package/<your-package> for the new version.

If a run fails for no clear reason (a flaky network blip, a registry timeout), GitHub lets you re-run the failed job (or all jobs) directly from the run's page, without needing a new commit.

Step 6: Run workflows on a schedule

Not every workflow needs to be tied to a code event. cron triggers let you run arbitrary code on a timer: nightly database exports, daily report generation, cleanup jobs.

name: Export Data
 
on:
  schedule:
    - cron: "0 3 * * *"
      timezone: "America/New_York"
  workflow_dispatch:
 
jobs:
  export:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: node scripts/export-to-storage.js
        env:
          GCP_SA_KEY: ${{ secrets.GCP_SA_KEY }}

The cron expression follows standard 5-field syntax (minute, hour, day-of-month, month, day-of-week): 0 3 * * * means "3:00 AM, every day." GitHub Actions now lets you attach an IANA timezone field alongside the cron expression, so you're no longer stuck converting everything to UTC by hand.

workflow_dispatch adds a manual trigger, a Run workflow button on the Actions tab, so you can fire the job on demand instead of waiting for 3 AM. The GitHub CLI can trigger it too: gh workflow run "Export Data".

Verify: run gh workflow run "Export Data" (or click Run workflow in the UI), then check the Actions tab for the run and confirm it completes successfully. Don't wait until 3 AM to find out your YAML has a typo.

Step 7: A few more workflows worth stealing

Once the pattern clicks, most CI/CD problems turn out to be "which pre-built action do I plug in." A few high-value ones:

  • Linting on every PR. Run your linter as a required check so style issues get caught automatically instead of in code review comments.
  • Stale issue management. actions/stale automatically labels or closes issues/PRs with no activity after a configurable window.
  • Auto-label and auto-respond. Label incoming issues by keyword, post a templated response, assign to a project board. Makes a repo look actively maintained with near-zero ongoing effort.

Before writing a custom step for something common, check awesome-actions; there's a good chance someone already built and published the action you need to the GitHub Marketplace.

If you do need something custom and reusable across projects, actions/toolkit provides the TypeScript building blocks (reading inputs, setting outputs, logging, calling the GitHub API) for writing your own action from scratch.

Step 8: Know your runner options

By default, every job in these examples runs on a GitHub-hosted runner (ubuntu-latest, windows-latest, macos-latest): a fresh VM GitHub provisions, runs your job on, and tears down. For most projects this is exactly right: zero maintenance, and it's what the free tier covers.

If you need hardware GitHub doesn't offer (GPUs, a specific OS image, access to an internal network), you can register self-hosted runners, machines you control that poll GitHub for jobs. It's more operational overhead (you own patching, scaling, security), so reach for it only when hosted runners genuinely can't do the job.

One more knob worth knowing: a matrix strategy lets a single job definition run across multiple environments in parallel:

strategy:
  matrix:
    os: [ubuntu-latest, windows-latest, macos-latest]
runs-on: ${{ matrix.os }}

Useful if you're shipping a CLI tool or library that needs to work cross-platform, not just on Linux.

What's changed in GitHub Actions recently

A few platform updates worth knowing about if you set up workflows a while ago and haven't looked since:

  • OIDC claims are GA. OpenID Connect tokens used for cloud authentication (AWS, Azure, GCP) can now include repository custom properties as claims, giving you finer-grained conditions in trust policies. That means no more managing long-lived cloud credentials as repo secrets at all, for providers that support OIDC federation.
  • Execution controls (Rulesets). Organizations can define centralized policies for who's allowed to trigger workflows and which events are permitted, built on GitHub's existing Ruleset framework. Useful for locking down workflow_dispatch or third-party PR triggers org-wide instead of per-repo.
  • Actions Data Stream. Real-time execution telemetry can now stream to Amazon S3 or Azure Event Hub / Data Explorer, which matters if you're building your own CI observability dashboard instead of squinting at the Actions UI.
  • entrypoint and command overrides. Container-based steps can now override image defaults directly from workflow YAML, with syntax matching Docker Compose. Handy when you're reusing a Docker image that wasn't originally built as a GitHub Action.
  • IANA timezones on cron (used in Step 6 above). Schedules no longer have to be hand-converted to UTC.
  • Self-hosted runner version enforcement is back. GitHub is re-enabling minimum version requirements for self-hosted runners, with enforcement deadlines rolling out through mid-to-late 2026. If you're running self-hosted runners, keep the runner software current or scheduled jobs will start failing.

Where to go from here

You now have the three workflows that cover 90% of what a project actually needs: test on every push and PR, deploy on every merge, publish on every release, all testable locally with act before they ever touch GitHub's infrastructure. From here, the highest-leverage next step is usually a linting workflow (cheapest to add, immediate payoff) followed by whichever maintenance automation (stale bots, auto-labeling) matches how your specific project gets used.

The underlying idea, worth keeping in mind as you add more: every manual, repeatable step in your workflow is a candidate for a .github/workflows/*.yml file. If you're doing it by hand more than twice, it's probably worth automating the third time.