Two weeks ago you forked a repository, made a branch and opened a pull request. Today that same machinery starts doing work for you while you are not looking.
Nothing to install today. You need git, a GitHub account you are logged into, and the repository below forked into your own account. Everything else runs on somebody else's computer.
git --version
gh auth status 2>/dev/null || echo "no gh cli — that is fine, the browser is enough"
Then, in a browser: open github.com/studio-typo-hq/dataeko-week3-lab and press Fork.
Hands up when you can see the forked copy under your own username.
By the end you will have a robot that runs your tests on every push, blocks a broken pull request, and hands you back a file it produced. Everything today runs on GitHub's computers, free, on your own fork.
The word "continuous" is doing the work. Not "before release" and not "when someone remembers" — on every push, automatically, whether you asked for it or not.
The machine matters as much as the automation. Your laptop has three years of accumulated software on it and no memory of how any of it got there. A fresh machine that installs only what your project declares is the only honest test of whether your project can be installed at all.
That is also why CI catches a specific and very common bug: the file you forgot to commit. It works locally because it is on your disk. It fails in CI because CI only ever sees what is in the repository.
| what happens | who decides | |
|---|---|---|
| Continuous integration | every push is built and tested automatically | the robot, always |
| Continuous delivery | every green build is ready to ship, packaged and waiting | a human presses the button |
| Continuous deployment | every green build goes to production by itself | nobody — it just goes |
Almost everybody means the first two when they say "CI/CD". The third is real, and it is a much bigger commitment: it means your tests are the only thing standing between a typo and your customers.
Today is entirely the first row. Next week's material is where the second row starts to make sense, because you cannot deliver a thing until you have built the thing.
There is nothing to install and nothing to connect. GitHub looks in
.github/workflows/ in your repository, reads every .yml file it finds there, and does
what they say. Commit a file to that folder and you have CI. Delete it and you do not.
The folder name is exact — .github, with the dot, and workflows, plural. Get either wrong
and nothing happens at all, with no error, because as far as GitHub is concerned you simply have
not configured anything.
your-repo/
orders.py
test_orders.py
.github/
workflows/
test.yml <- this is CI
name: test # what shows up in the Actions tab
on: # WHEN — the trigger
push:
pull_request:
jobs: # WHAT — one or more independent units
pytest: # the job's id, yours to choose
runs-on: ubuntu-latest # WHERE — which machine to rent
steps: # the ordered list of things to do
- uses: actions/checkout@v5
- run: pytest -v
steps is the only part that does anything. Each step is either uses: — borrow a block of
work somebody else wrote and published — or run: — a shell command, exactly as you would type
it in a terminal.
on: is the whole question of when, and push is only the most obvious answer| trigger | fires when | used for |
|---|---|---|
push | anyone pushes commits | the default — test everything, always |
pull_request | a PR opens, or gets new commits | the checks that block a merge |
workflow_dispatch | you press a button in the Actions tab | releases, publishing, anything manual |
schedule | a cron expression, e.g. "0 6 * * 1" | nightly builds, weekly dependency checks |
release | you publish a release | shipping the built thing |
workflow_dispatch is the one worth remembering today. Some jobs should not run on every
commit — publishing, deploying, anything that costs money or is visible to other people. That
trigger gives you a Run workflow button and nothing else.
A workflow can list several. Most real ones do.
runs-on rents a computer. You get it for the length of one job, then it is destroyed.ubuntu-latest gives you a fresh virtual machine with a stock Ubuntu on it. Not a container you
prepared, not a machine you configured — a clean one, from an image GitHub maintains, that has
never seen your project.
That is the point rather than a limitation, and it has three consequences worth expecting:
Every job in a workflow gets its own machine, and by default they all start at once.
whoami -> runner
pwd -> /home/runner/work/dataeko-week3-lab/dataeko-week3-lab
In your fork, on GitHub, press Add file → Create new file. Type this as the filename — including the slashes, which create the folders as you type:
.github/workflows/hello.yml
Paste this in, then Commit directly to the main branch:
name: hello
on: push
jobs:
say-hello:
runs-on: ubuntu-latest
steps:
- run: echo "Hello from $RUNNER_OS, $GITHUB_ACTOR"
- run: whoami
- run: pwd
Now open the Actions tab. Hands up when you see a green tick.
Open the run, click the job, and you get every step as a collapsible section with timings. That is not a summary — it is the actual terminal output of the actual machine, and reading it is the skill that makes all of this debuggable.
Three habits worth forming now:
name:, or from the command itself if you did not give it one.
Naming your steps is how you find things later.This surprises everybody, and it is the first thing that will break for them: a fresh runner has your workflow file, because that is how it knew to start — but not your repository.
steps:
- uses: actions/checkout@v5 # NOW the code is there
- run: pytest -v
actions/checkout is a published action that clones your repository into the working directory.
Leave it out and pytest runs in an empty folder and reports no tests ran, which reads like
a broken test setup and is actually a missing line.
It is the first step of almost every workflow you will ever read. When you are looking at
somebody else's YAML and it starts with uses: actions/checkout, that is why.
The runner has a Python. It does not have your Python and it certainly does not have
requests or pytest. So a real workflow says which version it wants and then installs what
the project declares.
- uses: actions/setup-python@v6
with:
python-version: "3.13"
- run: pip install -r requirements.txt
- run: pytest -v
with: passes settings into an action — the same idea as arguments to a function. And
requirements.txt finally earns its keep: it is the file that lets a machine which has never
met your project install exactly what it needs. If a dependency is missing from that file, CI
is where you find out.
Same as before — Add file → Create new file in your fork:
.github/workflows/test.yml
name: test
on: [push, pull_request]
jobs:
pytest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: "3.13"
- run: pip install -r requirements.txt
- run: pytest -v
Commit it, open Actions, and click into the run.
Find the line that says how many tests passed and read it out.
Green ticks are pleasant. The red X is what CI is for. When a pull request fails its checks, GitHub puts the failure at the bottom of the conversation, next to the merge button, where the reviewer cannot miss it — and with branch protection turned on, the merge button itself stops working.
That changes what a code review is. The reviewer no longer has to wonder whether the tests pass, whether it works on a clean machine, or whether the author remembered to commit everything. A machine already checked all three, and it never gets tired or polite.
Reviews become about design and readability, which are the things humans are actually good at.
test_orders.py::test_is_large PASSED [ 25%]
test_orders.py::test_total_sugar PASSED [ 50%]
test_orders.py::test_total_sugar_empty PASSED [ 75%]
test_orders.py::test_discount FAILED [100%]
> assert discount({"size": "large", "price": 100}) == 90
E NameError: name 'discount' is not defined
test_orders.py:18: NameError
========================= 1 failed, 3 passed in 0.03s ==========================
Three tests passed and one did not, so the whole job is red — a job is only as green as its
worst step. The > line is the assertion that ran, the E line is what went wrong, and the
line after it is the file and line number.
This particular failure is a forgotten import. It would have failed on the author's machine too, if they had run the tests. That is the entire point.
In your fork, open test_orders.py, press the pencil, and change one number so the test is
wrong:
def test_total_sugar():
assert total_sugar([{"sugar": 2}, {"sugar": 3}]) == 6 # was 5
This time choose Create a new branch and start a pull request. Name the branch break-it.
Open the pull request. Watch the checks section at the bottom.
Then fix the number back to 5 on the same branch, commit again, and watch the same pull request turn green without you touching anything.
By default a red X is a suggestion. The merge button still works, and a determined human can merge a broken branch at half past six on a Friday.
Branch protection turns the suggestion into a rule. In Settings → Branches, a rule on
main can require named status checks to pass before merging, require a review, and forbid
force-pushes. With it on, the merge button is genuinely disabled until the robot is happy.
This is the single highest-value repository setting most teams never turn on, and it takes about thirty seconds. The checks only become a safety net at the moment they can say no.
Your code has to work on more than the one Python you happen to have. A matrix declares the list, and GitHub starts one job per entry — all at once, on separate machines.
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]
steps:
- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
Four jobs, four machines, and the workflow finished in 17 seconds — not four times as long. Parallelism is the default here and you get it by writing a list.
${{ ... }} is the expression syntax: GitHub substitutes the value before the step runs.
Everything the runner produced — the coverage report, the built package, the screenshots from a failed browser test — disappears when the job ends. If you want a file afterwards, you have to say so.
- run: pytest -v --junitxml=report.xml
- uses: actions/upload-artifact@v4
with:
name: test-results
path: report.xml
The file then appears as a download at the bottom of the run's summary page. Ours came out at 351 bytes and is kept for 90 days, which is the default retention.
This is the "artifacts" half of the phrase "CI/CD and artifacts". A build that produces something you can keep is the bridge to next session, where the thing you keep is an image.
Edit .github/workflows/test.yml in your fork. Change the pytest line and add two steps at
the end:
- run: pytest -v --junitxml=report.xml
- uses: actions/upload-artifact@v4
with:
name: test-results
path: report.xml
Commit, wait for the run, then open the run's summary page — the top level, not the job.
Scroll to the bottom. There is a download. Hands up when you have the file.
Your workflow is in the repository, and the repository is readable. So an API key in a workflow file is a published API key — and bots scan public repositories for exactly that, within minutes.
GitHub stores secrets outside the code, in Settings → Secrets and variables → Actions, and
injects them at run time:
- run: python fetch.py
env:
API_KEY: ${{ secrets.API_KEY }}
Two things worth knowing. Logs are scrubbed — if a secret's value would appear in the
output, GitHub replaces it with ***. And secrets.GITHUB_TOKEN already exists in every
run, with no setup at all. Next session uses it to publish something.
| symptom | cause | fix |
|---|---|---|
| Nothing in the Actions tab at all | folder is not exactly .github/workflows/ | rename it |
| Green tick, but no tests ran | no actions/checkout — pytest ran in an empty folder | add the checkout step |
ModuleNotFoundError on the runner | dependency installed on your laptop, missing from requirements.txt | add it and commit |
| Two identical checks on every PR | on: [push, pull_request] fires for both | drop one trigger |
The second row is the dangerous one. A green tick that tested nothing is worse than a red X, because you will trust it. Whenever a suite goes suspiciously quiet, check the number of tests collected, not the colour of the tick.
You committed a text file and a computer you have never seen ran your tests on it. Then you broke something on a branch, and a pull request refused to pretend it was fine.
That is the whole shape of continuous integration and everything else is a variation:
That last one is delivery, and it needs the build to produce something portable enough to move. Next session you build that thing.
GitHub Actions · quickstart — the same workflow you wrote today, in GitHub's own words. Twenty minutes.
Workflow syntax reference — every key that can appear in a workflow file. Skim it; know it exists.
Understanding GitHub Actions — workflows, jobs, steps, actions and runners, defined properly in one page.
Docker · get started — read the first two pages only. Next session is containers and a head start helps.
Install Docker before next session — I will send the instructions tonight. It is a large download, so please do not leave it until the morning.