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, dependency-graph 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.
This file is the front door: what the package needs, how it is installed and
configured, and what the token must be allowed to read. How the two checks are built
— the client, the run, the budget ledger, what every line on a node and in the log
means — is docs/architecture.md; why they are built that way
is docs/decisions.md and the records in
docs/adr/.
The contract
- Requires
little-sister >= 0.3.18— 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.18", "little-sister-github==0.1.9"]
# wsgi.py — registrations first, the app last. The order is load-bearing: the
# server's start, right after little_sister.app is imported, builds the engine from
# 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
What the token needs, read by read, is under What the token needs below. Each team's check carries its own credential, so a second team is a second config file rather than a code change.
The settings
The common keys every check has — path, title, about, frequency, timeout,
secrets — are little-sister's; timeout: is the whole run's budget here (it used
to be spent per request — if you are upgrading, re-read it against your scope). The
example file carries a comment per key; this is the same list with its defaults.
| Key | Default | What it decides |
|---|---|---|
owner |
required | the account login — an organization or a person |
kind |
required | organization or user; declared, and verified against GitHub on every run |
team |
— | narrows an organization to one team, by slug or name; refused with kind: user |
name_prefix |
— | keeps only repositories whose name starts with it |
include_archived |
false |
archived repositories are ones nobody can act on |
include_forks |
true |
a fork the account holds is a repository, and its pull requests are real |
api_url |
https://api.github.com |
a GitHub Enterprise Server's …/api/v3; its GraphQL endpoint is found beside it |
advanced_security_on_private |
true for an organization, false for a personal account |
whether the code-scanning and secret-scanning aspects read private repositories — a question about visibility, since Advanced Security is paid there; the ones they skip are named on the aspect (ADR-0003, decision 6) |
expect_min_repos |
1 |
fewer repositories discovered is WARN on the check's node; cannot be 0 |
request_timeout |
15s |
one request's budget; a request is also clamped to what is left of timeout |
max_pause |
half of timeout |
how much of the run may be spent asleep, waiting out a rate limit or on a retry's backoff; must be less than timeout |
rate_limit_safety_factor |
4 |
the guard: a run is skipped when a budget window it would spend has less than this times the reads landing on it |
<aspect>.enabled |
true |
in the aspect's own block; secret_scanning: is the block of secret_scanning_alerts |
pull_requests.ignore_title_prefixes |
[] |
open pull requests whose title starts with one are not listed (case-insensitive) |
security_advisories.severities |
[critical, high] |
which Dependabot severities are reported at all |
security_advisories.severity_map |
critical, high → ERROR; medium, low → WARN |
what each reported severity means here |
code_scanning_security.severity_map |
every band ERROR | the alerts GitHub gave a security severity |
code_scanning_quality.severity_map |
error, warning → WARN; note → OK |
everything else, by the rule's own analysis severity |
secret_scanning.require_enabled |
true |
a repository with secret scanning switched off is ERROR |
sbom_check.ignore |
[] |
repositories exempt from the dependency-graph requirement |
actions.all_branches |
false |
watch every branch rather than the default one — the one mode that stays incomplete, and says so |
actions.branches |
[] |
watch these branches instead of the default one, each workflow asked about each name; refused together with all_branches |
actions.disabled_severity_map |
disabled_manually, disabled_inactivity → WARN; disabled_fork → OK |
what a switched-off workflow means here; its runs are not read |
actions.show_healthy |
false |
also list workflows that passed and are idle |
actions.ignore_workflow_name_patterns |
[] |
regexes, case-insensitive, applied before anything is spent on a workflow |
issues.ignore |
[] |
repositories exempt from the issues check |
subnodes |
— | your own display text per aspect, appended to the shipped one with {default} |
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. team: is an organization's, so kind: user
with a team is refused before the check ever runs.
A personal account's private repositories need its own token.
/users/{login}/repos returns 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.
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 (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
| 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 |
POST /graphql — repository(owner:, name:) { dependencyGraphManifests(first: 10) { totalCount nodes { filename parseable exceedsMaxSize } } }, one query per repository, one point each |
no dependency graph → ERROR (platform-api: no dependency graph (0 manifests)); manifests none of which could be parsed → ERROR, with the cause on the line (platform-api: 2 manifests, none parseable (package-lock.json exceeds the size limit)); more than ten manifests is a graph whatever the first ten say (sbom_check.ignore; ADR-0008) |
actions |
GET /repos/{r}/actions/workflows + …/actions/workflows/{id}/runs per workflow and branch, and …/actions/runs/{id} for a held run an answer went back on |
one coded line per workflow and branch that has something to say: the newest useful verdict, plus a newer in-flight run (the default branch, or the branches actions.branches names, or every branch with actions.all_branches; a passing idle workflow only with actions.show_healthy). Asking per workflow is exact as a question, and GitHub's answer is checked: each is sorted by run id, and the newest completed run a line has read is held, so an answer that goes back on it is checked by reading that run by id — one INFO line in the log each time — and nothing back means that workflow does not run on that branch only where nothing is held. A line is named after its workflow, as the workflow list names it (ADR-0015). actions.all_branches and a budget too thin to pay per workflow fall back to one page of …/actions/runs, and one WARN line then names the repositories the answer was short about; where the named branches matched no workflow in a repository, another names it and its default branch (ADR-0005, ADR-0009) |
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, filtered by
name_prefix, include_archived and include_forks. Every aspect starts from that
one set, narrowed only where an aspect says so. The check's own node carries the
discovery coverage reading (expect_min_repos) and the repository roster, and rolls
up worst-of its aspects. A severity band's title is a colored circle by severity
name — 🔴 critical, 🟠 high, 🟡 medium, 🔵 low, the analysis severities on the
same three rungs, ❓ for one this package does not name — and nodes.yaml sets a
different title per node path if you want the word. 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 three banded 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.
What the token needs
The check works with a classic personal access token and with a fine-grained one; nothing in it cares which, as long as the token may read what an aspect reads. A fine-grained token is issued with the organization as its resource owner and granted the repositories the check should see — every repository the scope will contain, or the listing is quietly shorter than the organization. Read access is enough everywhere; the check writes nothing.
| Read | Classic scope | Fine-grained permission |
|---|---|---|
discovery — /orgs/{org}/repos, /users/{login}/repos |
repo (public_repo for public repositories only) |
Metadata (read), which every fine-grained token has |
discovery with team: — /orgs/{org}/teams, …/teams/{team}/repos |
read:org |
Members — an organization permission (read) |
pull_requests |
repo |
Pull requests (read) |
security_advisories — Dependabot alerts |
security_events |
Dependabot alerts (read) |
code_scanning_security, code_scanning_quality |
security_events |
Code scanning alerts (read) |
secret_scanning_alerts |
repo or security_events |
Secret scanning alerts (read) |
sbom_check — the dependency graph |
repo |
Contents (read) |
actions |
repo |
Actions (read) |
issues |
repo |
Issues (read) |
github-rate-limit — GET /rate_limit, and GET /user once per process |
none | none |
A token that may not read something answers 401 or 403, and the check reports that
as a fact about the repository rather than as a failure of the run; a token that may
not read the repository list fails discovery, which the node says in as many words.
A GitHub App's installation token is not a fit today: it expires after an hour, and
little-sister resolves a credential once, at startup.
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): a 5xx, a dropped
connection or a rate limit — retried once — is a line saying could not ask GitHub
that grades nothing, while a 404 and a 401 or 403 without a throttle header
are answers, and still grade. Because those quiet lines grade nothing, the coverage
does: 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, and the check's own node states
the run's total once, with what the run slept, by cause — paused 61s for a GitHub rate limit, paused 3s retrying after GitHub did not answer. A wait GitHub asks for is
taken when the run and max_pause can afford it and refused whole when they cannot;
when to ask again is your frequency:. If GitHub cannot answer which repositories
exist, the check says WARN and reports nothing else, so every aspect keeps what the
last good run found; 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. The fault table, the throttle reading and the two coverage lines are
docs/architecture.md §2 and §3.
The run is skipped before it can exhaust the token
A run is skipped, with a WARN naming the window, when a budget window it would spend
cannot afford it: rate_limit_safety_factor times the reads that will land on that
window, less what something else is measured to be spending on it. A token has more
than one core window, split by request path, and GET /rate_limit reports one of
them — for some tokens one nothing spends — so the guard prices each window from the
budget headers of this process's own reads, and reads the endpoint only where it knows
nothing yet (ADR-0007,
decision 3):
skipped this run: 310 API calls left on the window GitHub charges the dependabot, code-scanning and actions reads to (resets in 12min), need > 4×57 for 19 repo(s)
The github-rate-limit check on the same token explains the skipped runs.
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. Its first run
also asks GET /user whose token it is — one core request per process — so that
each budget's reading names the account it belongs to; an installation token is not
a user, GitHub refuses it there, and its readings name none. timeout: is the whole
run's budget here too, as in github, and each request's limit as well; a rate-limit
wait the run cannot afford is not waited out, and the node says it could not ask.
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: 2441 of 5000 requests left, resets in 1min; 269 of it this process's own; ~1250/h of it is spent by something else using this token — the tightest of 2 windows GitHub keeps for this token; the other has 3932 left, resets in 6min
graphql: 5000 of 5000 points left, resets in 59min — as /rate_limit reports it; nothing here has spent it
The line is written from the budget headers on every response this process has had on
that token, kept in a ledger for the life of the process, with GET /rate_limit merged
in as one more reading: the tightest window grades the resource, and the line says how
many windows there are, what the others hold, how much of the window this process
itself spent, at what rate something else is spending the token, and — once this process
has seen a window open — what it already carried at the first reading
(ADR-0007 decision 2). Its own
spend is a count of what the ledger recorded, not an
estimate, and it is bounded by the ledger's life: it is this process's share since it
first saw that window, so on a token a second instance or a pipeline also spends, the
rest of used is what the other two clauses are for. After a restart the line is the endpoint's until the first github run
refills the memory. What every clause means, and the readings the check refuses to
fake, is docs/architecture.md §4.
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), and
the foreign rate is on the line and not in the grade. warn_below and
error_below are compared with the tightest window, so a deployment that
tuned them against the endpoint's number may see the node go amber sooner — that
is the node telling the truth. The thresholds in force are rendered on the node.
Between runs most of what the github check reads has not changed, so it asks
conditionally: it keeps each read's ETag and sends If-None-Match, and GitHub answers
304 Not Modified without charging the primary rate limit — measured, not assumed. The
held answer is returned unchanged, so no reading and no line differs from what a full
read would have said. Nothing is configured, and the check's own page says how many
payloads are held and how many of a run's requests were free
(ADR-0011).
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 a release resolves it from the index, like any
other dependency: a released tree carries no [tool.uv.sources] table, and its
uv.lock names the index. Working against a library that is not on the index yet
takes a redirect to the checkout beside this one:
# pyproject.toml — while the library is unreleased, and never in a release
[tool.uv.sources]
little-sister = { path = "../little-sister", editable = true }
The redirect leaves a second trace by itself — the next uv run, and the pre-commit
gate is one, rewrites uv.lock to name the directory — and the two go together:
while the library is unreleased both may be committed, and neither may reach a
release. uv reads the sources table of a dependency it resolves from a path or a
checkout, so a released one would be imposed on every deployment that installs this
package that way; an install from the index is unaffected. The comment on the sources
table in pyproject.toml says what the window costs.
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.9
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.9.tar.gz | 117.5 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| little_sister_github-0.1.9-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 227.9 kB
Release files / little_sister_github-0.1.9.tar.gz
| Download URL | little_sister_github-0.1.9.tar.gz |
|---|---|
| Size | 117.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
c529b5ba45d569992f836633588f6a2db56cf3b08bd7339fd56839c6b4b564b4
|
|
BLAKE2b-256 checksum How to use checksums |
d58f4302e0a84701a6444e37ef3e274271beb44c65e3d778d7c0d535c69e31a0
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.12.17 {"installer":{"name":"uv","version":"0.12.17","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.9-py3-none-any.whl
| Download URL | little_sister_github-0.1.9-py3-none-any.whl |
|---|---|
| Size | 110.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
9167118fb0f88cf8ad385365bdf9e875953d38d9328423386a9ade655f602df3
|
|
BLAKE2b-256 checksum How to use checksums |
308b2d7c57d943056ef3fe99f6b1f71ebdbe079d256770a761120bed3d3134ac
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.12.17 {"installer":{"name":"uv","version":"0.12.17","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}
|