Skip to main content
ANVISoftware Solutions
Lesson 15 of 15Intermediate16 min

GitHub Actions

By the end of this lesson

Run checks automatically on every push and pull request.

Every team has a set of checks that should pass before a change is accepted: it builds, the tests pass, the formatter has been run, nothing obvious is broken. Relying on each person to remember means it holds until the afternoon somebody is in a hurry.

GitHub Actions runs those checks for you, on GitHub's machines, triggered by events in the repository. You describe what to run in a file, commit the file, and from then on every push and every pull request gets the same treatment. Running checks automatically on every change is usually called continuous integration, shortened to CI.

Five terms, and they nest inside each other:

Workflow
One YAML file in .github/workflows describing what to run and when. A repository can have several, each for a different purpose.
Event
What triggers the workflow: a push, a pull request opening or updating, a schedule, a manual click. Listed under the on: key.
Job
A named unit of work that runs on a fresh machine. Jobs in one workflow run in parallel unless you declare that one needs another.
Step
One thing inside a job, in order: either a shell command or a reusable action published by someone else.
Runner
The machine executing a job. GitHub provides Linux, Windows and macOS runners, and each job starts on a clean one with nothing of yours on it.
.github/workflows/ci.yml
YAML
name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  build-and-test:
    runs-on: ubuntu-latest

    steps:
      - name: Check out the code
        uses: actions/checkout@v4

      - name: Install the .NET SDK
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: "8.0.x"

      - name: Restore dependencies
        run: dotnet restore

      - name: Build
        run: dotnet build --no-restore --configuration Release

      - name: Run tests
        run: dotnet test --no-build --configuration Release
  • The on: block declares two triggers. push with branches: [main] runs the workflow when commits land on main. pull_request runs it against the proposed merge result every time the branch is updated, which is the one that protects main.
  • runs-on: ubuntu-latest asks for a Linux runner. The job starts on a clean machine, which is why the first step has to fetch your code.
  • uses: refers to a published action. checkout copies your repository onto the runner; setup-dotnet installs the SDK. Action versions move over time, so check the action's own repository for the current major version rather than copying one from an old example.
  • run: executes a shell command. Any non-zero exit code fails the step, fails the job, and marks the check as failed on the pull request.
  • --no-restore and --no-build tell the later steps not to repeat work the earlier ones did. Matching --configuration across build and test matters: mismatch them and the test step rebuilds, which is slower and quietly defeats --no-build.
  • Set dotnet-version to match what your project targets. A workflow that builds against a different SDK than your team uses locally will eventually disagree with them.
Adding the workflow is an ordinary Git change
Shell
git switch -c add-ci-workflow

# Create .github/workflows/ci.yml, then:
git add .github/workflows/ci.yml
git commit -m "Run build and tests on every push and pull request"

git push -u origin add-ci-workflow

# Open a pull request. The workflow runs against this very branch,
# so the pull request itself tells you whether the workflow works.
  • The workflow file is committed like any other file, which means it is versioned, reviewable and revertable. A change to the build process goes through review the same way a change to the code does.
  • The path matters. GitHub looks for workflows in .github/workflows, and a file anywhere else is ignored with no error message.
  • Opening a pull request is the quickest way to test a new workflow, because the pull_request trigger applies to the branch adding it.
  • Expect two or three attempts before it passes. A workflow runs on a clean machine with none of the tools your own machine has accumulated, and the first failures are usually something you had installed and forgot about.

What is worth running on every change, roughly in order of value per second spent:

  • The build — a change that does not compile should never reach a reviewer
  • Unit tests — fast, deterministic, and they catch the majority of regressions
  • A formatter or linter check — so formatting stops being a review topic at all
  • Integration tests against a service started for the run, if they finish in a reasonable time
  • A dependency audit for known vulnerabilities in what you depend on

Summary

  • A workflow is a YAML file in .github/workflows describing what to run and which events trigger it
  • Workflows contain jobs, jobs contain steps, and each job starts on a clean runner with none of your local tooling
  • Running the build and tests on pull_request catches a problem while it is still a proposal
  • Any non-zero exit code fails the step and marks the check as failed, which is what makes the check meaningful
  • Never put literal secrets in a workflow file — it is committed, so the value is in the history permanently

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Try it yourself

Add a workflow and watch it fail on purpose

Add the workflow above to a practice repository, adjusting the SDK version and commands to match your project. Open a pull request and confirm the checks pass.

Then push a commit that deliberately breaks a test, and look at what the pull request shows you.

Show solution

The failing run marks the check as failed and links to the log, with the failing step expanded. The point of the exercise is to read that log once while you know exactly what caused it, so it is familiar when the cause is a mystery.

Notice how much the pull request now tells a reviewer before they read a line of your code. That is the return on a twenty-line file.

Think about it

Think about it

A test passes on every developer's machine and fails on the runner every time.

List four environment differences that could explain it. Which is the more likely culprit: the test or the runner?

Show solution

Plausible causes: a file or folder that exists locally but was never committed; a tool installed on developer machines and not on the runner; a different time zone or locale affecting date or number formatting; tests running in a different order or in parallel, exposing a dependency between them; a case-sensitive Linux filesystem where developers use a case-insensitive one.

The test is the culprit. The runner is a clean machine with only what you asked for, so it is the honest environment. Passing locally means the test depends on something about your machine that it never declared.

This is one of the more valuable things CI provides, separate from catching regressions: it finds the hidden assumptions in your setup, which are the same assumptions that make a project hard for the next person to run.

Saved in this browser only.

End of the published lessons

That is everything written so far in Git & GitHub

More lessons in this course are on the way. In the meantime, the course page shows the full roadmap, and the projects are the best way to consolidate what you have covered.