JustToThePoint English Website Version
JustToThePoint en español

GitHub Actions and CI for your macOS dotfiles repo

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

GitHub Actions

1. The problem CI solves, in your own project

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:

2. Vocabulary: workflow, trigger, job, runner, step, action

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:

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.

3. The setup at a glance: the macOS repo, stow, myPython

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.

Why the workflow needs working-directory

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:

Note: defaults.run.working-directory applies only to run: steps. I

The paths: filter

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.

4. The tests.yml, line by line

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

4.1 YAML basics, and the three mistakes that break first workflows

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"

4.2 Line by line

  1. push fires when you push commits to any branch.
  2. pull_request fires when you open or update a pull request.
  3. 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:

Step 1

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.

Step 2

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

Step 3 — install packages

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

Step 4 — run the tests

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

4.3 Why Python 3.12 and not my Mac’s(PC’s) newer Python

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.

4.4 What happens when a step fails

The failure model is simple and worth internalising:

  1. The failing step is marked red.
  2. Later steps in the same job are skipped (shown as grey).
  3. The job is marked failed.
  4. The run is marked failed.
  5. Other jobs run independently, unless they declare needs: on the failed job.

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/

4.5 How to read the logs when something fails

  1. Open the Actions tab.
  2. Click the failed run.
  3. Click the test job in the left sidebar.
  4. Expand each step. The failing step is already expanded and marked red.
  5. Read from the bottom up. The last lines of a failed step usually contain the actual error, e.g., ModuleNotFoundError, AssertionError, No such file or directory.

Two debugging aids:

4.6 The one-line summary of the whole file

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

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

  1. git switch -c experiment-ci creates and opens a new branch.
  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 Mac. It fails, as expected.
  4. git add -A && git commit -m "Break a test on purpose" && git push -u 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. Go back: 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.

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

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.

9. Security in a public repository

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.

10. Costs, limits and housekeeping

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.

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.

11. Improvements, one small step at a time

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

12. Daily workflow, checklist and glossary

Your everyday routine, in five lines.

  1. Edit code in myPython/.
  2. Run the tests on your Mac: cd myPython && python -m pytest. Fix what fails.
  3. Commit and push: git add -A, git commit -m "What I changed", git push.
  4. Wait about a minute, then look at the tick next to your commit (or the badge in the README).
  5. If it is red, open the log, read the last 20 lines of the first red step, and fix the cause.

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

Quick answers to common worries.

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.

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.