Windows Support: All hope abandon, ye who enter here!, Anawim, justtothepoint.com

CI (continuous integration) is a robot that runs your tests for you, on a clean computer, every time you push code to GitHub. In my case it runs the 130 tests of myPython and tells me with a green tick or a red cross whether they still pass.
Here is why that matters for me specifically. My Python toolkit has 15 modules that talk to each other (content_creation, content_quotes, util, myconfig…). When I change one function, I can easily break another module without noticing. Before CI, the only protection was my own memory: I had to remember to type python -m pytest before every push. With CI, the check happens whether I remember or not.
There is a second, quieter benefit: my computer is not a clean room. It has years of installed packages, a .env file, a macOS Keychain, the fortune command, a Hugo site in /Volumes/MacDatosMaster/justtothepoint and a virtual environment on Python 3.15. A test can pass on my computer only because of something that happens to be installed there.
The GitHub robot starts from an empty Linux machine, so if the tests pass there too, they do not depend on anything hidden on your Mac.
What CI does not do:
GitHub Actions has a small vocabulary. Learn these seven and the rest is detail.
| Word | What it means | In your file |
|---|---|---|
| GitHub Actions | GitHub’s built-in robot service; the name of the whole feature. | The “Actions” tab of your repo |
| Workflow | One YAML file in .github/workflows/ that describes one automation. |
tests.yml, named “Tests” |
Trigger (on:) |
The event that starts the workflow: a push, a pull request, a button, a schedule. | push, pull_request, workflow_dispatch |
| Job | A group of steps that runs on one fresh machine. Jobs are independent of each other by default. | test |
| Runner | The machine that runs a job. GitHub lends it to you and throws it away afterwards. | ubuntu-latest (a Linux machine) |
| Step | One command (run:) or one ready-made action (uses:), executed in order. |
“Install packages”, “Run the tests” |
| Action | A reusable step written by someone else, referenced as owner/name@version. |
actions/checkout@v4, actions/setup-python@v5 |
Three more words you will meet in the interface:
29b0fae that identifies the exact version of your files the run tested. Click it in the Actions tab to see the diff that triggered the run.The mental model: a trigger starts a run of a workflow, which sends each job to a runner, which executes the steps one by one. If any step fails, the job stops and the run turns red.
If any step exits non-zero, that step fails, the job stops immediately (later steps in the same job do not run), and the run turns red. Other jobs in the same workflow are independent: a failing test job does not prevent a separate lint job from running, unless you declare a needs: dependency.
Key insight: the color of the run is decided by exit codes, not by parsing output. pytest returns 0 when all tests pass and 1 when any test fails. That is why run: python -m pytest is enough — no special “report to GitHub” step is needed.
My repository nmaximo7/macOS is public, its default branch is main, and it is a collection of stow packages: one top-level folder per tool (zsh, git, ssh, ghostty, aerospace, Espanso, continue, bin, code, myPython…). Stow links the content of each folder into your home directory. myPython is the folder with the toolkit, its tests/ and its pyproject.toml.
CI adds exactly one new file to this picture, and it lives in a different place from the project:
| What | Where | Who reads it |
|---|---|---|
| The toolkit and its tests | myPython/ |
You, Python, pytest |
| The robot’s instructions | .github/workflows/tests.yml at the top of the repo |
GitHub only |
| The results | The “Actions” tab on github.com | You, in a browser |
Visually
macOS/ ← repo root
├── Brewfile
├── install.sh
├── .github/
│ └── workflows/
│ └── tests.yml ← the robot reads ONLY here
├── myPython/
│ ├── pyproject.toml
│ ├── requirements.txt
│ ├── tests/
│ └── ... (15 modules)
├── zsh/
├── git/
├── ssh/
└── ...
The one rule that surprises everybody: GitHub only looks for workflows in .github/workflows/ at the very top of the repository. A workflow file inside myPython/ would be silently ignored. That is why your file sits next to Brewfile and install.sh, not next to pyproject.toml.
You can confirm GitHub has detected the workflow: after pushing, the Actions tab should list “Tests” in the left sidebar. If it does not, the file is in the wrong place, has a YAML syntax error, or was pushed to a branch GitHub is not watching.
Because the project is in a subfolder, every run: command needs to execute from myPython/, not from the repo root. That is the job of this block:
defaults:
run:
working-directory: myPython
It means “run every run: command as if I had typed cd myPython first.” Without it:
pip install -r requirements.txt looks for requirements.txt at the repo root and fails with “No such file.”python -m pytest runs from the root, finds no tests/ directory, and exits with “no tests ran” — which pytest treats as a failure (exit code 5).Note: defaults.run.working-directory applies only to run: steps. I
My repo holds many unrelated things — shell config, SSH config, a Brewfile, Hugo content. I do not want a Python test run every time I change zsh. With:
paths: ["myPython/**", ".github/workflows/tests.yml"]
the robot only wakes up when something inside myPython (at any depth) or the workflow file itself changes. The second path is important: if you edit tests.yml, you want the workflow to run so you can verify the edit.
This is the file:
name: Tests
on:
push:
paths: ["myPython/**", ".github/workflows/tests.yml"]
pull_request:
paths: ["myPython/**", ".github/workflows/tests.yml"]
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: myPython
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
cache-dependency-path: myPython/requirements.txt
- name: Install packages
run: python -m pip install -r requirements.txt pytest
- name: Run the tests
run: python -m pytest
YAML is a settings format made of key: value pairs. Indentation (two spaces per level) shows what belongs to what.
Mistake 1 — tabs instead of spaces. A tab is the most common reason a first workflow does not even start. GitHub reports “Invalid workflow file” and points at the line. Configure your editor to insert spaces for YAML.
Mistake 2 — unquoted version numbers. YAML treats 3.10 as the number 3.1, not the string “3.10”. Always quote versions:
python-version: "3.12" # correct
python-version: 3.12 # also fine, but avoid!, 3.10 would become 3.1
Mistake 3 — wrong indentation of list items. A - must be indented consistently with its parent key:
steps:
- uses: actions/checkout@v4 # two spaces before the dash
- uses: actions/setup-python@v5
A line starting with - is one item of a list. Nested keys under that item must be indented further:
- uses: actions/setup-python@v5
with: # aligned with "uses"
python-version: "3.12" # indented under "with"
name: Tests is the label shown in the Actions tab and in your badge. It is optional; without it, GitHub uses the file path (tests.yml).on: lists the triggers.push fires when you push commits to any branch.pull_request fires when you open or update a pull request.workflow_dispatch adds a “Run workflow” button in the Action tab, so you can start a run by hand without pushing. This is invaluable when debugging: you can re-run the exact workflow without an empty commit.on:
push:
paths: [...]
pull_request:
paths: [...]
workflow_dispatch:
paths: under a trigger limits it to commits that touch those files. myPython/** means “anything inside myPython, at any depth”.jobs: starts the list of jobs. You have one, called test. The name is yours to choose.runs-on: ubuntu-latest requests a Linux runner. Linux is the right choice: it is the fastest, and for public repos it is free. ubuntu-latest currently points at Ubuntu 24.04 LTS. Pinning ubuntu-24.04 is more reproducible but means you must update it when GitHub retires the image; ubuntu-latest is the pragmatic default for a personal repo.defaults.run.working-directory: myPython is the cd myPython explained in section 3. Applies to every run: step in the job. It does not apply to uses: steps.steps: is the ordered list of things to do. Steps run sequentially. If one fails, the rest of the job is skipped (unless you use if: always() or continue-on-error: true).uses: actions/checkout@v4
Downloads your repository onto the empty runner. Without it, the machine has no files — myPython/ does not exist, and every later step fails. @v4 is the major version of the action; GitHub maintains v4 and backports fixes within it.
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
cache-dependency-path: myPython/requirements.txt
Installs Python and configures caching. Each setting:
Caching is off by default. cache: pip is opt-in. Once enabled, the first run populates the cache and later runs restore it, which typically saves 10–30 seconds when many packages are installed. You do not need actions/cache separately for pip; setup-python handles it.
- name: Install packages
run: python -m pip install -r requirements.txt pytest
Installs your pinned packages and pytest. Pytest is not in requirements.txt because it is a development tool; it is listed in the dev extra of pyproject.toml. Installing it explicitly here is correct for a workflow that only runs tests.
Why python -m pip and not pip? python -m pip guarantees you use the pip belonging to the Python that setup-python put on PATH. A bare pip can resolve to a different interpreter on some runners. The python -m form is the portable habit.
- name: Run the tests
run: python -m pytest
The same command you type on your Mac/PC. Its exit code decides the colour: 0 is green, anything else is red.
Pytest exit codes worth knowing:
My project declares requires-python >= 3.11, so 3.12 is inside the supported range. The runner uses a stable, widely available version; your Mac/PC may have a newer one (3.13, 3.14, or a development build). That difference is fine as long as the code does not rely on newer syntax or standard-library behaviour. If it does, either raise the workflow to a newer Python (3.13 is available on the runner) or pin your Mac’s project to the same version in pyproject.toml.
Python 3.15 is not a stable release. If your PC/Mac reports a 3.15 virtual environment, it is likely a development or pre-release build. CI on 3.12 is still the right baseline, because it tests the minimum supported stable version a basic project claims.
The failure model is simple and worth internalising:
This means “Install packages” failing will prevent “Run the tests” from running — which is usually what you want, because running tests without dependencies is meaningless. If you want a step to run regardless (for example, uploading logs on failure), use:
- name: Upload logs
if: always()
uses: actions/upload-artifact@v4
with:
name: pytest-logs
path: myPython/.pytest_cache/
Two debugging aids:
(i) Check out the repo → (ii) install Python 3.12 with pip caching keyed to myPython/requirements.txt → (iii) install dependencies and pytest from inside myPython → (iv) run pytest from inside myPython → (v) report the exit code.
Everything else in the file exists to make those five steps happen in the right place, at the right time, with the right files.
Follow what happens from the moment you type git push until the green tick appears. Your first run took 34 seconds in total.
t=0s git push ──────────────────────────────► GitHub receives commit 29b0fae
t=0s GitHub evaluates on: ──────────────────► push? yes. paths match? yes.
t=1s Run queued ────────────────────────────► "Tests #1" yellow dot
t=2s Runner provisioned ────────────────────► clean Linux VM, 4 cores, 16 GB RAM [reference:0]
t=3s Set up job (hidden) ───────────────────► runner image, env vars, action download
t=4s checkout ──────────────────────────────► your repo appears on the VM
t=5s setup-python ──────────────────────────► Python 3.12 from tool cache
t=6s pip install ───────────────────────────► ~20 s first run, ~3 s with cache
t=27s pytest ────────────────────────────────► 130 tests
t=33s Post job (hidden) ─────────────────────► cache saved, runner torn down
t=34s Verdict ───────────────────────────────► green tick
The seconds are illustrative, not measured. The order is what matters, and the two hidden steps are the ones beginners never see.
You push. On your PC/Mac: git add, git commit, git push. Git sends the new commit objects and updates the branch ref on GitHub. The commit 29b0fae now exists remotely.
GitHub checks the trigger. It reads the workflow files (.github/workflows/) at that commit, not at main’s latest state. For tests.yml it asks two questions: is this a push? and did the pushed commits touch myPython/** or the workflow file? If either answer is no, no run is created at all — not a skipped run, nothing. The Actions tab simply does not gain a row.
A run is queued. It appears in the Actions tab as “Tests #N” with a yellow dot. Usually it starts within seconds.
A fresh runner is created. GitHub boots a clean Linux VM for this job only. It has Git, Docker, Node, Python and common build tools preinstalled . The default python3 is 3.12.3, and the tool cache holds 3.10–3.14 . None of your files, packages, dotfiles, or .env are there
checkout copies your repo onto it. About one second. Now myPython/ exists on the runner, at the workspace root.
setup-python installs Python 3.12 and restores the pip cache if there is one (the first run has none).
pip install downloads your pinned packages and pytest. This is the longest step, roughly 20 seconds the first time and less once the cache exists.
pytest runs your 130 tests. On your PC/Mac it takes 0.19 seconds, on the runner a little more.
The result is decided by exit codes. pytest exits 0 when everything passed. Any other code fails the step. 1 = test failures, 2 = interrupted, 5 = no tests collected . Exit code 5 is the classic symptom of a wrong working directory.
The runner is destroyed. Nothing stays behind: no files, no packages, no state. The next run starts from zero again. This is exactly what makes the test trustworthy — the pip cache does survive, because GitHub saves it as a cache archive before tearing the VM down, and restores it on the next run.
You are notified. The run gets a green tick or a red cross, shown in the Actions tab, next to the commit in the file list and in your README badge. By default, GitHub emails the user who triggered the run when it fails; for your own pushes, that is you. Notifications are configurable under Settings → Notifications → Actions.
Every job has two steps you did not write:
Both appear in the log with their own timestamps. A run that “took 34 seconds” includes them.
Neither is required, but both are cheap and prevent common annoyances.
Fail fast on a hung test. The default job timeout is 6 hours — long enough that a deadlocked test wastes an afternoon of your attention. Cap it:
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 10
Cancel superseded runs. If you push three commits in a minute, three runs queue and the first two are already obsolete. Add:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
This keeps at most one running plus one pending run per branch.
.env, the Keychain, my Hugo site and my fortunes file are not there. The tests passed anyway, which tells me they do not depend on them.Open github.com/nmaximo7/macOS and click Actions. This is what you will see, and what each part is for.
Each row is one run:
| Column | What it tells you |
|---|---|
| Title | The commit message, e.g. “Add tests workflow” |
| Workflow + number | “Tests #1”; the number increments on every run of that workflow |
| Commit | The short SHA, e.g. 29b0fae; click it to see the diff |
| Branch | main, or your experiment branch |
| Actor | Who pushed |
| Duration | 34s |
| Icon | Green tick = passed; red cross = failed; yellow dot = running; grey = cancelled; grey dash = skipped (path filter did not match) |
The left sidebar. Under “Workflows” you see “Tests”. Click it to see only the runs of that workflow, and to find the Run workflow button created by workflow_dispatch.
Click a row. You see the job (test) with its duration, and a summary. You get:
Inside the job, each step is one collapsible row. A green tick means exit code 0. Click a step to unfold its raw output — exactly what you would have seen in your terminal.
A habit that saves hours: when a run is red, do these three things in this order.
cd myPython && python -m pytest -x -q). If it fails there too, it is an ordinary bug; if it passes locally, it is a difference between your PC/Mac and the runner (go to section 7).Every run has a Summary tab that is empty by default. You can write Markdown into it from any step, and it becomes the first thing you see when you open the run:
- name: Environment summary
if: always()
run: |
{
echo "### Test environment"
echo "| Key | Value |"
echo "|---|---|"
echo "| Python | \`$(python --version 2>&1)\` |"
echo "| OS | \`$(lsb_release -ds 2>/dev/null \|\| uname -s)\` |"
echo "| Commit | \`$GITHUB_SHA\` |"
} >> "$GITHUB_STEP_SUMMARY"
if: always() makes it run even when the tests fail, so the summary is there precisely when you need it. This is five lines of YAML that replace a dozen clicks into logs.
GitHub shows step failures as annotations on the Summary tab and, for pull requests, inline in the Files changed view. By default, a pytest failure produces an annotation pointing at the step, not the test line.
If you want per-test annotations, add the plugin pytest-github-actions-annotate-failures to your dev dependencies and install it in the workflow; pytest then emits ::error file=…,line=… commands that GitHub renders as inline errors on the diff. This is optional but turns “go read the log” into “click the red squiggle.”
Re-run failed jobs does not reuse the runner. It creates a new attempt on the same commit, with fresh logs, and the previous attempt’s logs are kept under the attempt selector.
This matters for one reason: a re-run that passes is a flake, not a fix. The right response is:
Useful re-run cases: a package download timed out, the PyPI mirror hiccuped, a GitHub service had a brief outage. Not a useful case: an assertion failure.
You already have gh installed (it is one of your stow packages), so you can stay in the terminal:
# List recent runs for this workflow
gh run list --workflow=tests.yml --limit 5
# Watch the most recent run live (interactive if ambiguous)
gh run watch
# Show only the failed step's log — the fastest red-run command
gh run view --log-failed
# Re-run only the failed jobs
gh run rerun --failed
# Open the run in the browser when you do want the UI
gh run view --web
gh run view --log-failed is worth memorising: it prints exactly the failing step, no scrolling, no clicking.
Notifications. By default, GitHub emails the actor (the user who triggered the run) when a workflow fails. You can broaden or narrow this in Settings → Notifications → Actions. For a personal repo, the default is correct: you get mail when your push breaks, and nothing when it passes.
Retention. Logs and artifacts are kept for 90 days by default. Until recently, the run, check, and status records themselves survived for 400+ days regardless of that setting. Since 1 October 2026, checks, workflow runs, and statuses are governed by the same retention setting as logs and artifacts, and for public repositories the cap is 90 days.
Privacy warning for a public repo. If your repo is public, anyone can read your Actions logs. Never echo a token, a full .env, or an API key in a step — GitHub masks values registered as secrets, but not arbitrary strings you print. If a log ever contains a secret, treat it as leaked and rotate it; deleting the run is not enough.
Useful buttons.
Where else the result shows up.
A red run is normal and useful; it is the reason CI exists. Your first run was green, so these are the failures to expect in the future, ordered from most to least likely for your project.
| Symptom in the log | Likely cause | What to do |
|---|---|---|
A test fails with an assertion error (assert 3 == 4) |
You changed behaviour and a test noticed. | Reproduce on your Mac with the same command. Fix the code, or update the test if the new behaviour is intended. |
ModuleNotFoundError: No module named 'xyz' |
You started importing a package that is not in requirements.txt. It worked on your Mac because it was installed there. |
Add it to requirements.in, requirements.txt and pyproject.toml (keep the three lists equal). |
ERROR: Could not find a version that satisfies the requirement ... during pip install |
A pinned package exists only for macOS, or the pin is too new/old for Python 3.12. | Check the package name in the log. Remove or relax that pin, or install it only on macOS with a marker such as pkg==1.0; sys_platform == "darwin". |
FileNotFoundError or No such file or directory with a path like /Volumes/... or /Users/nmaximo7/... |
A test depends on a path that exists only on your Mac. | Make the test build its own files in a temporary folder (tmp_path in pytest) instead of using real paths. |
| A test passes on the Mac and fails only on the runner because of upper/lower case in a file name | macOS ignores case in file names by default; Linux does not. | Fix the file name or the import so the case matches exactly. |
command not found: fortune, or an error about the keychain |
The runner has no fortune command and no macOS Keychain. |
Tests must not depend on them: use fakes, as your quote tests already do with SOURCES. |
No such file: .env or settings that are empty |
.env is ignored by git, so it is not in the repo. |
Tests should set what they need themselves (your conftest.py and myconfig tests already do). |
SyntaxError or a deprecation error that appears only on one Python version |
Your Mac runs Python 3.15, the runner 3.12. | Reproduce with the same version, or avoid syntax and standard-library features newer than 3.11. |
ConnectionError, timeouts, “rate limit” |
A test reaches the real internet and the network or a website had a bad moment. | Click “Re-run failed jobs”. If it keeps happening, replace the network call with a fake. |
| The run never starts, or the Actions tab says “Invalid workflow file” | A YAML mistake: a tab, wrong indentation or a missing colon. | Click the error: GitHub shows the exact line. Fix it and push again. |
Test it yourself: the “break it on purpose” exercise. The best way to understand red runs is to cause one safely, on a branch, without touching main.
git switch -c experiment-ci creates and opens a new branch.myPython/, open one test (for example in tests/test_quotes.py) and change an expected value so that it is wrong.python -m pytest on your Mac. It fails, as expected.git add -A && git commit -m "Break a test on purpose" && git push -u origin experiment-ci.experiment-ci, and it turns red. Open it and find your wrong assertion in the log. You will also receive an email.git switch main, then delete the experiment with git branch -D experiment-ci and git push origin --delete experiment-ci.Nothing of this reaches main, and you have now seen the whole red cycle once.
A general rule for fixing. Never edit the workflow to hide a failure (removing a step, adding || true, skipping tests). That turns the robot into a decoration. Fix the cause, or make the test independent of your Mac.
Your repository is also your dotfiles: stow links its folders into your home directory. So you may reasonably worry that adding .github/ will clutter your home folder. It will not, and here is why.
stow zsh, stow git, stow myPython: each command links the content of one top-level folder. A folder you never name is never linked..github is not a package you stow. Never run stow .github. It would try to link workflows/tests.yml into ~/workflows/, which is meaningless.stow */ or a loop in install.sh, the pattern * does not match names starting with a dot, so .github is skipped automatically. Open your install.sh once and check how it chooses the packages; if it lists folders explicitly, nothing changes..stfolder is a different thing. That folder in your repo belongs to Syncthing, not to GitHub. Leave it alone.How editing works day to day. Your files live in two forms that are always the same thing: the git repository (e.g. ~/macOS) and, through stow’s symbolic links, your home directory. When you edit ~/.zshrc, you edit the file in the repo. The workflow is a file in the repo that nothing on your Mac depends on; it only matters when you push.
What the robot can and cannot see. It sees only what is committed and pushed:
| Visible to the robot | Not visible to the robot |
|---|---|
Every file committed in macOS |
Your real .env (ignored by .gitignore) |
requirements.txt, pyproject.toml, tests/ |
The macOS Keychain and your OpenRouter key |
| Whatever the workflow installs | /Volumes/MacDatosMaster, your Hugo site, Ollama, your fortunes file |
| The branch you pushed | Uncommitted or unpushed changes |
This last row is a common beginner trap: you fix something, forget to commit it, and CI is still red because it tests the old version. If the log shows code that you have already fixed, run git status and git push.
Recommended commands for the workflow file itself. After editing .github/workflows/tests.yml locally:
cd ~/macOS # your repo folder (use your real path)
git status # see what changed
git add .github # stage the workflow
git commit -m "Update CI workflow"
git push
If you edit the file in the GitHub web editor instead, run git pull on your Mac before your next local commit, otherwise git will tell you that your branch is behind.
Your repo is public: everyone on the internet can read your files, your commits and the logs of your runs. That is fine as long as you follow a few simple rules.
1. Logs are public too. Anyone can open the Actions tab of a public repo and read the output of every step. Never print secrets (echo $KEY, print(settings), pip install with a token in the URL). GitHub hides values stored as official “secrets” in the log, but it cannot hide something your own code prints in another form.
2. Do not put secrets in the workflow file or in the repo. Your toolkit does not need any in CI: the OpenRouter key lives in the Keychain, the real .env is ignored by git, and the tests use fakes. If one day a workflow needs a secret, store it in Settings > Secrets and variables > Actions and read it as ${{ secrets.NAME }}. Never as plain text in tests.yml.
3. Check your .gitignore before the first push of anything new. It must contain .env, .venv/ and __pycache__/. A secret committed once stays in the history even if you delete the file later; in that case, change the secret, because deleting is not enough. This is also why I asked you to change TOR_CONTROL_PASS, which you pasted in chat.
4. Your SSH folder is safe as it is. The file you showed me (ssh/.ssh/config) contains host entries and the path of the key (IdentityFile ~/.ssh/id_ed25519), not the key. Never commit id_ed25519 (the private key); id_ed25519.pub (the public key) is harmless but unnecessary. A good safety net is a line ssh/.ssh/id_* in .gitignore for private keys, with an exception for *.pub if you want them.
5. Limit what the workflow is allowed to do. Every run receives a temporary token (GITHUB_TOKEN) that can, by default, do more than a test needs. Declare the minimum at the top of the file:
permissions:
contents: read
This means “the robot may read the repo and nothing else”. A test run never needs more.
6. Be careful with other people’s actions. uses: runs someone else’s code on your runner. Rules of thumb: prefer actions/... (GitHub’s own) and well-known publishers; pin a version (@v4, never @main) so an update cannot silently change what runs; for anything unfamiliar, read its repository first. The strictest pinning is a full commit code, which is rarely necessary for a personal repo.
7. Pull requests from strangers. Anyone can fork your public repo and open a pull request. For first-time contributors GitHub asks you to approve the run before it starts, and workflows triggered by forks do not receive your secrets. Keep it that way: use the pull_request trigger as you do, and avoid pull_request_target unless you fully understand it, because it runs with more power.
8. Turn on the free alerts. In Settings > Code security, enable Dependabot alerts and secret scanning (public repos get secret scanning for free). They email you if a package you pin has a known vulnerability or if a key shaped like a real token appears in a commit.
For your situation the cost is zero. GitHub does not charge for standard runners on public repositories, and your run uses well under a minute. The figures below are the ones to keep in mind; GitHub changes plans from time to time, so check the current pricing page before relying on them.
| Topic | What to know |
|---|---|
| Public repo (yours) | Free, unlimited use of standard Linux, Windows and macOS runners. |
| If you make the repo private | You get a monthly allowance of free minutes on a Free account (about 2,000 minutes at the time of writing). Linux minutes count 1x, Windows 2x, macOS 10x. Keep runs-on: ubuntu-latest. |
| One run of yours | About 35 seconds. Even 100 runs a month would be about one hour. |
| Time limit of a job | 6 hours by default. Add timeout-minutes: 10 to the job so a stuck test cannot run for hours. |
| Logs | Kept 90 days by default. |
Scheduled workflows (on: schedule) |
GitHub pauses them in a public repo after 60 days without repository activity. You have none, so this does not affect you. |
| Concurrent jobs | Plenty for one person. A second push while one run is active simply queues another run. |
Housekeeping, only if you want it.
concurrency block (section 11) so a new push cancels the previous run of the same branch..github/workflows/, each with its own badge and its own history.A simple budget of attention. Most of CI’s cost is human: reading red runs and keeping the tests healthy. A rule that works well for a personal project is “never leave main red for more than a day”. If a test is flaky (sometimes red, sometimes green with no change), fix it or delete it; a test you ignore trains you to ignore all of them.
You have a working setup. Everything below is optional. Add one item, push, see it green, and only then add the next. Each is ordered by usefulness for your project.
Step 1: the minimum safety settings (recommended now). Add three small things to tests.yml: read-only permissions, a time limit, and cancellation of obsolete runs.
name: Tests
on:
push:
paths: ["myPython/**", ".github/workflows/tests.yml"]
pull_request:
paths: ["myPython/**", ".github/workflows/tests.yml"]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: tests-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 10
defaults:
run:
working-directory: myPython
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
cache-dependency-path: myPython/requirements.txt
- run: python -m pip install -r requirements.txt pytest
- run: python -m pytest
What each addition does: permissions limits the token (section 9); concurrency makes a new push to the same branch cancel the older unfinished run; timeout-minutes kills a run that hangs.
Step 2: test several Python versions (recommended soon). Your project promises Python 3.11 or newer, but only 3.12 is tested. A matrix runs the same job once per version, in parallel:
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.11", "3.12", "3.13"]
# ... the same defaults as before ...
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: pip
cache-dependency-path: myPython/requirements.txt
# ... install and test as before ...
You then see three jobs (3.11, 3.12, 3.13) in one run. fail-fast: false lets the others finish even if one fails, so you learn which versions break. Add a newer version to the list when you upgrade your Mac, and drop 3.11 when you raise requires-python.
Step 3: let a bot keep your packages and actions current. Create the file .github/dependabot.yml at the top of the repo:
version: 2
updates:
- package-ecosystem: "pip"
directory: "/myPython"
schedule:
interval: "monthly"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "monthly"
Once a month Dependabot opens pull requests such as “Bump requests from 2.32 to 2.33”. CI runs on each one. If it is green, you merge with one click; if it is red, you have learned that the update breaks something, before it reached main. This is where CI pays off most.
Step 4: protect main (optional). In Settings > Branches > Add rule for main, tick “Require status checks to pass” and choose the check named test. Then no pull request can be merged while red. For a one-person repo that pushes directly to main this is not needed, and a rule can get in your way, so skip it unless you start working with pull requests.
Step 5: a linter (optional, later). A linter such as ruff finds unused imports and obvious mistakes without running the code. Add it as a separate step (pip install ruff and ruff check .) only after you have run it locally and cleaned the existing warnings, otherwise your first run will be red for reasons unrelated to your change.
Step 6: tests for the network parts (optional, later). Your suite checks the pure functions. The code that calls Ollama, OpenRouter, Hugo and the web is covered only by fakes. A future step is a small fake local server in the tests; it makes CI more thorough without making it flaky.
What I would not do for this project. Windows and macOS runners (your tools are for your Mac, and macOS minutes cost ten times more in private repos), deployment or release jobs (nothing to deploy), and Docker (the plain runner is enough).
Your everyday routine, in five lines.
myPython/.cd myPython && python -m pytest. Fix what fails.git add -A, git commit -m "What I changed", git push.Step 2 is not redundant: running tests locally takes a fraction of a second and gives faster feedback than waiting for GitHub. CI is the second opinion, not a replacement.
Setup checklist (everything below is already done in your case unless marked).
.github/workflows/tests.yml at the top of the repo, committed and pushed.permissions, concurrency and timeout-minutes (section 11, step 1).install.sh and confirm it does not try to stow .github (section 8)..gitignore lists .env, .venv/ and private SSH keys (section 9).dependabot.yml (section 11).Quick answers to common worries.
myPython/** or the workflow file, so the paths filter skipped it. That is expected. Use “Run workflow” to start one by hand.experiment-ci: push there, look at the run, and delete the branch afterwards.Glossary.
| Term | Plain meaning |
|---|---|
| CI | Continuous integration: automatically testing every change. |
| GitHub Actions | GitHub’s service that runs automations such as CI. |
| Workflow | A YAML file in .github/workflows/ describing an automation. |
| Trigger | The event that starts a workflow (on:). |
| Run | One execution of a workflow. |
| Job | A set of steps that runs on one runner. |
| Runner | The temporary machine that runs a job. |
| Step | One command or one action inside a job. |
| Action | A reusable step from someone’s repository (uses:). |
| Matrix | The same job repeated for several values (e.g. Python versions). |
| Cache | Files kept between runs to save time. |
| Secret | A private value stored in GitHub, never in the code. |
| Badge | A small image showing the latest status, for the README. |
| Dependabot | A GitHub bot that proposes updates to packages and actions. |
| YAML | The indented settings format used by workflows. |
| Exit code | The number a command returns; 0 means success. |
Where to learn more. The official documentation at docs.github.com/actions has a “Quickstart”, a “Workflow syntax” reference and the list of triggers. For your project you do not need more than sections 4, 7 and 11 of this article.