GitHub Actions: First CI Workflow
Imagine every time anyone pushes code or opens a pull request, a fresh computer downloads the project, installs it and runs all the tests, then puts a green tick or a red cross next to the commit. That is continuous integration (CI), and GitHub Actions gives it to every GitHub repository for free on public projects.
In this lesson you will write a CI workflow for a small Node.js project, understand each line of the YAML, read the results on a pull request, and learn a few next steps such as a version matrix and secrets.
The project
Meera has a small JavaScript project, bill-split, that splits a restaurant bill between friends. It uses Node's built-in test runner, so it has no dependencies. Her package.json defines a test script:
{
"name": "bill-split",
"version": "1.0.0",
"type": "module",
"scripts": {
"test": "node --test --test-reporter=spec"
}
}On her laptop, npm test runs the three tests in split.test.js:
npm test> bill-split@1.0.0 test > node --test --test-reporter=spec ✔ splits ₹900 between 3 friends (6.334ms) ✔ rounds to paise (0.5776ms) ✔ rejects zero people (2.2973ms) ℹ tests 3 ℹ suites 0 ℹ pass 3 ℹ fail 0 ℹ cancelled 0 ℹ skipped 0 ℹ todo 0 ℹ duration_ms 1988.7665
The problem: tests only help if someone runs them. Kabir might push a change without running them, and the bug lands in main. CI makes running them automatic.
Key words
- A workflow is an automated process, defined in a YAML file inside
.github/workflows/in your repository. - An event (trigger) starts a workflow: a push, a pull request, a schedule, a manual button press.
- A workflow has one or more jobs. Each job runs on a fresh virtual machine called a runner.
- A job is a list of steps. A step either runs shell commands (
run) or uses a ready-made action (uses), a reusable building block published on GitHub.
Writing the workflow
Meera creates the file .github/workflows/ci.yml:
name: CI
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Check out the code
uses: actions/checkout@v7
- name: Set up Node.js
uses: actions/setup-node@v7
with:
node-version: 26
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm testLine by line:
name: CIis the label shown in the Actions tab and on pull requests.on:lists the events. This workflow runs on pushes tomainand on every pull request (when it is opened and whenever new commits are pushed to it).permissions: contents: readlimits what the workflow's automatic token may do. Giving only what a job needs is a good security habit.jobs:holds one job with the idtest.runs-on: ubuntu-latestasks for a GitHub-hosted Linux runner. Windows (windows-latest) and macOS (macos-latest) runners exist too.actions/checkout@v7copies the repository's code onto the runner. Without it, the runner's folder is empty. The@v7pins the action's major version.actions/setup-node@v7installs the Node.js version given underwith:.npm ciinstalls exactly whatpackage-lock.jsonlists (commit that file), andnpm testruns the tests. If anyruncommand exits with an error, the step fails, the job fails and the workflow shows a red cross.
YAML is sensitive to indentation. Use spaces only (never tabs), and keep items at the same level lined up exactly. A dash (-) starts a list item, which is why every step begins with one.
Pushing and watching it run
Commit the workflow like any other file:
git add .github/workflows/ci.yml package-lock.json
git commit -m "Add CI workflow that runs the tests"
git pushOpen the Actions tab on GitHub. A new run appears within seconds; click it to see the job, and click the job to see each step's log, exactly as it would print in a terminal. A green tick means every step passed.
CI on pull requests
The real value appears on pull requests. Suppose Kabir opens a PR that changes the rounding by mistake. The workflow runs on his branch, and the PR page shows a failed check. Clicking Details opens the log, which contains the same error he would see locally:
✖ failing tests: test at split.test.js:9:1 ✖ rounds to paise (3.9841ms) AssertionError [ERR_ASSERTION]: Expected values to be strictly equal: 33.3 !== 33.33
Kabir fixes the code and pushes again; the check reruns and turns green. If the repository owner adds a branch protection rule that requires the test check to pass, a PR with a red cross cannot be merged at all, so broken code never reaches main.
Testing several versions with a matrix
A matrix runs the same job several times with different values. This tests both the previous and the current Node.js LTS releases in parallel:
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [24, 26]
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: ${{ matrix.node-version }}
- run: npm ci
- run: npm test${{ ... }} is an expression: GitHub replaces it with a value before the step runs. Here it becomes 24 in one job and 26 in the other.
Secrets and other triggers
Workflows that deploy a website or call an API need passwords or tokens. Never write them in the YAML file. Store them in Settings > Secrets and variables > Actions, then read them in a step with ${{ secrets.DEPLOY_TOKEN }}. GitHub hides secret values in logs, and workflows triggered from forks do not receive your secrets.
Other useful events: workflow_dispatch: adds a Run workflow button for manual runs, schedule: with a cron expression runs a workflow on a timetable, and release: runs when you publish a release.
Common mistakes
- Saving the file in the wrong place. It must be in
.github/workflows/and end in.ymlor.yaml, or GitHub ignores it. - Tabs or uneven indentation in YAML. GitHub reports "Invalid workflow file". Use two spaces per level.
- Forgetting
actions/checkout. Later steps fail with "file not found", because the runner starts empty. - Committing secrets into the workflow. Use repository secrets instead, and change any secret that was ever committed.
Key points
- GitHub Actions runs workflows defined in YAML files in
.github/workflows/. on:sets the events;jobs:run on fresh runners;steps:eitherruncommands orusesactions.actions/checkoutgets the code andactions/setup-nodeinstalls Node.js; a failing command fails the job.- On pull requests, results appear as checks, and branch protection can block merging until they pass.
- Use a matrix to test several versions and store passwords in Actions secrets, read with
${{ secrets.NAME }}.
Exercise
Add a CI workflow to a repository of your own that has at least one command which can check the code (tests, or even node --check app.js). Make it run on pushes to main and on pull requests. Then open a pull request that deliberately breaks the check, see the red cross, fix it and watch it turn green.
Show solution
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 26
- run: node --check app.jsnode --check only checks the file's syntax, so a missing bracket in the PR makes the step exit with an error and the check fails. Pushing the fix to the same branch reruns the workflow automatically.
Quick Quiz
1. Where must GitHub Actions workflow files be stored?
GitHub only reads workflow files from .github/workflows/.
2. What does actions/checkout do?
Runners start empty, so most workflows begin with checkout.
3. What makes a workflow job fail?
Any failing step stops the job and marks it red.
4. How should a workflow get a deployment password?
Secrets are encrypted, hidden in logs and never committed.
Learn Git with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
