Addaly is in open beta. Things will change, and AI answers can be wrong — check anything that matters.

Python, From Zero, For AI

From your first line of code to your first API call.

Lesson 89 of 899 min

GitHub Actions: the tests run on a machine that is not yours, before anything ships

Works on my machine

Every test passing on your laptop proves the code works with your Python, your installed packages, your environment variables, your filesystem and the files you forgot to commit. A second machine with none of those proves the code works. Continuous integration is the habit of having that second machine run the tests on every change, automatically, before the change is merged. GitHub Actions provides the machine for free for public repositories and with a generous monthly allowance for private ones.

The workflow file

yaml
# .github/workflows/test.yml
name: test

on:
  push:
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: ["3.11", "3.12"]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python-version }}
          cache: pip
      - run: pip install -e ".[dev]"
      - run: pyright src/
      - run: pytest -q

Read it top to bottom. on: says when — every push and every pull request. runs-on: picks a fresh Ubuntu virtual machine. matrix: runs the job once per listed Python version, in parallel. The steps are what you would type: check out the code, install Python, install the package with its dev extras from module 7's pyproject.toml, run the type checker, run the tests. If any step exits non-zero, the job fails and the commit gets a red cross.

Commit the file, push, and open the Actions tab. The first run takes a couple of minutes; with cache: pip the later ones are faster.

What it catches that you did not

The clean machine has no memory of your setup, and that is its value.

A dependency you use but never declared. It was installed in your venv from some earlier experiment. On the runner, ModuleNotFoundError. The fix is in pyproject.toml, where it should have been.

A file that is not committed. A test fixture, a prompt template, a .env.example you meant to add. The runner has only what git has.

Case. macOS and Windows filesystems ignore letter case; Linux does not. import tagger.Utils finds utils.py on a Mac and fails on the runner. Paths built from Path("Data/notes.md") behave the same way. The Linux runner is the only place many developers ever discover this.

Line endings and encodings. A file saved with Windows line endings, a test that opens a file without encoding="utf-8" and relies on the Mac's default. Module 5 warned; the runner enforces.

A different Python. A syntax that arrived in 3.12 fails on 3.11 in the matrix, before a user on 3.11 finds it.

What happens on a machine that has never seen your projectYou pushGitHub starts a fresh Ubuntu machine with none of your setup on it.0:10Checkout brings only what git has. The fixture you never committed is not there.0:25pip install from your declared dependencies. The package left over in your venvfails here.1:05pytest runs on 3.11 and 3.12 at once, so syntax that arrived in 3.12 fails before auser meets it.1:40import tagger.Utils, fine on a Mac, fails on Linux, where case matters.ResultGreen, or a locked door: with the branch protected, the pull request cannot mergered.Every one of these is a bug that passes on your laptop. The runner's value is precisely that itremembers nothing about you: it has only what git has, in the Python you declared, on a filesystemwhere filenames have case. Protect the branch and a red cross stops being a suggestion.
What happens on a machine that has neverseen your projectYou pushGitHub starts a fresh Ubuntu machine with noneof your setup on it.0:10Checkout brings only what git has. The fixtureyou never committed is not there.0:25pip install from your declared dependencies.The package left over in your venv fails here.1:05pytest runs on 3.11 and 3.12 at once, so syntaxthat arrived in 3.12 fails before a user meetsit.1:40import tagger.Utils, fine on a Mac, fails onLinux, where case matters.ResultGreen, or a locked door: with the branchprotected, the pull request cannot merge red.Every one of these is a bug that passes on yourlaptop. The runner's value is precisely that itremembers nothing about you: it has only what githas, in the Python you declared, on a filesystemwhere filenames have case. Protect the branch and ared cross stops being a suggestion.

Secrets and the tests that cost money

Module 4 split tests into two layers: fast tests with a fake provider, and a few slow ones against the real service. The fast ones run on every push. The real ones need the API key, and the key must not be in the repository.

In the repository's Settings → Secrets and variables → Actions, add OPENAI_API_KEY. In the workflow:

yaml
      - run: pytest -q -m "live"
        if: github.event_name == 'push' && github.ref == 'refs/heads/main'
        env:
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}

The secret is injected as an environment variable, and GitHub masks it in logs. The if: restricts the live tests to pushes on main, and here is the reason that matters: a pull request from a fork runs your workflow with the fork's code. If the live tests ran on pull requests with your secret available, a stranger could open a pull request whose "test" prints the key or spends the budget. GitHub withholds secrets from fork pull requests by default; the if: makes the intent explicit and keeps the paid tests off every branch push as well. Add a spend cap on the provider side regardless.

Mark the live tests with @pytest.mark.live and register the marker in pyproject.toml, so pytest -q alone runs only the free ones.

A schedule, for free

yaml
on:
  schedule:
    - cron: "0 2 * * *"      # 02:00 UTC daily
  workflow_dispatch:         # and a button to run it by hand

The same cron syntax as the previous lesson, running on GitHub's machine — a job that runs while your laptop is closed, with no server of your own. Times are UTC. Scheduled runs on free plans can be delayed under load and are paused on repositories with no activity for sixty days, so a critical job needs a heartbeat check as the previous lesson said.

Protecting the branch

In Settings → Branches, require the test job to pass before a pull request can merge. Now a red cross is not a suggestion; it is a locked door. For a project you work on alone this is discipline; for one with contributors it is the only thing standing between a well-meaning change and a broken main.

Reading a failure

Click the red cross, open the failing step, and read the log from the bottom as module 4 taught for tracebacks. The environment section at the top of the log shows the Python version and the installed packages, which is where "it passes locally" gets resolved. To reproduce locally, act (free) runs workflow files on your own machine in Docker; more often, the log alone names the cause.

The habit

Every push runs the checks; every pull request shows them; nothing merges red. It is the last piece of the course: a program that installs from one file, is tested by a machine you do not control, keeps its secrets out of the code, and runs on a schedule with a lock and a done-record. That program you can hand to someone else, or to your future self, and it will still work.

Try this now

Add the workflow to your project and push. Read the first failure — there will be one — and fix it in the project, not the workflow. Then add the live-test step with the secret and the if:, open a pull request from a branch, and confirm the live step is skipped there and runs on main.

The one thing to keep

A workflow file runs your tests on a clean machine on every push, which catches the dependency you forgot to pin and the file whose case only Windows and Linux care about; secrets go in the repository settings and the paid-API tests stay behind a flag so a pull request from a stranger cannot spend your money.

Before you move on

Tests pass on a developer's Mac. The same tests fail in GitHub Actions on `ubuntu-latest` with `ModuleNotFoundError: No module named 'tagger.Utils'`. The file is `src/tagger/utils.py`. What does the CI machine know that the Mac did not?

Pick the one you would defend. Nobody sees your answer.

No ads. No data sale. No public scores on people. Ever.

© 2026 Addaly