little-sister-github
GitHub check types for little-sister.
github — one node per configured account — an organization, optionally
narrowed to one team, or a personal account — with a child per enabled aspect:
open pull requests, Dependabot advisories, code-scanning and secret-scanning
alerts, SBOM presence, workflow runs and open issues.
github-rate-limit — one node per token, with a line per API budget.
Cheap enough to run every minute, and it is what explains a github check that
has started skipping its runs.
Every finding is an individually addressable line, so an operator who opens a ticket for one alert can put that line into maintenance and the rest of the aspect keeps reporting.
The contract
- Requires
little-sister >= 0.3.13— a floor, never a pin. - Runs on Python 3.11 or newer — the library's floor, not a higher one of its own.
- Registers two check types:
githubandgithub-rate-limit. One import registers both.
Install
# your deployment's pyproject.toml — both come from the index
[project]
# Pin them. A deployment names exact versions so an upgrade is a deliberate edit
# rather than drift; a plugin is the one that declares a floor, because two plugins
# that each pinned could not be installed together.
dependencies = ["little-sister==0.3.13", "little-sister-github==0.1.0"]
# wsgi.py — registrations first, the app last. The order is load-bearing:
# importing little_sister.app builds the engine and loads the check configs, so
# every check type must already be registered. `isort: off` keeps an import
# sorter from quietly reversing that.
# isort: off
import little_sister_github # noqa: F401 registers both check types
from little_sister.app import app
# isort: on
__all__ = ["app"] # without it, lint calls the app import unused
Configure github
Copy examples/github.yaml into your deployment's
config/checks/, set owner, kind, team and the token reference, and you are done —
one file per team. The credential is a reference, never a value:
type: github
path: /platform/github
secrets:
token: env://PLATFORM_GITHUB_TOKEN
owner: example-org
kind: organization
team: platform
The token needs read:org, repo, security_events and dependency-graph read
access. Each team's check carries its own credential, so a second team is a second
config file rather than a code change.
owner: may name a person
GitHub has one account namespace with two kinds in it, and owner: takes a login
of either kind. kind: says which — organization or user — and it is
required. It is declared rather than discovered because every load-time decision
is taken from it, and it is verified on every run against GET /users/{login},
because a claim nobody checks decays: GitHub lets a personal account convert to an
organization. A disagreement is a refusal naming both the config's claim and
GitHub's answer, not a wrong scope.
What follows from the kind:
team:is an organization's. A personal account has no teams, so the combination is a config error, refused before the check ever runs.advanced_security_on_privatedefaults totruefor an organization andfalsefor a personal account — see below.- A personal account's private repositories need its own token.
/users/{login}/reposreturns public repositories only, however privileged the token — the private ones are listed by/user/repos, and only for the account the token belongs to. When they are out of reach the check's own reading says(public only), because a scope smaller than the one configured otherwise reads exactly like a complete one. - The link to the organization security overview is dropped on a personal account rather than pointing at a page that 404s.
Advanced Security is about visibility, not about the kind
Code scanning and secret scanning need GitHub Advanced Security.
advanced_security_on_private: says whether this deployment has it on private
repositories; the account kind only picks the default. With it off, private
repositories drop out of those two aspects entirely and are named on the aspect's
node; the account's public repositories are still read. Every other aspect reads
private repositories either way. Why the switch is drawn on visibility rather than
on the account kind, and why the skipped repositories are named instead of reported
as unscanned, is
ADR-0003,
decision 6.
Switching an aspect off
Every aspect runs unless its own block says enabled: false:
secret_scanning:
enabled: false # switches off the `secret_scanning_alerts` aspect
A switched-off aspect is skipped whole — no node, none of its API calls, and the
rate-limit estimate shrinks with it. No node also means no pin: a maintenance
pin held against that node, or against a line under it, matches nothing while the
aspect is off. An aspect that says nothing is on, so an aspect a later release
adds arrives switched on in configs written before it existed. Switching every
aspect off is a config error. The one block not named after its aspect is
secret_scanning:, which switches secret_scanning_alerts — the block is named
for GitHub's feature, the node for what it reports, and both are keys somebody
holds, which is why neither was renamed
(ADR-0003,
decisions 5 and 7).
The per-aspect display text ships with the type and expands {owner} /
{team} from the config, so it is not copied per team. Your deployment's own policy — a
remediation deadline, who to notify — goes in that config's subnodes: block,
appended to the shipped text with {default}. little-sister reads that block itself,
so it works the same way for every branch check type you install.
What it reads
A severity band's title is a colored circle — 🔴 critical, 🟠 high, 🟡 medium,
🔵 low, and the analysis severities error / warning / note on the same three
rungs — with ❓ for a severity this package does not name. The colour is by name, so
the same severity wears the same circle in every aspect and in every deployment; the
band's own name sits beside it, and nodes.yaml sets a different title per node path if
you want the word.
| Aspect | Endpoint | Grade |
|---|---|---|
pull_requests |
GET /repos/{r}/pulls?state=open |
any open PR (minus ignore_title_prefixes) → WARN |
security_advisories |
GET /repos/{r}/dependabot/alerts?state=open |
one leaf per selected severity, graded by security_advisories.severity_map |
code_scanning_security |
GET /repos/{r}/code-scanning/alerts?state=open |
the alerts GitHub gave a security severity: one leaf per critical / high / medium / low, graded by code_scanning_security.severity_map (all ERROR by default) |
code_scanning_quality |
(the same read) | everything else, by the rule's own analysis severity: error / warning / note, graded by code_scanning_quality.severity_map (WARN / WARN / OK by default) |
secret_scanning_alerts |
GET /repos/{r}/secret-scanning/alerts?state=open |
any open alert → ERROR; scanning disabled → ERROR (secret_scanning.require_enabled) |
sbom_check |
GET /repos/{r}/dependency-graph/sbom |
no dependency graph → ERROR (sbom_check.ignore) |
actions |
GET /repos/{r}/actions/workflows + …/actions/runs |
one coded line per workflow and branch that has something to say: the newest useful verdict, plus a newer in-flight run (default branch unless actions.all_branches; a passing idle workflow only with actions.show_healthy). Where one read did not carry every run, a workflow it saw nothing of is reported as having no state rather than left looking healthy |
issues |
GET /repos/{r}/issues?state=open |
any open issue → WARN (issues.ignore); issues disabled → WARN |
Discovery is one call verifying the declared kind — plus, for a personal account,
one asking whose token this is — and then the repository listing: the
organization's, the account's, or (with team:) the org's teams followed by that
team's repositories. It is filtered by name_prefix, include_archived and
include_forks, and every aspect starts from that one set — narrowed only where an
aspect says so: the two Advanced Security aspects drop private repositories when
advanced_security_on_private is off, and sbom_check and issues skip their own
ignore lists. The check's own node carries the
discovery coverage reading (expect_min_repos) and the repository roster, and rolls
up worst-of its aspects. Only stdlib urllib is used — the package has no
dependency but little-sister itself.
The table says what each aspect grades. Why it grades that way — why the tree is aspect-first, why a missing dependency graph is red while an open pull request is amber, and what a severity band asserts while it is empty — is ADR-0003 and ADR-0004.
Which of these you can change, and which you cannot. The two alert aspects are
graded by settings that exist in order to be overruled — severity_map says what a
severity means here, severities says which ones are looked at at all. The other
five carry the codes in this table with no knob for them: what a deployment decides
about those is whether the aspect runs (enabled: false) and which repositories or
titles it skips.
When GitHub is the one having a bad day
A read that fails is not automatically a finding about the repository, and this type tells the two apart by status (ADR-0002).
| what came back | what you see |
|---|---|
| 5xx or a transport failure, twice | a line saying could not ask GitHub that grades nothing — the repository is not painted amber for GitHub's outage |
| a rate limit — 403 or 429 with a throttle header | the same quiet line, and the wait GitHub named is honored if the run can afford it |
| 404 | unchanged: the thing is absent, which is a finding |
| 401 / 403 with no throttle header | unchanged: still grades, because a token that may not read a repository is a fact about that repository |
| an answer this check cannot read | a line saying could not read, which grades — waiting changes nothing about a payload of the wrong shape |
A rate limit is not now, not no. GitHub answers one with 403 or 429, and a
bare 403 also means this token may not see it — so the status alone cannot tell the
two apart, and the headers decide: retry-after, then x-ratelimit-remaining: 0 with
x-ratelimit-reset, otherwise 60 seconds. A bare 429 counts as a throttle, because
GitHub sends that status for nothing else; a bare 403 does not. The wait stays inside
timeout: — a short secondary limit is absorbed and the run carries on, while a
reset twenty minutes out is not slept through: the line says how long GitHub asked for,
and the run reports what it has. When to ask again is your frequency:.
A transient failure is retried once before any of that, so what reaches the dashboard has usually survived a second ask — usually, because a run short of budget skips the retry, which is why the sentences below claim only that GitHub did not answer (ADR-0002, decision 3).
Because those repository lines grade nothing, the coverage does, in two places
that say different things. An aspect that could not ask about a repository carries one
amber line of its own — GitHub did not answer for 1 of 40 repositories — so an
aspect that could not look is never mistaken for one with nothing to report, whether
it missed one repository or all of them. That line appears only when something
could not be asked about, so an aspect whose only trouble was a permission error stays
as quiet as before: that repository is already amber on its own line, and counting it
twice would make the number mean two things. And the check's own node states the run's
total, once: 3 repository reads could not be completed this run.
Discovery follows the same rule as everything under it. If GitHub cannot answer which
repositories exist, the check says WARN and reports nothing else — which leaves
every aspect showing what the last good run found, rather than replacing a working
dashboard with one red line. A discovery failure GitHub answered — an owner or team
that is not there, a token that may not look — is a defect in your configuration and
stays ERROR.
Three budgets, and they are not the same one
timeout:is the whole run's deadline, and the check honors it. When it runs out the aspects that finished are kept, the rest are absent, and the node says so. Size it for the scope: a run makes at least one request per repository per aspect —actionsmakes two — and pages on top of that.request_timeout:(default15s) bounds one request, and a request is additionally clamped to whatever is left oftimeout:.max_pause:(default: half oftimeout:) bounds how much of the run may be spent asleep waiting out a GitHub rate limit. The deadline cannot say this on its own: a run that sleeps for its whole budget never overruns it, and reports nothing. A wait the budget cannot afford is refused whole and ends the run like an exhaustedtimeout:— what finished is kept, and the node says which wait it would have taken. It must be less thantimeout:, or no run could reach it.
request_timeout: is what reaches the socket layer, which bounds a socket
operation rather than a whole request; timeout: is checked between requests and
clamps that socket timeout down to whatever is left of the run. A response body is
read in bounded chunks against timeout: as well, which is what closes the last gap
in that pair: a socket timeout alone never fires on a server dribbling one byte at a
time, because every individual read succeeds.
If you are upgrading, re-read your timeout: against that first bullet: it used to
be spent per request and now bounds the run.
github-rate-limit — the API budget
One node per token, with a line per API budget. It is a check type of its own
rather than an eighth aspect of github, because a rate limit belongs to the
credential and not to an account — the argument is
ADR-0001. Reading
GET /rate_limit does not count against the budget it reports, so this check can
run every minute beside a github check that runs every fifteen, and it keeps
reporting through the runs the github check skips for want of budget.
type: github-rate-limit
path: /platform/github-rate-limit
frequency: 1m
secrets:
token: env://PLATFORM_GITHUB_TOKEN # the same token you want the budget of
warn_below: 1000 # the default for every resource below
error_below: 500
# resources: # leave the key out to watch core + graphql
# core:
# search:
# warn_below: 10 # a small budget wants both of its own
# error_below: 3 # numbers — error_below is the lower one
One line per resource, each carrying its own verdict, keyed by GitHub's own
resource name — so a maintenance pin held against core survives a config that
starts watching search next year:
core: 4812 of 5000 requests left, resets in 43min
graphql: 122 of 5000 points left, resets in 12min
Readings it will not fake:
- an unreadable endpoint says the asking failed, rather than claiming a budget nobody read;
- a watched resource GitHub did not report is a warning line naming it, not a missing line;
- a row this check cannot read — a missing
limit, a field in a shape that is not a number — is a warning line for that resource only; - a resource whose reported limit is not positive says nothing at all rather than grading.
Why each of those is the reading it is, is ADR-0001, decision 5.
Grading is on what is left and only on that: the reset time is on the line so you can see a red budget is about to refill, and it does not soften the verdict (ADR-0001, decision 4). The thresholds in force are rendered on the node.
The token needs no scopes for this endpoint — but it has to resolve, and it has
to be one GitHub accepts. A reference that resolves to nothing pins the check to a
visible ERROR before it ever runs; a token GitHub rejects is a 401, which this check
reports as could not ask GitHub for the rate limit. Neither is a quiet failure.
Copy examples/github-rate-limit.yaml — one file
per token, not per team.
Develop
little-sister is declared as a floor — the release that promised the surface
this package imports — and it resolves from the index, like any other
dependency. There is no [tool.uv.sources] table here, and the committed
uv.lock is what a release runs against. To work against a local library
checkout, add the redirect and do not commit it: uv reads the sources table of
a dependency it resolves from a path or a checkout, so a committed line would
follow this package into every deployment that installs it.
# pyproject.toml — locally, never committed
[tool.uv.sources]
little-sister = { git = "file:///path/to/little-sister" }
Restore uv.lock with it. The next uv run — the pre-commit gate is one — rewrites
the lock to source = { directory = … }, so a redirect kept out of pyproject.toml
can still reach a commit through the lock beside it.
uv sync
uv run ruff check
uv run shellcheck $(git ls-files -- '*.sh' 'hooks/pre-commit')
uv run mypy
uv run mypy --python-version 3.11 # against the floor, not the interpreter you have
uv run pytest -q
# The same gate runs before every commit once the hook is enabled:
git config core.hooksPath hooks
A release runs it once more on a real 3.11, because the floor is a promise a type checker alone cannot keep.
The tests are fixture-based; nothing in this repository calls GitHub.
License
MIT — see LICENSE.
Metadata
Release files for little-sister-github 0.1.4
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| little_sister_github-0.1.4.tar.gz | 60.6 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| little_sister_github-0.1.4-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 120.5 kB
Release files / little_sister_github-0.1.4.tar.gz
| Download URL | little_sister_github-0.1.4.tar.gz |
|---|---|
| Size | 60.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
6dc493e8005f2cc99dfed36f6928dd35d6341ea5aa152a7fe9255425ed547ff9
|
|
BLAKE2b-256 checksum How to use checksums |
3769620080a8850e0a904f270db37a4eed8e780d556e30aba03576781eb607af
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.11.28 {"installer":{"name":"uv","version":"0.11.28","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Release files / little_sister_github-0.1.4-py3-none-any.whl
| Download URL | little_sister_github-0.1.4-py3-none-any.whl |
|---|---|
| Size | 59.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
7f47669d5b89d47500d38edcb102b6730f930c7a81ab771868ea4805f67090ca
|
|
BLAKE2b-256 checksum How to use checksums |
30281c8c5a285f9dfeb57057581b38e827bfe1f695c82449e37860f2ff845d03
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.11.28 {"installer":{"name":"uv","version":"0.11.28","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|