DevOps.Academy
CoursesCurriculumPricing

    Loading…

    ↑ ↓ move · ↵ open · Ctrl K anywhere

    ◔My learningSign in
    DevOps.Academy

    Free, hands-on DevOps lessons. Made with care in India.

    Learn

    All coursesLinux roadmapCurriculumMy learning

    Academy

    PricingCertificates

    Legal

    PrivacyTermsContact

    devops.rajeev.pro

    CI/CD PipelinesLesson 2 of 12
    Your progress · 0%
    On this page
    1. How an event becomes a run
    2. The YAML you need
    3. Triggers: when a workflow runs
    4. Jobs and steps
    5. Runners: where jobs run
    6. Check it before you push
    ↖ Course roadmap
    Lesson 2 · GitHub Actions basics

    GitHub Actions: your first real pipeline

    How GitHub Actions turns an event into jobs on a runner: the YAML you need, the triggers that matter, jobs and steps, what a runner gives you, and checking a workflow before you push it.

    35 min read · beginner · hands-on

    By the end of this lesson you'll be able to

    0 of 6 complete

    How an event becomes a run

    A workflow is a YAML file in .github/workflows/. Something happens in the repository (a push, a pull request, a button press, a time of day), GitHub finds every workflow listening for that event, and starts a run. Each run has jobs; each job gets a fresh runner machine and executes its steps in order:

    Event → workflow run → jobs, each on its own fresh runner → steps in order. A failing step fails the job.© Diagram: DevOps Academy

    Event → workflow run → jobs, each on its own fresh runner → steps in order. A failing step fails the job.© Diagram: DevOps Academy

    Every lesson in this course builds the same small app, shop-api: a Node.js service with a /health endpoint, a /price endpoint that adds tax, and tests that use Node's built-in test runner. No dependencies to install, so the pipeline is the only thing you're learning:

    $ npm run lint && npm test
    ✔ adds 18% tax and rounds to cents
    ✔ rejects a negative amount
    ✔ GET /health answers ok
    ℹ tests 3
    ℹ pass 3
    ℹ fail 0
    
    npm run lint && npm test
    • &&run the next command only if this one worked

    Here's its first pipeline. Save it as .github/workflows/ci.yml:

    name: CI
    
    on:
      push:
        branches: [main]
      pull_request:
      workflow_dispatch:
    
    jobs:
      lint:
        runs-on: ubuntu-latest
        timeout-minutes: 5
        steps:
          - uses: actions/checkout@v7
          - uses: actions/setup-node@v7
            with:
              node-version: 22
          - run: npm run lint
    
      test:
        needs: lint
        runs-on: ubuntu-latest
        timeout-minutes: 10
        steps:
          - uses: actions/checkout@v7
          - uses: actions/setup-node@v7
            with:
              node-version: 22
          - name: Run the tests
            run: npm test
    

    The YAML you need

    Workflows use a small part of YAML. Indentation is structure: two spaces, never tabs.

    You writeYAML meansUsed for
    key: valuea mappingruns-on: ubuntu-latest
    - itema list itemeach step
    [a, b]a list on one linebranches: [main, release/*]
    run: | then indented linesa multi-line string, newlines keptscripts of several commands
    # texta commentexplaining why
    - name: Build and smoke-test
      run: |
        npm run build
        node dist/server.js &
        sleep 2 && curl -fsS http://localhost:3000/health
    
    YAML guesses types, and guesses wrong

    python-version: 3.10 is read as the number 3.1, so you get the wrong Python or a failed step. node-version: 22 is fine, but quote any version with a dot: "3.10", "1.20". Values like yes, no, on and off can also turn into true or false in some tools, so quote strings that look like them. actionlint doesn't catch the version trap, so it's on you.

    Triggers: when a workflow runs

    The on: block lists the events. These five cover almost every pipeline:

    on:
      push:
        branches: [main]            # pushes to main only
        paths-ignore: ['**.md']     # skip runs for documentation-only changes
      pull_request:                 # every PR, on open and on each new push to it
        branches: [main]            # …targeting main
      workflow_dispatch:            # a "Run workflow" button, and gh workflow run
        inputs:
          environment:
            type: choice
            options: [staging, production]
      schedule:
        - cron: '30 22 * * *'       # 22:30 UTC = 04:00 IST, every day
      release:
        types: [published]          # when a GitHub Release is published
    
    EventTypical use
    pull_requestthe checks that gate merging: lint, test, build
    push to mainbuild the artifact, deploy to staging
    workflow_dispatchmanual deploys, one-off maintenance
    schedulenightly security scans, cleanup, dependency checks
    releasedeploy to production (the Git course's Tags and releases lesson)

    schedule uses UTC, and scheduled runs can start several minutes late when GitHub is busy. They also stop after 60 days without activity in a public repository. Start a dispatch workflow from the terminal with gh workflow run ci.yml.

    Jobs and steps

    A job is a set of steps that run on one runner. Jobs run in parallel unless needs: says otherwise:

    jobs:
      lint:    { runs-on: ubuntu-latest, steps: [...] }
      test:    { needs: lint, runs-on: ubuntu-latest, steps: [...] }   # waits for lint
      build:   { needs: [lint, test], runs-on: ubuntu-latest, steps: [...] }
    

    A step does one of two things:

    StepRunsExample
    uses:a reusable action, configured with with:uses: actions/checkout@v7
    run:shell commands on the runner (bash on Linux)run: npm test

    Each step must succeed (exit code 0) for the next one to run. When one fails, the job fails, the rest of its steps are skipped, and jobs that need it are skipped too.

    Jobs don't share a disk

    Each job starts on a brand-new machine with an empty folder, which is why both jobs above check out the code and set up Node. To pass files from one job to another, upload them as an artifact (the Workflow details lesson). timeout-minutes matters too: the default is 360 minutes, and a hung test would burn six hours of runner time.

    Runners: where jobs run

    A runner is the machine a job runs on. GitHub-hosted runners are fresh virtual machines, created for each job and destroyed after it:

    runs-on:MachineNotes
    ubuntu-latestLinux VMthe default choice: fastest to start, cheapest
    windows-latestWindows VMfor Windows builds; costs more minutes than Linux on private repos
    macos-latestmacOS VMfor iOS and Mac builds; the most expensive minutes
    ubuntu-24.04pinned Linux imageuse a fixed version when -latest moving would break you

    The images come with Git, Docker, Node, Python, Java, the cloud CLIs and much more preinstalled (the full list is linked below). Public repositories get free minutes; private ones get a monthly allowance on each plan. The Scaling pipelines lesson covers self-hosted runners, for when you need your own hardware or network access.

    Check it before you push

    A typo in a workflow is found only after you push, wait and read a red run. actionlint finds most of them in under a second, on your machine. Here it is on a deliberately broken workflow:

    # .github/workflows/broken.yml
    on: push
    jobs:
      t:
        runs-on: ubuntu-latest
        steps:
          - run: echo ${{ github.event.pull_request.title }}
          - uses: actions/checkout@v3
        needs: build
    
    $ actionlint
    .github/workflows/broken.yml:3:3: job "t" needs job "build" which does not exist in this workflow [job-needs]
    .github/workflows/broken.yml:6:23: "github.event.pull_request.title" is potentially untrusted. avoid using it directly in inline scripts. instead, pass it through an environment variable. [expression]
    .github/workflows/broken.yml:7:15: the runner of "actions/checkout@v3" action is too old to run on GitHub Actions. update the action's version to fix this issue [action]
    $ echo $?
    1
    
    echo $?
    • echoprint text

    Three real bugs: a job that waits for one that doesn't exist, a script-injection hole (the Pipeline security lesson explains it), and an action too old to run. Messages are shortened here; actionlint adds a link to the docs for each.

    The same run in a real terminal: each error points at the exact line and column, and the exit code 1 fails a pre-commit hook or CI job.© Screenshot: DevOps Academy

    The same run in a real terminal: each error points at the exact line and column, and the exit code 1 fails a pre-commit hook or CI job.© Screenshot: DevOps Academy

    It checks structure, needs:, expressions, action inputs, outdated actions and, with shellcheck installed, the shell in your run: steps. Run it in a pre-commit hook (the Git course's Hooks lesson) and you'll rarely see a broken workflow on GitHub.

    ⌘
    Mini mission

    Give shop-api its first pipeline

    Create the shop-api app, add a CI workflow with a lint job and a test job that runs only after lint passes, check it with actionlint, then push it on a branch and open a pull request to see both checks run.

    Input: Node.js 22+, a GitHub repository, actionlintOutput: A pull request showing lint and test as passing checks
    Reveal one safe solution

    The whole app is two small files. src/app.js:

    import { createServer } from 'node:http'
    
    export function priceWithTax(amount, rate = 0.18) {
      if (!Number.isFinite(amount) || amount < 0) throw new RangeError('amount must be a positive number')
      return Math.round(amount * (1 + rate) * 100) / 100
    }
    
    export function createApp() {
      return createServer((req, res) => {
        const url = new URL(req.url, 'http://localhost')
        if (url.pathname === '/health') return send(res, 200, { status: 'ok', version: process.env.APP_VERSION ?? 'dev' })
        if (url.pathname === '/price') {
          try {
            return send(res, 200, { total: priceWithTax(Number(url.searchParams.get('amount'))) })
          } catch (err) {
            return send(res, 400, { error: err.message })
          }
        }
        send(res, 404, { error: 'not found' })
      })
    }
    
    function send(res, status, body) {
      res.writeHead(status, { 'content-type': 'application/json' })
      res.end(JSON.stringify(body))
    }
    

    src/server.js starts it, and test/app.test.js tests it:

    // src/server.js
    import { createApp } from './app.js'
    
    const port = Number(process.env.PORT ?? 3000)
    createApp().listen(port, () => console.log(`shop-api listening on :${port}`))
    
    // test/app.test.js
    import { test } from 'node:test'
    import assert from 'node:assert/strict'
    import { priceWithTax, createApp } from '../src/app.js'
    
    test('adds 18% tax and rounds to cents', () => {
      assert.equal(priceWithTax(100), 118)
      assert.equal(priceWithTax(9.99), 11.79)
    })
    
    test('rejects a negative amount', () => {
      assert.throws(() => priceWithTax(-1), RangeError)
    })
    
    test('GET /health answers ok', async () => {
      const server = createApp().listen(0)
      const { port } = server.address()
      const res = await fetch(`http://localhost:${port}/health`)
      assert.equal(res.status, 200)
      assert.equal((await res.json()).status, 'ok')
      server.close()
    })
    
    $ mkdir -p shop-api/src shop-api/test shop-api/.github/workflows && cd shop-api && git init
    $ npm init -y
    $ npm pkg set type=module scripts.test="node --test" scripts.lint="node --check src/app.js && node --check src/server.js"
    $ npm run lint && npm test           # after saving the three files above
    $ nano .github/workflows/ci.yml      # the workflow from this lesson
    $ actionlint && echo "workflow OK"
    workflow OK
    $ git add . && git commit -m "ci: add lint and test workflow"
    $ gh repo create shop-api --private --source=. --push
    $ git switch -c test/ci && git commit --allow-empty -m "test: trigger CI" && git push -u origin test/ci
    $ gh pr create --fill && gh pr checks --watch
    
    mkdir -p shop-api/src shop-api/test shop-api/.github/workflows && cd shop-api && git init
    • mkdirmake a directory
    • -pcreate missing parents, no error if it exists
    • &&run the next command only if this one worked
    • cdchange directory
    • gitversion control
    • initcreate a repository here
    actionlint && echo "workflow OK"
    • echoprint text
    git add . && git commit -m "ci: add lint and test workflow"
    • addstage changes
    • .this folder
    • commitsave staged changes
    git switch -c test/ci && git commit --allow-empty -m "test: trigger CI" && git push -u origin test/ci
    • pushupload commits

    node --check checks one file per call, which is why the lint script runs it twice. gh pr checks --watch shows lint finishing first and test starting after it, because of needs: lint. Break a test on purpose, push again, and watch the pull request turn red. Later lessons add to this same repository.

    Try it yourself

    0 of 6 steps done

    Run each task in your own account or terminal, then tick it off.

    Knowledge check

    Quick check

    Question 1 of 5

    Two jobs in one workflow have no needs between them. How do they run?

    Go deeper

    Official reading for this lesson

    • DocsGitHub Docs: understanding GitHub ActionsGitHub Docs ↗
    • DocsGitHub Docs: workflow syntaxGitHub Docs ↗
    • DocsGitHub Docs: events that trigger workflowsGitHub Docs ↗
    • ToolGitHub-hosted runners: images and installed softwareGitHub ↗
    • Toolactionlint: static checker for workflow filesactionlint ↗

    Or get every quiz answer right and it completes itself.

    ← PreviousWhat a pipeline is forNext lesson →Workflow details: expressions, matrices, caches and artifacts