Right, let’s get your TypeScript project off your local machine and into the cold, unforgiving light of automation. You’re not just writing code; you’re building a system. And a system that only works on your machine is a system that’s one npm install away from collapse. We’re going to use GitHub Actions because it’s right there, it’s powerful, and frankly, it’s a lot less fuss than some alternatives for a project living on GitHub.

The goal is simple: every time you push code or open a pull request, a virtual machine in the cloud should spin up, clone your code, and run a gauntlet of checks to prove it’s not a house of cards. We’re talking type checking, linting, testing, and finally, building. If any of these steps fail, the whole process fails, and you get a big red X. It’s brutally honest, and you’ll learn to love that honesty.

Here’s the blueprint. You create a file at .github/workflows/ci.yml in your repo. This YAML file defines our entire continuous integration process.

name: CI

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

jobs:
  ci:
    name: Type Check, Lint, Test, Build
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

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

      - name: Install dependencies
        run: npm ci

      - name: Type Check
        run: npx tsc --noEmit

      - name: Lint
        run: npm run lint

      - name: Test
        run: npm test

      - name: Build
        run: npm run build

Let’s break this down, piece by piece, because just copying this is a recipe for eventually getting confused.

The Trigger: on

This tells GitHub when to bother running this workflow. We’ve set it to run on any push to the main branch and any pull_request targeting main. This is your first line of defense. No code gets into main without passing this CI check. It’s the bouncer at the club.

The Execution Environment: runs-on

We’re using ubuntu-latest. It’s a clean, standard Linux environment. You could use Windows or macOS runners, but they’re slower to start and you’re probably not doing anything platform-specific for a basic TS project. Stick with Ubuntu for speed and consistency.

The Critical Steps: Caching and npm ci

Notice the cache: 'npm' in the Setup Node.js step? This is a pro move. It caches your node_modules directory based on your package-lock.json. If the lockfile hasn’t changed, the next run will pull the modules from a cache, slashing your installation time from minutes to seconds.

And we use npm ci instead of npm install. This isn’t a minor preference; it’s a rule. npm ci is designed for automated environments. It’s stricter and faster. It blows away the existing node_modules and installs exactly what’s in your package-lock.json. It will throw an error if your package.json and package-lock.json are out of sync, which is exactly what you want. An inconsistent lockfile is a silent killer.

The Gauntlet: The Actual Commands

Now for the main event. The order here is deliberate. We run the fastest, most fundamental checks first to fail quickly.

  • npx tsc --noEmit: This compiles your TypeScript code for type errors but doesn’t output any files. It’s a pure type check. If this fails, nothing else matters. Your tests might run, but they’re running on potentially broken code. Stop here.
  • npm run lint: Your linter (probably ESLint) enforces code style. A style failure might not break your app, but it breaks your team’s sanity. Enforcing it here keeps everyone honest.
  • npm test: This runs your test suite (Jest, Vitest, etc.). If you’ve set up your testing framework correctly, this will fail the workflow step if any tests fail.
  • npm run build: The final proof. This is where you might run tsc again with emit, or use a bundler like Webpack or Vite. This ensures your code can actually be packaged for production. I’ve lost count of the times a PR passed tests but failed to build because of some obscure path or configuration issue. This step catches that.

Handling Environment Variables and Secrets

Your tests might need API keys or database URLs. For local development, you use a .env file. For GitHub Actions, you use secrets. Never, ever commit your .env file.

Go to your repo’s Settings > Secrets and variables > Actions. Add a new secret, say, DATABASE_URL. In your workflow, you can inject it into the environment.

- name: Test
  run: npm test
  env:
    DATABASE_URL: ${{ secrets.DATABASE_URL }}
    SOME_API_KEY: ${{ secrets.SOME_API_KEY }}

This makes the secret available to the Node.js process as process.env.DATABASE_URL. Your test setup code (e.g., in jest.config.js or a setupFiles script) can then pick it up, just like it does with dotenv locally.

When Things Get Fancy: The Matrix Strategy

What if you need to test against multiple versions of Node.js? You don’t create three separate workflows. You use a build matrix. It’s GitHub Actions’ way of running the same job in multiple parallel variations.

jobs:
  ci:
    name: CI on Node.js ${{ matrix.node-version }}
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [18, 20, 22]
    steps:
      - uses: actions/checkout@v4
      - name: Use Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'npm'
      - run: npm ci
      - run: npx tsc --noEmit
      # ... and so on

Now your workflow will spin up three separate jobs, one for each Node version. They all have to pass. This is how you ensure your library or app doesn’t accidentally rely on some feature only available in the latest version. It’s the difference between being confident and being hopeful.

This isn’t just busywork. This is the foundation of professional-grade software development. Set it up once, and it becomes your brilliant, tireless, and brutally blunt coding partner.