Operations¶
Probes¶
| Path | Question | Check | On failure |
|---|---|---|---|
/livez |
is the process alive | none (zero I/O) | restart the container |
/readyz |
can this instance serve | one database round trip | drain it, do not restart |
/health, /healthz |
aliases of /readyz |
same | same |
Both carry the build stamp (build.revision, build.number, built_at), which is
the one thing that answers "is the deployment the commit I pushed?" — version
is a source constant and cannot tell two images apart. A null build means the
image was not stamped (locally built), not that it is old.
Stop, restart, upgrade¶
| Action | Containers | init runs again | Data |
|---|---|---|---|
stop / start |
kept | yes, skips when tables exist | unchanged |
restart web |
kept | no (INITDB=skip) |
unchanged |
down → up -d |
recreated | yes, skips | unchanged (bundled keeps its volume) |
down -v |
deleted | yes, creates for real | data lost |
Upgrading:
docker compose --env-file docker/stack.env pull
docker compose --env-file docker/stack.env up -d
docker compose --env-file docker/stack.env ps
curl -k https://127.0.0.1:18080/readyz
A schema change means restarting web and mcp: the pool runs server-side
prepared statements, and DDL from another process leaves cached plans that fail
with cached plan must not change result type until those connections are
replaced. up -d recreates the containers and is not affected.
Rebuilding the image is always manual¶
A push to main runs the tests (test.yml) and never starts images.yml:
publishing a multi-arch image is a deliberate act, not a side effect of merging.
When a new image is needed -- after a change to src/**, Dockerfile,
docker/**, pyproject.toml, or after a sibling library merged, because the
image pins the siblings at build time with pip install git+...@<ref>:
gh workflow run images.yml --ref main
gh run watch # multi-arch builds take roughly 7-8 minutes
docker compose --env-file docker/stack.env pull
docker compose --env-file docker/stack.env up -d
Two registries, one build, one tag¶
images.yml pushes the same multi-arch build to both destinations, tagged
latest — one manifest, so the two latest tags are the same bytes:
| Destination | Image | Secrets (Settings -> Secrets and variables -> Actions) |
|---|---|---|
| Aliyun | registry.cn-shanghai.aliyuncs.com/vistart_public/github-actions-tracker |
ALIYUN_REGISTRY_USERNAME / ALIYUN_REGISTRY_PASSWORD |
| Docker Hub | docker.io/vistart/github-actions-tracker |
DOCKERHUB_REGISTRY_USERNAME / DOCKERHUB_REGISTRY_PASSWORD |
Each destination is independent: a missing pair is skipped (the run summary
says which), and the other still publishes. With neither pair present the
workflow still builds, so a build failure remains a failure, but pushes nothing.
A run on a branch or a tag publishes nothing and only proves the build --
feature code must not become what production pulls. A dispatch at a commit is
honoured, which is how a rollback works (gh workflow run images.yml --ref
<older-sha>): that commit's latest becomes what production pulls again.
"Which build is running" no longer comes from the tag — it comes from
build.revision, which /readyz and ci_status report from inside the
container, and from the stamp the workflow bakes in at build time. Pinning a
deployment still works: point GHAT_IMAGE at a registry address and let
build.revision say what you pulled.
For a local check no registry is involved:
GHAT_IMAGE=github-actions-tracker:local docker compose build, then
GHAT_IMAGE=github-actions-tracker:local docker compose up -d.
Replacing a PAT¶
- Edit the token file the compose stack mounts read-only (or the
GHAT_BOOTSTRAP_PATvalue). - Re-run init:
docker compose --env-file docker/stack.env run --rm -e GHAT_INITDB=force init.
Prefer GHAT_BOOTSTRAP_PAT_FILE over _PAT: an inline value is visible in
docker inspect, and a leaked token should be revoked at GitHub immediately.
Legacy / manual mode¶
docker compose --env-file docker/stack.env run --rm -e GHAT_INITDB=skip init
docker compose exec web rhosocial-github-actions-tracker-db init # tables only, idempotent
docker compose exec web rhosocial-github-actions-tracker credential pat ensure --name work --token <PAT>
Database maintenance¶
docker compose exec web rhosocial-github-actions-tracker-db is-initialized
docker compose exec web rhosocial-github-actions-tracker-db stats
docker compose exec web rhosocial-github-actions-tracker-db usage
docker compose exec web rhosocial-github-actions-tracker-db usage --json
docker compose exec web rhosocial-github-actions-tracker-db cleanup --runs-before 90d --logs-before 365d
docker compose exec web rhosocial-github-actions-tracker-db partitions --list
docker compose exec web rhosocial-github-actions-tracker-db partitions --ensure 2
docker compose exec web rhosocial-github-actions-tracker-db partitions --drop-before 6m # dry run
docker compose exec web rhosocial-github-actions-tracker-db partitions --drop-before 6m --drop-old
docker compose exec web rhosocial-github-actions-tracker-db deliveries --unhandled --limit 20
docker compose exec web rhosocial-github-actions-tracker-db cleanup --deliveries-before 30d
docker compose exec web rhosocial-github-actions-tracker-db reset --yes
docker compose exec web rhosocial-github-actions-tracker-db drop --yes
stats counts rows; usage weighs the database. They answer different
questions and the answers live in different places: a row count cannot be
turned into bytes, because PostgreSQL measures storage the engine's way (TOAST,
indexes, free space) and the introspector is the only party that knows it. Row
counts are the planner's estimate rather than a COUNT(*), so a table nobody
has analyzed since it was created prints no count at all — an absent number,
never a zero that reads as "this table is empty". --json emits the same
report the ci_status.storage field carries.
init also enables pg_trgm (CREATE EXTENSION IF NOT EXISTS pg_trgm).
A cluster ships the extension as available and a database uses it only once
it is installed: those are two different lists, and the gap between them is
where a deployment fails with operator error: type trigram does not exist
instead of degrading. Enabling is idempotent and never blocks a boot — a server
that refuses the extension (a managed plan without it, a privilege we lack)
still gets its tables, and usage reports trgm_installed: false so the
missing accelerator is a visible state rather than a surprise.
partitions is the other half of capacity: the two log tables are partitioned by
the UTC month their job finished, so a month is a unit. --ensure N creates
this month and the next N (a partitioned table starts with no partitions and
refuses a write it cannot place, so this is what keeps the first capture of every
month working); --drop-before 6m names every month wholly before the cutoff and
-- dry by default, as dropping is irreversible -- needs --drop-old to act. Both
tables are always handled as a pair, in one transaction, because the capture path
writes both and a month that exists on one table only is the state where the
write has already failed. --list shows that pair-wise view, including the
asymmetries.
Order matters when reclaiming space: drop partitions first (cheap, one metadata
operation), then consider cleanup --runs-before for the run rows themselves.
cleanup --logs-before still works but is deprecated on log tables -- it deletes
rows a partition would have dropped whole, and leaves the space for a vacuum.
Remember: PostgreSQL does not return space to the filesystem on DELETE alone.
With the log tables partitioned, dropping a month is one DROP TABLE and needs
no vacuum at all.
Runtime proxy (mainland China only)¶
The image carries no proxy configuration. A deployment in mainland China injects it at runtime:
services:
web:
environment:
HTTP_PROXY: http://proxy:7897
HTTPS_PROXY: http://proxy:7897
NO_PROXY: 127.0.0.1,localhost,::1
Verify¶
curl -fsS https://<host>:18080/readyz # one database round trip; 503 + checks when it fails
curl -fsS https://<host>:18080/livez # the process, no database
docker compose logs -f init web mcp
docker compose exec web rhosocial-github-actions-tracker credential pat list
A question asked through an MCP client is one more probe: ci_status answers
about the service, the database and the pool without spending a GitHub call.
The delivery ledger, and the push channel's health¶
Every delivery that reaches the receiver writes one row to
ghat_webhook_delivery — processed, ignored, or rejected for a bad signature,
it lands in the same table. workflow_run and workflow_job are two
independent streams on GitHub's side (different payloads, different lifetimes),
so the ledger carries an event_type and reports by stream. This is what
answers "is the push channel alive?" — and it answers it in three
distinguishable ways, which is the part worth knowing before there is a problem:
| what you see | what it means | what to do |
|---|---|---|
| no new rows at all | the request never reached the receiver | the URL, or the router's port mapping |
secret_not_configured |
it arrived for a repository that has no secret | webhook rotate-secret --name owner/repo |
signature_failed |
it arrived, and that repository's secret does not match | rotate it again, or fix the value in GitHub |
processed |
the channel works end to end | nothing |
# what the push channel has received lately
docker compose exec web rhosocial-github-actions-tracker-db stats
ci_status's health field is the same answer as a structure, with names a
caller can branch on:
{
"ledger_available": true,
"runs_tracked": 12,
"last_webhook_at": "2026-11-05T07:37:33+00:00",
"webhooks_in_last_hour": 13,
"push_channel": "active",
"alerts": [],
"streams": {
"workflow_run": {"last_at": "...", "in_last_hour": 4, "deliveries_total": 4,
"redelivered": 0, "unhandled": 0, "unhandled_reasons": {},
"write_failures": 0, "last_write_failure": null},
"workflow_job": {"last_at": "...", "in_last_hour": 9, "deliveries_total": 9,
"redelivered": 0, "unhandled": 0, "unhandled_reasons": {},
"write_failures": 0, "last_write_failure": null}
}
}
(The numbers are one live window, not a target: counters are process-local, so they restart with the container.)
Reading it:
last_atofnullmeans nothing has arrived. That is normal for a fresh deployment and for a repository nobody has run anything in; a default route that treats it as a fault will page on a quiet repository.redeliveredcounts a second arrival of the sameX-GitHub-Deliveryvalue — someone pressed "Redeliver", or GitHub retried. Redelivery is idempotent by design: the same delivery applied twice leaves the same rows, so it costs time, never a double count.unhandledcounts arrivals that changed no state: an unsubscribed repository, aping, an event recorded but not acted on. Non-zero is not a fault, but a number that keeps growing usually means the subscription scope and the delivery scope disagree.unhandled_reasonsis the breakdown of the last 24 hours;unhandledalone is a single number with nothing to act on.write_failuresandalertsare the ones to act on.ignored/unsupported_event/invalid_json/signature_failedare "declined on purpose" and belong inunhandled_reasons. A failure is different: anIntegrityError, or a delivery that never reached the database at all, means a run, a job or a step is missing from the rows. The next read repairs it — GitHub is the source of truth — so this is an alert about the push channel, not data loss.