My software never has bugs. It just develops random features.
I will add unit tests later.

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. My first run was green, so these are the failures to expect in the future, roughly ordered from most to least likely for any 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/PC with the same command: python -m pytest -x -q. 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 with the runner’s Python. | 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/PC. | 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/PC 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. |
SyntaxError or a deprecation error that appears only on one Python version |
My Mac runs Python 3.15, the runner 3.12. | I may need to 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/problem: a tab, wrong indentation or a missing colon. | Click the error: GitHub shows the exact line. Fix it and push again. |
The log for a failing test looks like this, and the useful part is the last block:
=================================== FAILURES ===================================
________________________________ test_slugify_spaces _________________________
def test_slugify_spaces():
> assert slugify("Hello World") == "hello-world"
E AssertionError: assert 'hello_world' == 'hello-world'
E - hello-world
E + hello_world
tests/test_util.py:14: AssertionError
=========================== short test summary info ============================
FAILED tests/test_util.py::test_slugify_spaces - AssertionError: assert 'hel...
========================= 1 failed, 129 passed in 1.42s ========================
Read it as: file tests/test_util.py, line 14, test name test_slugify_spaces, expected vs actual. The short test summary info block is what gh run view –log-failed leaves on screen, and the test id (tests/test_util.py::test_slugify_spaces) is exactly what you paste into pytest locally:
cd myPython
python -m pytest tests/test_util.py::test_slugify_spaces -vv
A test that skips on Linux is a hidden failure. It is green in the run, but it is not testing anything. Two small changes make skips loud:
- name: Run the tests
run: python -m pytest -ra
-ra prints a short summary of all skipped, xfailed, and xpassed tests with their reasons. Compare the counts to your local run: 130 passed locally vs 129 passed, 1 skipped in CI is a signal, not a detail.
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 named experiment-ci and switches you to it immediately: 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 PC/Mac. It fails, as expected.git add -A && git commit -m "Break a test on purpose" && git push -u origin experiment-ci. git add -A. Adds all modified, new, and deleted files to Git’s staging area. git commit -m "Break a test on purpose". git push -u origin experiment-ci. -u (--set-upstream) tells Git to remember that this local branch tracks 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 to the pytest command, skipping tests). That turns the robot into a decoration. Fix the cause, or make the test independent of your Mac.
My repository is also my dotfiles: stow links its folders into my home directory. So I may reasonably worry that adding .github/ will clutter my home folder. It will not, and here is why.
GNU Stow is a symlink farm manager. It takes packages — top-level directories in my dotfiles repo — and makes their contents appear inside a target tree (my $HOME) by creating symbolic links. It mirrors the directory structure below the package root into the target.
Two mechanics explain everything in this section:
Four independent mechanisms protect you. Any one of them is sufficient; together they make an accident very unlikely.
stow */ or a loop in install.sh, the pattern * does not match names starting with a dot, so .github is skipped automatically.What to check in your own install.sh: open it once and look for the line that selects packages. Three shapes are safe:
# Shape A: explicit list — .github cannot appear
packages=(zsh git ssh myPython)
for p in "${packages[@]}"; do stow -v "$p"; done
# Shape B: glob — .github is skipped by default shell behaviour
for p in */; do stow -v "${p%/}"; done
# Shape C: glob with explicit exclusion — belt and braces
for p in */; do
[[ "$p" == ".github/" ]] && continue
stow -v "${p%/}"
done
.stfolder is a different thing. It is a marker directory Syncthing creates inside a synced folder to confirm the folder is present and writable (this only applies if you are using Syncthing). It has nothing to do with GitHub, nothing to do with Stow’s ignore lists, and nothing to do with Actions. Leave it alone; if it appears in git status, add it to .gitignore rather than deleting it, because Syncthing may recreate it.
How editing works day to day. Your files live in two forms that are always the same thing: the git repository at ~/macOS (or wherever you cloned it) and, through stow’s symbolic links (symlinks), your home directory.
When you edit ~/.zshrc, you are editing ~/macOS/zsh/.zshrc through a symlink. There is no second copy to keep in sync; that is the point of Stow.
The workflow file is different in one important way: nothing on your PC/Mac depends on .github/workflows/tests.yml. No symlink points at it, no shell sources it, no application reads it. It is a file that exists only for GitHub. That asymmetry is why adding it does not change your daily environment at all — and why you should not try to Stow it.
The runner sees only what is committed and pushed at the commit being tested. This table is the practical version:
| 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 (any external drives, pendrives, etc.), your Hugo site, Ollama, your fortunes file |
| The branch you pushed | Uncommitted or unpushed changes |
| README, LICENSE, other committed root files | Files excluded by .gitignore or .git/info/exclude |
The beginner trap this table is really about: you fix a test, forget to commit, push an unrelated change, and CI is red because it is testing the old version of the file. The symptom is a log that shows code you have already changed. The fix is always the same:
cd ~/macOS # your repo folder (use your real path)
git status # is the file modified but not staged?
git diff # see what changed, what exactly is different?
git add file && git commit -m "Fix …" && git push
Editing the workflow locally (the normal path):
cd ~/macOS # your repo folder (use your real path)
git add .github # stage only the workflow
git commit -m "Update CI workflow"
git push
Prefer git add <path> over git add . in a dotfiles repo. A broad git add . can stage a local .env, a Syncthing .stfolder, or an editor swap file, and the robot would then see something you did not intend to publish.