JustToThePoint English Website Version
JustToThePoint en español

GitHub Actions and CI for your macOS dotfiles repo III

If it’s green or wriggles, it’s biology. If it stinks, it’s chemistry. If it doesn’t work, it’s physics or engineering. If it’s incomprehensible, it’s mathematics. If it puts you to sleep, it’s statistics.

If at first you don’t succeed… then bomb disposal probably isn’t the career for you.

The voices in my head may not be real, but they have some great ideas!

GitHub Actions

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 understand the exposure surface and follow a few rules that are cheap to apply and expensive to skip.

9.1 The exposure surface, in one table

What is exposed Who can see it Mitigation
Every committed file The whole internet .gitignore, careful commits
Every commit, including deleted files The whole internet, forever Never commit a secret; rotate if you do
Actions logs The whole internet Never print secrets; masking is partial
The workflow YAML The whole internet No secrets in the file; pin actions
Fork pull requests Anyone can open one Approve first-time contributors; avoid pull_request_target

The rule that covers most of this: treat the repository and its logs as a public web page.

9.2 Logs are public, and masking is not a guarantee

Anyone can open the Actions tab of a public repo and read the output of every step. Never print secrets:

# WRONG — the value appears in the log
- run: echo "Key is ${{ secrets.OPENROUTER_KEY }}"

# $API_KEY is expanded by the shell before curl starts, 
# so the bearer token becomes part of curl’s command-line arguments. 
# That can expose it through process listings or certain error/debug output; 
# GitHub’s masking reduces log exposure but is not a complete security boundary
- run: curl -H "Authorization: Bearer $API_KEY" https://example.com
  env:
    API_KEY: ${{ secrets.OPENROUTER_KEY }}

9.3 Secrets belong in Settings, not in the file

Your toolkit needs no secrets in CI: the OpenRouter key lives in the Keychain, the real .env is gitignored, and the tests use fakes. That is the ideal state, and it is worth keeping.

If a workflow ever needs one, store it in Settings → Secrets and variables → Actions and reference it as ${{ secrets.NAME }}.

9.4 .gitignore is your first gate, and git history is forever

Before the first push of anything new, confirm .gitignore contains at least:

.env
.env.*
.venv/
__pycache__/
*.pyc

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.

9.5 SSH keys: the config is safe, the private key is not

Your ssh/.ssh/config contains host entries and the path of the key (IdentityFile ~/.ssh/id_ed25519), not the key itself. That file is safe to commit and is genuinely useful in a dotfiles repo.

What must never be committed:

A good safety net, in .gitignore:

ssh/.ssh/id_*
!ssh/.ssh/id_*.pub

The ! line keeps public keys (*.pub), which are harmless and sometimes useful for reference. If you do not want public keys either, drop the exception.

9.6 GITHUB_TOKEN: least privilege from the first line

Every run receives a temporary token, GITHUB_TOKEN, that can act on the repository. Its default permissions depend on when the repository was created:

The difference is invisible until you need to write, and dangerous until you need to read a log. Do not rely on the repository default. Declare the minimum at the top of every workflow:

permissions:
  contents: read

This means “the robot may read the repo and nothing else.” A test run never needs more. If a job genuinely needs to write (a release job, a comment bot), grant it per job, not workflow-wide.

9.7 Third-party actions: prefer, pin, and read

Be careful with other people’s actions.** uses: runs someone else’s code on your runner, with your token and (if configured) your secrets. Three rules:

  1. Prefer actions/… and well-known publishers. actions/checkout, actions/setup-python, actions/cache, actions/upload-artifact are maintained by GitHub. They are not immune to compromise, but they are the lowest-risk default.

  2. Pin a version; never use @main or @master. A floating branch means the publisher can change what runs on your machine without any action from you. @v4 is a moving tag within a major version — better than @main, but still mutable.

  3. For the strictest pinning, use a full 40-character commit SHA:

- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683  # v4.2.2

A SHA is immutable: the publisher cannot change what that reference points to. The comment keeps it readable. This is the documented recommendation for third-party actions.

9.8 Fork pull requests and the pwn-request trap

Anyone can fork your public repo and open a pull request. Two triggers behave very differently:

Trigger Runs from Secrets available? Token
pull_request The fork’s code, in the fork’s context No Read-only
pull_request_target The base repo’s context Yes Read/write

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.

9.9 Free security features you should turn on

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.

Secret scanning notifies the provider. If you commit an OpenRouter or AWS key, GitHub tells the provider, and the provider may revoke it automatically. That is a good outcome — but it is a revocation, not a rescue. Rotate the key anyway.

10. Costs, limits and housekeeping

For a basic situation the cost is zero. GitHub does not charge for standard runners on public repositories, and my run uses well under a minute. The figures below matter the day you flip the repo to private, or the day a run behaves strangely; GitHub changes plans periodically, so check the current pricing page before relying on a number.

10.1 The short answer for your project

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.
How long does one run take? 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.
Concurrent jobs Plenty for one person. A second push while one run is active simply queues another run.

10.2 Public vs private, precisely

The billing model has three layers: included minutes, OS multipliers, and per-minute overage.

Public repositories consume zero included minutes — standard runner usage on public repos is free on all plans. Private and internal repositories draw from the pool. Your repo is public, so the pool is irrelevant today.

10.3 The multiplier trap

Included minutes are Linux-equivalent. Windows and macOS consume them faster:

Runner Multiplier What 100 wall-clock minutes costs
Linux 1× 100 included minutes
Windows 2× 200 included minutes
macOS 10× 1,000 included minutes

This is the most common source of surprise. A workflow that runs 100 minutes of macOS builds per month consumes half of the Free plan's entire allocation. The same workflow on Linux consumes 100.

10.4 Rounding and larger runners

Two details that are easy to miss:

The default spending limit on Free and Pro is $0: when the included minutes are exhausted, workflows simply stop running rather than incurring a charge. If you ever see “The job was not started because recent account payments have failed or your spending limit needs to be increased,” that is this limit, not a bug.

For your project, runs-on: ubuntu-latest is not just a default — it is the cheapest correct choice. Do not switch to macos-latest unless a test genuinely needs macOS; a Python test suite does not.

10.5 Housekeeping without ceremony

None of this is required, but each item is one click or one line and prevents a small annoyance. Housekeeping, only if you want it.

A deadlocked test will otherwise burn the default six hours of runner time and your attention.

10.5 The attention budget

Most of CI’s cost is human: reading red runs, deciding whether a failure is real, and keeping the tests healthy. The compute is free; the attention is not.

A rule that works well for a personal project: Never leave main red for more than a day.

If a test is flaky — sometimes red, sometimes green with no code change — fix it or delete it. A test you learn to ignore trains you to ignore all of them, and that is the one failure mode CI cannot survive. The -ra flag from section 7 helps here: it makes skips visible, so a test that silently stopped running does not masquerade as a pass.

11. Improvements, one small step at a time

We have a working setup. Everything below is optional. The discipline that makes this section useful is simple: add one item, push, see it green, and only then add the next. Each step is ordered by value-per-effort for your project — a public dotfiles repo with one Python package and a 35-second test run.

A note on the order. The draft started with safety settings. I would put the badge first, because it is zero-risk and it changes how you see CI: the result becomes visible on the repo’s front page instead of behind a tab. Everything else follows.

Step 1 — The status badge (5 minutes, zero risk)

What it is. A small image at the top of your README that shows the result of the latest run on main. It is a URL, not a file.

Why first. It turns CI from “a tab I check” into “the first thing I see when I open the repo.” That single change is what makes the rest of this section feel worth doing.

The badge for your workflow:

![Tests](https://github.com/nmaximo7/macOS/actions/workflows/tests.yml/badge.svg)

Put it near the top of README.md, under the title. The path must match the workflow file name (tests.yml), not the workflow’s name: field.

Verify. Open the README on GitHub. The badge should render within a few seconds. If it shows “no status,” confirm the file name and that the workflow has run on main.

Add three small things to tests.yml: read-only permissions, a time limit, and cancellation of obsolete runs. Each is one block, and each prevents a specific annoyance.

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?

  1. permissions: contents: read — the token can read the repo and nothing else. This is the single highest-value security line in the file.
  2. timeout-minutes: 10 — the default job timeout is 6 hours. A deadlocked test would otherwise consume a runner for six hours and leave a yellow dot in your Actions tab all afternoon. Ten minutes is generous for a 35-second suite.
  3. concurrency — a new push to the same ref cancels the previous run only for pull requests.

What it is? A matrix runs the same job once per version, in parallel.

Why it matters for your project specifically. myPython declares requires-python >= 3.11, but only 3.12 is tested. If a contributor on 3.11 hits a SyntaxError or a removed standard-library module, you will not know until someone reports it. The runner’s tool cache already contains 3.10–3.14, so adding 3.11 and 3.13 costs almost nothing.

jobs:
  test:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    strategy:
      fail-fast: false
      matrix:
        python-version: ["3.11", "3.12", "3.13"]
    defaults:
      run:
        working-directory: myPython
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python-version }}
          cache: pip
          cache-dependency-path: myPython/requirements.txt
      - run: python -m pip install -r requirements.txt pytest
      - run: python -m pytest

Why fail-fast: false? The default is true, which means the first failing matrix job cancels all the others. You then learn about one version per push and have to push again to discover the next. With false, all three finish and you see every failure in one run. For a matrix of three versions, false is almost always the right choice.

What you will see? Three jobs in the run graph, labelled with the version. Each has its own log, its own cache key, and its own verdict. A green run with three green jobs means “this commit works on every version you claim.”

When to change the list?

Verify. The run should show three jobs. If one is yellow and two are green, fail-fast is off and the yellow one is the version you need to fix (or the version to drop).

Step 4 — Let a bot keep dependencies and actions current (20 minutes + monthly review)

What it is? Dependabot opens pull requests when a pinned dependency or action has a new release. CI runs on each PR. If it is green, you merge with one click; if it is red, you learned that the update breaks something before it reached main.

Why this is where CI pays off most. You are not going to remember to bump requests from 2.32 to 2.33. The bot does. And because CI runs on the PR, the update is tested in exactly the environment that matters.

Create the file .github/dependabot.yml at the top of the repo:

.
├── .github
│   ├── dependabot.yml
│   └── workflows
│       └── tests.yml
└── myPython
    ├── requirements.txt      # or pyproject.toml / Pipfile
    └── ...
version: 2
updates:
  - package-ecosystem: "pip"
    directory: "/myPython"
    schedule:
      interval: "monthly"
    open-pull-requests-limit: 5
    groups:
      python-dependencies:
        patterns: ["*"]

  - package-ecosystem: "github-actions"
    directory: "/"
    schedule:
      interval: "monthly"
    groups:
      github-actions:
        patterns: ["*"]

The three settings that matter

What to do with a red Dependabot PR?

A red Dependabot PR is information, not a problem. It means the new version changes something your tests catch. Two legitimate responses:

  1. Fix the incompatibility (update your code, or pin the dependency below the breaking version with a comment).
  2. Close the PR and wait — but add an ignore rule so the bot does not reopen it next month.

Step 5 — Protect main (10 minutes, optional)

What it is? A branch protection rule that requires the test check to pass before a pull request can merge into main.

Availability for your repo. nmaximo7/macOS is a public repo, and branch protection is available in public repositories on GitHub Free. How to enable it:

  1. Settings → Branches → Add branch ruleset.
  2. Ruleset name: main.
  3. Tick Require status checks to pass before merging.
  4. Search for the check named test — this is the job name in tests.yml. Select it.
  5. Tick Require branches to be up to date before merging if you want merges to be tested against the latest main. This is stricter and occasionally annoying. In my personal case, I will leave it off.
  6. Save.

Step 6 — A linter as a separate job (30 minutes, optional)

What it is? ruff finds unused imports, undefined names, and obvious mistakes without running the code. It is faster than pytest and catches different class of problems.

Why a separate job, not a step? A lint failure and a test failure are different signals. If lint is a step in the test job, a lint error prevents the tests from running, and you lose the information that the tests would have passed. As a separate job, lint and tests run in parallel and report independently.

Why not enable it today? If you enable lint before cleaning the existing warnings, your first run will be red for reasons unrelated to your current change. Run it locally first:

cd myPython
python -m pip install ruff
ruff check .

Fix or explicitly ignore every finding, commit the clean state, and only then add the CI job. The workflow, as a new file .github/workflows/lint.yml:

name: Lint

on:
  push:
    paths: ["myPython/**", ".github/workflows/lint.yml"]
  pull_request:
    paths: ["myPython/**", ".github/workflows/lint.yml"]
  workflow_dispatch:

permissions:
  contents: read

jobs:
  ruff:
    runs-on: ubuntu-latest
    timeout-minutes: 5
    defaults:
      run:
        working-directory: myPython
    steps:
      - uses: actions/checkout@v4
      - uses: astral-sh/ruff-action@v3
        with:
          args: check --output-format=github

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.

Quick answers to common worries.

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.