Skip to main content
BlogDevOps

How to Create CI/CD with GitHub Actions: 7 Beginner Steps

Cara membuat CI/CD GitHub Actions for beginners: write a first workflow, chain jobs with needs, add typecheck, lint and test gates, and block bad merges.

Matthews Wong

Software Engineer

October 202610 min read
How to Create CI/CD with GitHub Actions: 7 Beginner Steps

Key Takeaway

How to create CI/CD with GitHub Actions as a beginner: keep a YAML file in the .github/workflows folder, trigger it on push and pull_request, run npm ci, typecheck, lint and tests, then chain later jobs with needs. A failed job makes its dependent jobs skip, and branch protection makes a red check actually block the merge.

You wrote code that works on your laptop, pushed it, and only then noticed a type error that slipped into the main branch. Or you opened the Actions tab for the first time and saw an empty list, with no idea which file to create or where to keep it. At this point almost every beginner copies YAML from someone else's example, presses push and hopes for green.

This guide builds one workflow from scratch: one file, two jobs and a few ordered checks. Then you break it on purpose so you see the next job get skipped yourself. I matched the commands and key names against the official GitHub and npm documentation. The shape is the same as this site's deploy workflow (verify, then build, then deploy), kept as small as possible. The output I show is only what is certain to appear; the Actions tab may look slightly different over time.

Want the concepts first? Our free lessons cover quality gates in GitHub Actions and the DevOps pipeline from git push to production. This article is the hands-on side: you write the file, push, and see the result yourself.

What you need

Not much, and you do not need a server.

  • A GitHub account and one repository, with a Node.js project that has a package.json and a committed package-lock.json.
  • Node.js and git installed on your laptop, so you can run the same checks locally before you push.
  • Scripts for typecheck and lint. The examples use npx tsc --noEmit and npm run lint; swap in your own project's commands.

If your project has no TypeScript or linter yet, that is fine. The pattern is the same for any command that exits with a non-zero code when it fails, and that is what the runner looks at to decide pass or fail.

Step 1: Run the checks on your laptop first

CI only repeats on a server what you can run on your laptop. So first make sure these commands pass locally, in the order you will use in CI: cheap and fast ones first.

# npm ci deletes node_modules and installs exactly what package-lock.json says,
# which is the closest thing to a fresh CI runner on your own laptop.
npm ci
npx tsc --noEmit    # prints nothing when the types are fine
npm run lint
npm test

# Each command exits with 0 on success. Check the last one:
echo $?
# 0

According to the npm documentation, npm ci is meant for automated environments such as CI: it removes node_modules first and never modifies package.json or the lock files. If package-lock.json does not match package.json, it exits with an error instead of fixing it. That makes npm ci on your laptop the best rehearsal for a clean runner.

If npm ci complains about the lock file, package-lock.json is usually out of sync with package.json. Run npm install once on your laptop, commit the new lock file, then try again.

Step 2: Write your first workflow and push

Workflows live in the .github/workflows folder at the root of the repository, and the file must end in .yml or .yaml. Create ci.yml there. The on section sets the triggers: here, a push to the main branch and every pull request. One job named verify runs its steps in order on an ubuntu-latest runner.

# .github/workflows/ci.yml
name: ci

on:
  push:
    branches: [main]
  pull_request:

jobs:
  verify:
    name: Typecheck and lint
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npx tsc --noEmit
      - run: npm run lint

There are two kinds of step. uses calls a ready-made action (checkout fetches the repository code, setup-node installs Node.js), while run executes a plain shell command. The action versions here (v4) follow this site's workflow; the examples in GitHub's documentation now use newer majors, so check the action's page when you write a new workflow. And watch out for one very common folder-name mistake:

# Wrong: GitHub only reads .github/workflows (plural), so this file is ignored.
.github/workflow/ci.yml

# Right:
.github/workflows/ci.yml

Then commit and push. As soon as the push reaches main, GitHub reads the file and starts a run without you pressing anything.

git add .github/workflows/ci.yml
git commit -m "ci: add first workflow"
git push origin main

Step 3: Read the result in the Actions tab

Open the repository on GitHub and click the Actions tab. The order for reading the result, according to GitHub's documentation:

  1. Pick the ci workflow in the left sidebar.
  2. Click the run you want from the list of runs.
  3. In the left sidebar under Jobs, click the verify job.
  4. Expand each step to see its output.

If every step passes, the job is green. If one step is red, that is the step that failed. GitHub's documentation explains that by default a step runs only if the previous steps succeeded, so the steps below the red one do not run. That is why a single job already works as a small gate.

Step 4: Split into two jobs with needs

CI/CD needs an order between stages: check first, then build. According to GitHub's documentation, jobs in a workflow run in parallel by default, and needs is how you make them sequential. Without needs, the build job starts at the same time as verify, and a red verify does not stop it.

# Wrong: no needs. Jobs run in parallel by default, so build starts at the
# same time as verify and a red verify does not stop it.
  build:
    runs-on: ubuntu-latest
# Right: add this job under jobs: in ci.yml (it assumes an npm run build script).
# needs makes build wait for verify, and skips it when verify fails.
  build:
    needs: verify
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npm run build

The rule from the documentation: if a job fails or is skipped, all jobs that need it are skipped too, unless the job uses a condition that makes it continue, such as always(). The failure carries down the whole chain after the point where it happened.

# The same shape in this site's .github/workflows/deploy.yml (shortened):
jobs:
  verify:            # npm ci, tsc --noEmit, npm run lint
  build-and-push:
    needs: verify
  deploy:
    needs: build-and-push

This site's deploy workflow uses the same chain: verify runs npm ci, typecheck and lint; build-and-push needs verify; deploy needs build-and-push. For the deploy side (SSH, registry, Compose), read CI/CD with GitHub Actions from zero to production. In this guide we stop at the gate.

Step 5: Break it on purpose and watch the job get skipped

A gate is only proven once you have seen it hold something back. Add a type error, check it on your laptop first, then push.

// src/count.ts
const count: number = "three";
npx tsc --noEmit
# Expect a TS2322 error: Type 'string' is not assignable to type 'number'.

git add -A
git commit -m "test: break the typecheck on purpose"
git push origin main

In the Actions tab, the verify job is red and stops at the typecheck step. The lint step below it does not run, and the build job is marked skipped, not run. Nothing gets built. That is what a gate means: a bad commit stops at the first door. Remove that line, push again, and everything goes green.

A skipped job does not mean it passed. What you are looking for is red on verify; a grey build is only the consequence. If build turns green while verify is red, the needs line is almost certainly missing.

Step 6: Make sure CI actually calls your tests

A script in package.json protects nothing until a step calls it. This is real on this site: the repo has an npm test script, but the verify job in deploy.yml only runs npm ci, a typecheck and lint, so a failing test would not stop a deploy here. I am writing it as it is because you will find the same gap if you do not check.

# Wrong: package.json has a "test" script, but no step ever calls it.
      - run: npm ci
      - run: npx tsc --noEmit
      - run: npm run lint

# Right: cheapest checks first, tests last, so a typo fails in seconds.
      - run: npm ci
      - run: npx tsc --noEmit
      - run: npm run lint
      - run: npm test

The order is deliberate: cheap checks first. A typecheck and a linter report mistakes long before tests or a build finish, so a bad commit fails fast and the expensive jobs never start. If your project has no tests yet, skip this step and note that your gate does not cover them.

Step 7: Make a red check actually block the merge

A workflow only reports a result. What makes it binding is branch protection with required status checks: according to GitHub's documentation, all required checks must pass before collaborators can merge changes into the protected branch. Turn it on in the repository settings for the main branch, then pick the check from the verify job; the name shown follows the name key on that job.

Two traps. First, a successful, skipped or neutral status all satisfy the requirement, so a required check that is skipped by accident turns the merge button green. Second, job names should be unique across all workflows, because a reused name can give ambiguous results and block merges. One more thing: this site's deploy.yml only runs on pushes to main, not on pull requests, so a gate for pull requests needs a workflow with a pull_request trigger, like our ci.yml. The repo files cannot show whether branch protection is on; that lives in GitHub's settings.

Common errors and how to fix them

The six problems that most often stop beginners, with their causes and fixes.

SymptomCauseFix
The workflow does not appear in the Actions tabThe file is not in .github/workflows (for example workflow without the s), its extension is not .yml or .yaml, or it has not been pushed.Move it to .github/workflows/ci.yml at the repository root, then push.
npm ci errors during installationpackage-lock.json is missing or does not match package.json. npm ci does not repair it; it exits with an error.Run npm install on your laptop, commit the new package-lock.json, then push again.
The build job still runs while verify is redThe build job has no needs, so it runs in parallel with verify.Add needs: verify to the build job.
Green on your laptop, red in CIThe runner starts clean: files you have not committed are not there, and the Node version may differ from your laptop.Commit every file you need, match node-version to your local version, and run npm ci on your laptop to imitate the runner.
A test fails, but the flow carries onNo step calls npm test; a script in package.json alone is not enough.Add run: npm test to the verify job, after the typecheck and lint.
A pull request can still merge although a check is red or skippedThere are no required status checks yet, or the required check was skipped (a skipped status counts as satisfied).Turn on branch protection with required status checks, make sure the required job runs on every pull request, and keep job names unique.

Two habits save the most time: run the same commands on your laptop before you push, and read the red steps from top to bottom. The first red step is the cause; the ones below it are only victims.

Checklist before you call your CI done

  • The file is in .github/workflows with a .yml or .yaml extension, and it has been pushed.
  • The triggers cover a push to main and pull_request.
  • The commands in CI match what you run on your laptop, cheap first and expensive last.
  • Every later-stage job has needs pointing at the job that must guard it.
  • You have broken it on purpose and seen the next job skipped, and required status checks are on for the main branch.

Learn more

The concepts are in the free lessons quality gates in GitHub Actions and the DevOps pipeline from git push to production. To see the pipeline through to production, read the article above; if your runs feel slow, look at GitHub Actions caching strategies.

The rule to carry away: CI is what your workflow actually runs, not what package.json says; needs is what creates the order; and branch protection is what makes red mean stop. Start with one small honest job, then add gates one at a time.

FAQ

Frequently Asked Questions

Short answers to what readers ask most about this topic.

Where do I put a GitHub Actions workflow file?

Put it in the .github/workflows folder at the root of your repository, and name it with a .yml or .yaml extension, for example ci.yml. GitHub reads workflows only from that folder, so a typo such as .github/workflow means the file is ignored. Commit and push it, and the run appears in the Actions tab.

Why is my GitHub Actions workflow not running or not showing up?

The usual causes are a wrong folder (it must be .github/workflows), a wrong extension, or a file that was never pushed. Also check the on section: a workflow set to run on push to main will not run for a push to another branch. Fix the path or trigger, push again, and look at the Actions tab.

How do I run a deploy job only when the tests pass?

Give the deploy job a needs line that points at the job running your checks, for example needs: verify. If the checks job fails or is skipped, GitHub skips every job that needs it, so the deploy never starts. Make sure the checks job really calls your test command, because a script nobody runs protects nothing.

Does a skipped job in GitHub Actions count as passed?

A skipped job is not a pass, but in two places it behaves leniently. Jobs that need a failed job are skipped as a consequence of the failure, and branch protection treats a successful, skipped or neutral status as satisfying a required check. So make sure the job you require really runs on every pull request.

Can GitHub Actions block a pull request when a check fails?

Yes, but the workflow alone only reports the result. You also need branch protection with required status checks on the target branch, and the workflow must run on the pull_request event. Then the pull request cannot merge until the required check passes.