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!

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.
| 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.
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 }}
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 }}.
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.
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.
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.
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:
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.
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.
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.
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.
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.
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.
| 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. |
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.
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.
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.
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.
Delete old runs: in the Actions tab, the “…” menu on a row has “Delete workflow run”. They cost nothing, but a long list makes the useful runs harder to find.
Disable the workflow temporarily: Actions > Tests > “…” > Disable workflow. Useful when you are making many experimental commits and do not want a red run for each one. Re-enable it before you push anything you care about.
Cancel superseded runs automatically. Add a concurrency block so a new push cancels the previous run of the same branch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
The conditional is deliberate: do not cancel in-progress runs on main, or every commit except the last loses its verdict. Cancelling on pull requests only is the safe compromise.
Keep the workflow file small and single-purpose. One file, one job. If you later add a linter or a release job, put each in its own file in .github/workflows/, with its own badge and its own history. A 200-line workflow that does five things is harder to debug than five 40-line workflows.
Cap the job timeout. The single highest-value line you can add to tests.yml:
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 10
A deadlocked test will otherwise burn the default six hours of runner time and your attention.
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.
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.
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:

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?
permissions: contents: read — the token can read the repo and nothing else. This is the single highest-value security line in the file.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.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).
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
directory: "/myPython" — Dependabot looks for the package manifest inside that subdirectory, not at the repo root. This is correct for your layout: requirements.txt and pyproject.toml are in myPython/. If you ever add a second Python package, add a second entry with its own directory; Dependabot does not recurse into subdirectories by default.interval: "monthly" — weekly is too noisy for a personal project. Monthly gives you a predictable review session. For github-actions, monthly is also enough to keep pinned actions fresh without constant PRs.groups — without grouping, Dependabot opens one PR per dependency. With grouping, all Python updates arrive as a single PR, and all action updates as another. That is two PRs per month instead of ten, and CI runs once per group rather than once per package.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:
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:
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
Your everyday routine, in five lines.
myPython/.cd myPython && python -m pytest. Fix what fails.git add -A, git commit -m "What I changed", git push.Step 2 is not redundant: running tests locally takes a fraction of a second and gives faster feedback than waiting for GitHub. CI is the second opinion, not a replacement.
Quick answers to common worries.
myPython/** or the workflow file, so the paths filter skipped it. That is expected. Use “Run workflow” to start one by hand.experiment-ci: push there, look at the run, and delete the branch afterwards.