JustToThePoint English Website Version
JustToThePoint en español

GitHub Actions and CI for your macOS dotfiles repo II

My software never has bugs. It just develops random features.

I will add unit tests later.

GitHub Actions

5. The life of one run, step by step

Follow what happens from the moment you type git push until the green tick appears. Your first run took 34 seconds in total.

5.1 The time line

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.

5.2 What happens at each stage

  1. 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.

  2. 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.

  3. A run is queued. It appears in the Actions tab as “Tests #N” with a yellow dot. Usually it starts within seconds.

  4. 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

  5. checkout copies your repo onto it. About one second. Now myPython/ exists on the runner, at the workspace root.

  6. setup-python installs Python 3.12 and restores the pip cache if there is one (the first run has none).

  7. 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.

  8. pytest runs your 130 tests. On your PC/Mac it takes 0.19 seconds, on the runner a little more.

  9. 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.

  10. 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.

  11. 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.

5.3 The two hidden steps

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.

5.4 Two optional settings that improve the life of a run

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.

5.5 Two consequences that follow from step 10

6. Reading the Actions tab and the logs

Open github.com/nmaximo7/macOS and click Actions. This is what you will see, and what each part is for.

6.1 Anatomy of the run list

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.

6.2 Inside a run

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.

6.3 The 60-second triage habit

A habit that saves hours: when a run is red, do these three things in this order.

  1. Open the run and find the first red step. Later steps are usually skipped or just consequences. In our workflow the first red step is almost always “Install packages” or “Run the tests.”
  2. Unfold that step and read the last 20 lines. pytest prints the failing test id, the file, the line, and the assertion.
  3. Copy that error, and try to reproduce locally with the same command (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).

6.4 Make the run page tell you something ($GITHUB_STEP_SUMMARY)

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.

6.5 Annotations: failures that point at the line

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.”

6.6 Re-runs, attempts, and flaky tests

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:

  1. Note the failing test and the error.
  2. Re-run once to confirm it is nondeterministic.
  3. Fix the nondeterminism (time, ordering, network, shared state) or mark the test @pytest.mark.skip with a linked issue.
  4. Do not normalise “just re-run it” as a workflow — that trains you to ignore red, which is the one thing CI cannot survive.

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.

6.7 The gh CLI: the same information without the browser

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.

6.8 Notifications and retention

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.

7. When it turns red: the failures you are most likely to meet

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.

7.1 The failure table

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.

7.2 Reading a pytest failure in CI

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

7.3 Make skipped tests visible

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.

7.4 The “break it on purpose” exercise

The best way to understand red runs is to cause one safely, on a branch, without touching main.

  1. git switch -c experiment-ci creates and opens a new branch named experiment-ci and switches you to it immediately:
    switch = move to a different branch.
    -c = create the branch if it doesn’t exist.
    experiment-ci = the new branch name. \
  2. In myPython/, open one test (for example in tests/test_quotes.py) and change an expected value so that it is wrong.
  3. Run python -m pytest on your PC/Mac. It fails, as expected.
  4. git add -A && git commit -m "Break a test on purpose" && git push -u origin experiment-ci.
    Stage all changes: git add -A. Adds all modified, new, and deleted files to Git’s staging area.
    Create a commit: git commit -m "Break a test on purpose".
    Push the branch to GitHub (or another remote): git push -u origin experiment-ci.
    origin = the remote repository.
    experiment-ci = the branch being pushed.
    -u (--set-upstream) tells Git to remember that this local branch tracks origin/experiment-ci.
  5. Open the Actions tab. A new run appears for the branch experiment-ci, and it turns red. Open it and find your wrong assertion in the log. You will also receive an email.
  6. Clean up: 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.

7.5 Real fixes vs. fake fixes

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.

8. Stow, .github and your PC/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.

8.1 What Stow actually does

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:

  1. Stow is invoked per package. You run stow zsh, stow git, stow myPython. A package you never name is never linked. Stow does not scan the repo and link everything it finds.
  2. Stow has an ignore list. By default it refuses to stow certain names: VCS directories (.git, .svn, .hg, .bzr, CVS, _darcs), VCS metadata (.gitignore, .gitmodules, .cvsignore), editor backup and lock files (~, ##, .#), and top-level README, LICENSE*, COPYING. You can add your own rules in .stow-local-ignore (per package) or ~/.stow-global-ignore (per user).

8.2 Why .github is safe to keep at the repo root

Four independent mechanisms protect you. Any one of them is sufficient; together they make an accident very unlikely.

  1. .github is not a package you stow. Never run stow .github. Stow would try to link workflows/tests.yml into ~/workflows/, which is meaningless and would fail (or worse, succeed and leave stray directories). You stow zsh, git, myPython, and so on; .github is not on that list.
  2. Stow’s built-in ignore list includes .git, .gitignore, .gitmodules, .svn, .hg, and similar. It does not literally contain .github, so this mechanism is a near-miss rather than a guarantee. The real protection is mechanism 1 and 3.
  3. Shell globbing does not match dotfiles by default. If you use stow */ or a loop in install.sh, the pattern * does not match names starting with a dot, so .github is skipped automatically.
  4. Explicit package lists skip it. If your install.sh enumerates packages — PACKAGES=(zsh git ssh ghostty aerospace continue bin code myPython) — .github is not in the array and cannot be stowed.

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

8.3 .stfolder is not GitHub and not Stow

.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.

8.4 How editing works day to day

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.

8.5 What the robot can and cannot see

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 

8.6 The commands you will actually run

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.

Bitcoin donation

JustToThePoint Copyright © 2011 - 2026 Anawim. ALL RIGHTS RESERVED. Bilingual e-books, articles, and videos to help your child and your entire family succeed, develop a healthy lifestyle, and have a lot of fun. Social Issues, Join us.

This website uses cookies to improve your navigation experience.
By continuing, you are consenting to our use of cookies, in accordance with our Cookies Policy and Website Terms and Conditions of use.