Migrate to RunxBuild today!! Deploy More while Spending Less

Calculate your savings
unxBuild

GitHub CI Example: A Working Actions Workflow for Test, Build and Deploy

Sean

Platform Writer

Oct 01, 2026
9 min read

A GitHub CI example is a YAML file in .github/workflows that tells GitHub Actions to check out your code, install dependencies, run tests and build on every push or pull request, and the smallest useful version is under twenty lines.

GitHub CI Example: A Working Actions Workflow for Test, Build and Deploy

Most CI pain comes from copying a workflow without knowing what each line does. This one is annotated, small and grows with the project.

Table of contents

The smallest workflow that is useful

Create a file at .github/workflows/ci.yml in your repository. GitHub detects workflow files in that folder automatically and runs them when their trigger fires. Here is a minimal workflow for a Node project.

name: CI

on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npm test
      - run: npm run build

Read it top to bottom. The name appears in the Actions tab. The on block sets the triggers: a push to main and any pull request. A job named test runs on a fresh Ubuntu virtual machine that GitHub hosts. Each step either uses a prepackaged action or runs a shell command.

The checkout action pulls your repository onto the machine, which is needed any time a workflow uses your code. The setup action installs Node and, with the cache option, restores the npm cache between runs. The npm ci command installs exactly what the lockfile says, which is faster and more reproducible than npm install in CI.

GitHub’s own documentation includes an annotated version of a similar example, and it is the right place to check current action versions.

Triggers that match how you work

The on section decides when the workflow runs, and choosing it well saves minutes and money.

  • push to main. Verifies what actually landed.
  • pull_request. Verifies changes before merge, which is where CI earns its keep.
  • workflow_dispatch. Adds a manual run button, useful for deploy or maintenance workflows.
  • schedule. Runs on a cron expression, good for nightly checks and dependency audits.
  • paths filters. Skip the workflow when only documentation changed.
on:
  pull_request:
    paths:
      - 'src/**'
      - 'package.json'
      - 'package-lock.json'

A frequent mistake is triggering on every push to every branch and on pull requests, which runs the workflow twice for the same commit on a branch with an open pull request. Pick one pattern: run on pull requests, and on pushes only to the main branch.

Cancel superseded runs so a quick succession of pushes does not queue wasted work. A concurrency setting groups runs by branch and cancels the older ones:

concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

Test several versions with a matrix

If you support more than one runtime version or operating system, a matrix runs the same job across combinations without duplicating YAML.

jobs:
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, windows-latest]
        node: [20, 22]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node }}
          cache: npm
      - run: npm ci
      - run: npm test

That produces four jobs. Setting fail-fast: false lets the others finish when one fails, so you see the full picture instead of a single red cross.

Keep matrices honest. Each cell costs runner minutes and wait time, so test the versions you actually support and no more. A library benefits from a wide matrix. A web application you deploy to one runtime needs only that runtime.

Windows and macOS runners are slower and consume minutes at a higher rate than Linux on private repositories, so reserve them for when the platform genuinely matters. For most web apps, Linux alone is enough, and a single job keeps the feedback loop short.

Secrets, permissions and safe defaults

Workflows that deploy need credentials, and CI is a common path for leaks. A few rules prevent most of them.

Store tokens and keys as repository or environment secrets, never in the YAML. Reference them with the secrets context, and they are masked in logs.

- run: ./scripts/deploy.sh
  env:
    DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}

Restrict the default token’s permissions. By default a workflow gets a token that may be able to write to your repository. Set the narrowest scope at the top of the file:

permissions:
  contents: read

Be careful with pull requests from forks. Secrets are not passed to workflows triggered by forked pull requests, which is intentional, so a deploy step will not run there. Do not work around that by using triggers that run untrusted code with secrets, because that is a classic supply chain attack.

Pin third-party actions to a specific version, or better a full commit hash for anything sensitive, and update them deliberately. A moving tag in an action you do not control is code you run with your credentials.

Add a deploy job that waits for tests

A deploy job should only run when the tests pass and only from the main branch. The needs keyword creates that dependency and an if condition limits the branch.

  deploy:
    needs: test
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4
      - run: ./scripts/deploy.sh
        env:
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}

The environment setting attaches the job to a named environment in the repository settings. There you can store environment-specific secrets and require a human approval before the job runs, which is a sensible gate for production.

What the script does depends on where you deploy: copy files over SSH, push an image to a registry, call a platform’s deploy hook. Keep the script in the repository so the process is reviewable, make it fail loudly with non-zero exits, and finish with a health check that the new version responds.

Add a notification on failure to a channel the team watches. A deploy that fails quietly at three in the morning is only discovered by users.

Mistakes that break pipelines

After a few dozen workflows, the same failures show up again.

  1. Using npm install instead of npm ci. Builds drift from the lockfile and become unreproducible.
  2. No caching. Every run reinstalls from scratch and takes minutes longer than necessary.
  3. Tests that depend on the network or on each other. They pass locally and flake in CI. Mock outside calls and isolate state.
  4. Hard-coded secrets or tokens in YAML. Rotate any that were committed.
  5. A single giant job. Split lint, test and build into separate jobs so they run in parallel and failures are clear.
  6. Ignoring timeouts. A hung step can burn hours of runner time. Set timeout-minutes on jobs.
  7. No branch protection. If red builds can still merge, CI is decorative. Require the check to pass in repository settings.

Treat the workflow file as production code. Review changes to it, because it holds the keys to your deploys. The existing posts on CI/CD pipeline tools and automatic deployment rules cover how this fits the wider pipeline.

Deploying without writing a deploy job

You do not always need a deploy step in your workflow. If your host connects to the repository and builds on each push, then CI only has to gate the merge: tests run on the pull request, branch protection blocks merging a red build, and the host deploys when the main branch updates.

That split keeps credentials out of GitHub entirely and shortens the workflow to checks. It also means the platform’s own build log shows the deploy, separate from your test run.

On RunxBuild, web services and static sites deploy from a connected GitHub repository, with a build log, a live route, runtime logs and rollback to a previous deploy. Pair it with the workflow above and you get tests on every pull request and a deploy on merge, without a custom deploy script. The GitHub getting started guide covers connecting the repository, and the Node services docs list the build and start settings.

Reading a failed run quickly

When a run fails, the Actions tab shows each step with its log, and the failing step opens automatically. Read upward from the first red line rather than the last, since later errors are often consequences. Re-run only the failed jobs from the menu instead of the whole workflow, and use the debug logging option when a step fails mysteriously.

To reproduce failures locally, run the same commands the workflow runs, in a clean checkout, with the same Node version. If it passes locally and fails in CI, the usual culprits are an unpinned dependency, a missing environment variable, a case-sensitive file path on Linux, or a test that depends on timezone or locale. Add those to the workflow explicitly so the difference stops mattering.

How this fits the rest of the stack

CI minutes are cheap and the production they protect is not, so it is worth knowing what the deploy target costs per month. Run the numbers in the RunxBuild hosting calculator. When your tests are green and you are ready to deploy from the repository, the RunxBuild dashboard is where you connect it.

Useful related references:

FAQ

Where do GitHub Actions workflow files go?

In the .github/workflows directory of your repository, as YAML files. GitHub detects and runs them according to the triggers defined in each file.

Should I use npm ci or npm install in CI?

Use npm ci. It installs exactly what the lockfile specifies and fails if the lockfile is out of sync, which makes builds reproducible and usually faster.

How do I run a workflow only for pull requests?

Set on: pull_request in the workflow. To avoid duplicate runs, trigger pushes only for the main branch and let pull requests cover feature branches.

How do I keep secrets safe in Actions?

Store them as repository or environment secrets and reference them with the secrets context. Limit the default token permissions, and avoid running untrusted code from forks with secrets available.

How do I stop a deploy running when tests fail?

Give the deploy job needs: test so it waits for the test job and runs only if it succeeds. Add an if condition to restrict it to the main branch.

#github ci example#GitHub Actions#CI workflow YAML#Node CI#Continuous integration