Skip to main content

CI/CD with GitHub Actions: Building the Pib Patrol

Updated 16 min readMarkdown

Watch on Youtube (Hand-drawn Animations) (No AI): https://youtu.be/0sqIkd4UBQg

https://youtu.be/0sqIkd4UBQg

A junior dev, a senior dev, a pizza that is also a burger, and every GitHub Actions concept you need to read (and write) a real workflow.

1. Why do we need CI/CD?

A junior developer asks a senior to review his code. The senior asks if he ran the unit tests. “Should I?” Yes. Then static analysis. Then lint. Then coverage. Each time, the junior runs back, annoyed that nobody gave him the full list at once. Finally everything is “done,” the senior says LGTM, and the code is merged.

Days later, the senior coughs. “Did you test it properly?” while the junior responds angrily “What do you mean by properly, I even found bugs later and being the talented dev, fixed them as well.” and the senior follows up with “Did you test it after your fix?” The junior offers him an ice cream to cool down but the Production is already on fire.

Nothing here was a talent problem. It was a process problem:

  • Humans forget steps, especially after “just a small fix.”
  • Manual checking doesn’t scale as the team and the code grow.
  • Bugs found late are far more expensive than bugs found minutes after the change.

CI/CD exists so that the checklist runs automatically, identically, on every change, whether or not anyone remembers.


2. What is CI/CD?

Continuous Integration (CI) means every code change is automatically built and tested in a clean environment, and changes are merged into the shared branch often so problems surface while they’re small. Typical CI checks:

  • Build verification: does it compile or package?
  • Unit tests: does each small piece work alone?
  • Integration tests: do the pieces work together?
  • Lint and static analysis: is the style consistent, and does the code contain known risky patterns?
  • Coverage: how much of the code did the tests actually exercise?

The “CD” has two meanings, and interviewers like asking which one you mean:

Term What it means
Continuous Delivery Every change that passes CI is automatically prepared for release. A human still approves the final push to production.
Continuous Deployment Every change that passes all checks goes to production automatically, with no human approval.

CI doesn’t replace running quick checks locally. Do both: local checks for fast feedback, CI as the safety net that doesn’t depend on anyone’s memory.


3. What is GitHub Actions?

GitHub Actions is GitHub’s built-in automation platform. You describe automation in a YAML file inside your repository, and GitHub runs it in response to events such as a push or a pull request. Official starting point: GitHub Actions documentation.

The vocabulary, mapped to our pib factory (a food company that makes pizza-burgers, and now builds a checking department so no bad pib reaches customers):

GitHub Actions term In the pib factory
Workflow A checking department, one automated process
Event / trigger The department’s entrance door
Job A fresh, isolated room inside the department
Runner The type of room (Linux, Windows, Mac)
Step One task on the room’s checklist
Action A ready-made appliance from the marketplace
Output A postcard dropped in a postbox
Artifact A package sent by courier

A minimal workflow has this shape. We’ll fill in each part in the next sections:

name: pib_patrol          # 4. the workflow
on: [push]                # 5. the trigger
jobs:                     # 6. the jobs
  slice:
    runs-on: ubuntu-latest
    steps:                # 7-8. the steps
      - run: echo "hello"

4. The workflow: name and location

A workflow is one YAML file. GitHub only discovers workflow files in this folder:

.github/workflows/pib_patrol.yaml

Put the file anywhere else and nothing happens.

name: pib_patrol

Nuances:

  • name is the label shown in the repository’s Actions tab. If you omit it, GitHub shows the file path.
  • File extension can be .yml or .yaml.
  • A repository can have many workflows, for example one for tests, one for security scans and one for builds. Each is its own file and runs independently.
  • YAML is whitespace-sensitive: use spaces, never tabs.

Reference: Workflow syntax for GitHub Actions.


5. Triggers: the entrance doors (on)

The on key lists the events that start the workflow. You can list one or several:

on:
  pull_request:
  push:
  workflow_dispatch:
  schedule:
    - cron: "0 0 * * *"

5.1 pull_request

Runs when a pull request is opened, synchronized (new commits pushed to its branch) or reopened. Those three are the defaults. Change them with types, and limit to certain target branches with branches:

on:
  pull_request:
    branches: [main]                 # PRs targeting main
    types: [opened, synchronize, reopened, ready_for_review]

5.2 push

Runs when commits are pushed. With no filters it fires for every branch. Narrow it down:

on:
  push:
    branches: [main]
    paths: ["src/**"]                # only when files under src/ changed

A popular setup: pull_request for all branches, push for main only.

5.3 workflow_dispatch (manual run)

Adds a Run workflow button in the Actions tab, and can also be started from the GitHub CLI or the REST API. It can take typed inputs:

on:
  workflow_dispatch:
    inputs:
      environment:
        description: "Where to run"
        type: choice
        options: [staging, production]
        default: staging

Read it later with ${{ inputs.environment }}. The workflow file must exist on the default branch for the button to appear.

5.4 schedule (cron)

Runs the workflow at set times using POSIX cron syntax, five space-separated fields:

┌───────── minute        (0 - 59)
│ ┌─────── hour          (0 - 23)
│ │ ┌───── day of month  (1 - 31)
│ │ │ ┌─── month         (1 - 12 or JAN-DEC)
│ │ │ │ ┌─ day of week   (0 - 6 or SUN-SAT)
│ │ │ │ │
* * * * *

Operators you can use in any field:

Operator Meaning Example
* every value * in hour = every hour
, list 1,15 = the 1st and 15th
- range 1-5 = Monday to Friday
/ step */15 = every 15 units
on:
  schedule:
    - cron: "0 9 * * 1-5"
      timezone: "America/New_York"   # optional, see below

Things that surprise people:

  • Time zone: by default, schedules run in UTC. GitHub has added an optional timezone field (an IANA name such as Asia/Kolkata); without it, convert your local time to UTC yourself.
  • Minimum interval is 5 minutes. * * * * * will not run every minute.
  • Default branch only. Schedules run on the latest commit of the default branch. Changing the cron on a feature branch does nothing until it’s merged.
  • Delays are normal. Runs can start late, especially at busy times such as the top of the hour. Offsetting to something like 17 3 * * * helps.
  • Auto-disable: in public repositories, scheduled workflows are disabled after 60 days without repository activity, and they’re disabled by default in forks.
  • Quote the cron string. A value starting with * is special in YAML.
  • You can list several cron entries. Use github.event.schedule to see which one fired.

These are just the common ones. GitHub supports many more events, such as release, issues, workflow_run and repository_dispatch. See Events that trigger workflows for the full list.


6. Jobs and runners (jobs, runs-on)

When a trigger fires, GitHub starts the workflow’s jobs. Each job is a group of steps that run on one machine.

jobs:
  slice:                    # job ID
    name: Slice the pib     # optional display name
    runs-on: ubuntu-latest
    timeout-minutes: 15

Nuances:

  • Job ID rules: it must start with a letter or _ and contain only letters, numbers, - and _. No spaces. Use name: for a friendly label.
  • Jobs run in parallel by default and know nothing about each other (see section 9).
  • Fresh machine every time. On GitHub-hosted runners each job gets a brand-new virtual machine that is discarded when the job ends. That’s what makes CI a clean environment, and also why nothing carries over between jobs on its own.
  • Steps inside one job share the machine, so a file created in one step is visible in the next.
  • Timeouts: a GitHub-hosted job can run up to 6 hours, then it’s terminated; set timeout-minutes to fail faster. A whole workflow run is cancelled at 35 days. See Actions limits.

Choosing the room: runs-on

Label Machine
ubuntu-latest Linux, the usual default
windows-latest Windows
macos-latest macOS (usually the most expensive)
ubuntu-24.04 A specific, pinned Linux version
self-hosted Your own machine

-latest is a moving label: when GitHub promotes a newer image, the same word points to a new OS version. Pin the version if you need stability. Self-hosted runners are machines you maintain yourself, for special hardware or private networks; they can keep files between jobs unless you clean them, and they have their own usage limits.

Teaser for another day: a matrix (strategy.matrix) can create many jobs, such as 3 operating systems × 3 language versions, from one definition, up to 256 jobs per run.


7. Steps and run: your own commands

A job’s steps run one after another, top to bottom, on the same runner. Each step is either a command (run) or a reusable action (uses, next section).

steps:
      - name: Slice the pib
        id: slice
        run: echo "Slicing the pib..."
  • name is the label in the logs.
  • id lets other parts of the workflow refer to this step (we need it for outputs).

Running several commands: the | pipe

The | after run: is YAML block syntax, not a shell pipe. It keeps line breaks, so each line becomes a separate command in the same shell:

 - name: Prepare and slice
        run: |
          echo "Heating the oven"
          mkdir -p out
          echo "4 slices" > out/report.txt

Nuances:

  • On Linux and macOS the default shell is bash with -e, so if any line fails, the step stops and fails right there.
  • Lines share one shell, so a variable set on line 1 is visible on line 3.
  • Use env: to pass environment variables to a step.

In a real project, this is where pytest, npm test, mvn verify or your linter goes.


8. Actions and the Marketplace: uses

A fresh runner is empty. To get your repository’s code onto it, you could script authentication, cloning and private-repo handling yourself, or you can reuse what someone has already built. That is uses:

      - name: Get the recipe
        uses: actions/checkout@v6

An action is a reusable unit of automation, shared through the GitHub Marketplace or any repository. Inputs go under with:

      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"

The format is owner/repo@version. Nuances:

  • The version after @ can be a tag (@v6), a branch, or a full commit SHA. Tags can move, so security-conscious teams pin to a commit SHA.
  • Treat third-party actions as dependencies. They run inside your pipeline: prefer trusted publishers, read the source, and keep versions updated.
  • actions/checkout fetches only the latest commit by default (shallow clone). Use fetch-depth: 0 when you need full history.
  • Rule of thumb: uses calls someone else’s action, run runs your own command.

Popular first stops: actions/checkout, actions/setup-python, actions/upload-artifact, actions/download-artifact.


9. Running together, then waiting: needs

Add a second job next to slice:

  taste:
    runs-on: ubuntu-latest
    steps:
      - run: echo "Tasting the pib"

Run it, and both jobs start at the same moment. taste has nothing to taste but still turns green, because jobs are independent by default. Parallel-by-default keeps pipelines fast; you just have to declare real dependencies:

  taste:
    needs: slice
    runs-on: ubuntu-latest

Nuances:

  • needs makes the job wait until the needed job finishes successfully.
  • If the needed job fails, the dependent job is skipped, not failed. Override with if: ${{ always() }} for cleanup or notification jobs.
  • Depend on several jobs with a list: needs: [slice, build]. It waits for all of them.
  • Chains work (C needs B needs A), but circular dependencies are rejected.
  • needs is also what lets a job read another job’s outputs.

10. Passing small data: outputs

Jobs run on separate machines, so one job can’t read another’s variables. To send small values forward, use outputs. It takes three hops:

jobs:
  slice:
    runs-on: ubuntu-latest
    outputs:
      count: ${{ steps.slice.outputs.count }}   # 2. expose it
    steps:
      - name: Slice the pib
        id: slice
        run: echo "count=4" >> "$GITHUB_OUTPUT"   # 1. write it

  taste:
    needs: slice
    runs-on: ubuntu-latest
    steps:
      - run: echo "Tasting ${{ needs.slice.outputs.count }} slices"   # 3. read it
  1. A step writes name=value to the file named by $GITHUB_OUTPUT. (It’s GITHUB_OUTPUT, singular; the old ::set-output command is deprecated.)
  2. The job exposes it in its outputs: block, using the step’s id.
  3. Another job reads it as needs.<job_id>.outputs.<name>, which requires needs.

Nuances:

  • Outputs are strings and meant to be small: a version, a count, a flag. They have size limits (see the docs).
  • A step output doesn’t leave its job unless you map it in outputs:.

Official guide: Pass job outputs.


11. Passing files: artifacts

A report, a build, or a pile of test results won’t fit in a postcard. Artifacts store files from a job so other jobs, or you, can download them later.

  slice:
    runs-on: ubuntu-latest
    steps:
      - run: echo "Pib cut into 4 slices" > report.txt
      - uses: actions/upload-artifact@v4
        with:
          name: pib-report
          path: report.txt
          retention-days: 30          # optional

  check_cuts:
    needs: slice                      # wait, or there is nothing to download
    runs-on: ubuntu-latest
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: pib-report
      - run: cat report.txt

How long are artifacts kept?

  • Default: 90 days. Artifacts and logs are deleted automatically after that.
  • Public repositories: you can set retention anywhere from 1 to 90 days.
  • Private and internal repositories: anywhere from 1 to 400 days.
  • Set it per artifact with retention-days on the upload step, or as a default for a repository, organization or enterprise in settings. A repository can’t exceed the cap set by the organization or enterprise that manages it.
  • A new retention setting applies only to new artifacts, not existing ones.
  • Separate from this, workflow run history is kept 400 days and that can’t be changed.

See Configuring retention for artifacts and logs and usage limits and storage. Artifact storage counts toward your plan’s quota.

Other artifact details

  • Upload options: name, path (a file, folder or glob), retention-days, if-no-files-found (warn, error or ignore) and overwrite.
  • Names must be unique within a run with upload-artifact@v4. Jobs on several operating systems should put the OS in the name (for example report-${{ runner.os }}).
  • Download by name, or omit name to download all artifacts of the run; each lands in its own folder.
  • Downloading from another workflow run needs the run-id and a github-token.
  • You can also download artifacts by hand from the workflow run page.
  • Artifacts vs. cache: artifacts keep results you want to pass on or inspect. actions/cache stores dependencies so the next run is faster.

Outputs vs. artifacts in one line: outputs are small strings passed between jobs; artifacts are files that outlive the job.

Repos: upload-artifact and download-artifact.


12. The whole pib_patrol

name: pib_patrol

on:
  pull_request:
  push:
  workflow_dispatch:
  schedule:
    - cron: "0 0 * * *"

jobs:
  slice:
    runs-on: ubuntu-latest
    outputs:
      count: ${{ steps.slice.outputs.count }}
    steps:
      - name: Get the recipe
        uses: actions/checkout@v6

      - name: Slice the pib
        id: slice
        run: |
          echo "Slicing the pib..."
          echo "count=4" >> "$GITHUB_OUTPUT"
          echo "Pib cut into 4 slices" > report.txt

      - name: Upload report
        uses: actions/upload-artifact@v4
        with:
          name: pib-report
          path: report.txt

  taste:
    needs: slice
    runs-on: ubuntu-latest
    steps:
      - name: Taste the slices
        run: echo "Tasting ${{ needs.slice.outputs.count }} slices"

  check_cuts:
    needs: slice
    runs-on: ubuntu-latest
    steps:
      - name: Download report
        uses: actions/download-artifact@v4
        with:
          name: pib-report

      - name: Check report
        run: cat report.txt

Read the flow: a trigger opens the door, slice runs, and when it finishes taste and check_cuts start in parallel, one reading a postcard and the other collecting a courier package. In a real project, slice becomes your build, taste your tests, and check_cuts your lint, coverage or report checks.


13. Quick reference and what’s next

Item Value
Workflow folder .github/workflows/
pull_request default types opened, synchronize, reopened
Cron fields minute, hour, day of month, month, day of week
Cron time zone UTC by default (optional timezone field)
Shortest schedule interval 5 minutes
Scheduled runs Default branch only
Job time limit (GitHub-hosted) 6 hours
Workflow run limit 35 days
Matrix limit 256 jobs per run
Artifact retention default 90 days
Artifact retention range 1-90 days public, 1-400 days private/internal
Workflow run history 400 days, fixed

Where to go next:

  • Make checks mandatory. With branch protection and required status checks, a pull request can’t merge until the pipeline passes. Without that, CI is only advice.
  • Conditions (if:) to run steps or jobs only in certain situations, secrets for credentials, caching for speed, and matrix builds.
  • Reusable workflows and custom actions to share automation across repositories.
  • Continuous delivery, so changes that pass every check reach users automatically.

All limits and defaults can change, so double-check them in the official limits page before you rely on them.


Remember

Continuously integrate, or someone will end up under this:

REST IN PRODUCTION

No comments yet. Be the first to comment.