github-actions-ingester 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,522 @@
1
+ Metadata-Version: 2.5
2
+ Name: github-actions-ingester
3
+ Version: 0.1.0
4
+ Summary: Standalone GitHub Actions ingester — pulls workflows, runs and jobs from the GitHub API into PostgreSQL for Grafana dashboards, FinOps and alerting. Prometheus /metrics for ingester health and scheduled-workflow liveness.
5
+ Project-URL: Homepage, https://github.com/danielgines/github-actions-ingester
6
+ Project-URL: Repository, https://github.com/danielgines/github-actions-ingester
7
+ Project-URL: Issues, https://github.com/danielgines/github-actions-ingester/issues
8
+ Author: Daniel Gines
9
+ License: MIT License
10
+
11
+ Copyright (c) 2026 Daniel Gines
12
+
13
+ Permission is hereby granted, free of charge, to any person obtaining a copy
14
+ of this software and associated documentation files (the "Software"), to deal
15
+ in the Software without restriction, including without limitation the rights
16
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17
+ copies of the Software, and to permit persons to whom the Software is
18
+ furnished to do so, subject to the following conditions:
19
+
20
+ The above copyright notice and this permission notice shall be included in all
21
+ copies or substantial portions of the Software.
22
+
23
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29
+ SOFTWARE.
30
+ License-File: LICENSE
31
+ Keywords: ci,finops,github,github-actions,grafana,observability,postgres,prometheus
32
+ Classifier: Development Status :: 4 - Beta
33
+ Classifier: Intended Audience :: System Administrators
34
+ Classifier: License :: OSI Approved :: MIT License
35
+ Classifier: Operating System :: OS Independent
36
+ Classifier: Programming Language :: Python :: 3
37
+ Classifier: Programming Language :: Python :: 3.11
38
+ Classifier: Programming Language :: Python :: 3.12
39
+ Classifier: Programming Language :: Python :: 3.13
40
+ Classifier: Topic :: Software Development :: Build Tools
41
+ Classifier: Topic :: System :: Monitoring
42
+ Requires-Python: >=3.11
43
+ Requires-Dist: croniter>=3.0
44
+ Requires-Dist: httpx>=0.27
45
+ Requires-Dist: prometheus-client>=0.20
46
+ Requires-Dist: psycopg[binary]>=3.2
47
+ Requires-Dist: pydantic-settings>=2.2
48
+ Requires-Dist: pydantic>=2.6
49
+ Requires-Dist: pyjwt[crypto]>=2.8
50
+ Requires-Dist: pyyaml>=6.0
51
+ Requires-Dist: structlog>=24.1
52
+ Provides-Extra: dev
53
+ Requires-Dist: mypy>=1.10; extra == 'dev'
54
+ Requires-Dist: pgserver>=0.1.4; (python_version < '3.13') and extra == 'dev'
55
+ Requires-Dist: pre-commit>=3.7; extra == 'dev'
56
+ Requires-Dist: pytest-cov>=5.0; extra == 'dev'
57
+ Requires-Dist: pytest-httpx>=0.30; extra == 'dev'
58
+ Requires-Dist: pytest>=8.0; extra == 'dev'
59
+ Requires-Dist: ruff>=0.4; extra == 'dev'
60
+ Requires-Dist: types-croniter>=3.0; extra == 'dev'
61
+ Requires-Dist: types-pyyaml>=6.0; extra == 'dev'
62
+ Description-Content-Type: text/markdown
63
+
64
+ # github-actions-ingester
65
+
66
+ [![PyPI](https://img.shields.io/pypi/v/github-actions-ingester)](https://pypi.org/project/github-actions-ingester/)
67
+ [![Container](https://img.shields.io/badge/ghcr.io-github--actions--ingester-blue)](https://github.com/danielgines/github-actions-ingester/pkgs/container/github-actions-ingester)
68
+ [![Artifact Hub](https://img.shields.io/endpoint?url=https://artifacthub.io/badge/repository/github-actions-ingester)](https://artifacthub.io/packages/search?repo=github-actions-ingester)
69
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
70
+
71
+ Standalone ingester for **GitHub Actions**: a long-running service that pulls
72
+ repositories, workflows, workflow runs and jobs from the GitHub REST API into
73
+ **PostgreSQL**, and exposes **Prometheus metrics** about the ingestion itself
74
+ and about the liveness of your scheduled workflows.
75
+
76
+ - **PostgreSQL** holds the history. Query it from Grafana with the bundled
77
+ dashboard (a drop-in for the community dashboard
78
+ [24157 "GitHub Actions insights"](https://grafana.com/grafana/dashboards/24157)),
79
+ from SQL for FinOps (minutes per repository, per runner, per event),
80
+ or from anything else that speaks SQL.
81
+
82
+ - **Prometheus** (`/metrics`) answers the questions that need an alert
83
+ rather than a chart: is the ingester alive and current, is the GitHub
84
+ rate limit about to run out, and *did the nightly job actually run
85
+ last night* (per workflow, using the `on.schedule` cron read from the
86
+ workflow file).
87
+
88
+ ## Why
89
+
90
+ GitHub keeps 90 days of run history (less on some plans), the UI shows one
91
+ repository at a time, and the API caps a listing at 1 000 runs. Getting
92
+ "how long does CI take across the organization, and how much of it is
93
+ retries" out of that means paging the API on a schedule and keeping the
94
+ rows yourself. Existing solutions are either SaaS, tied to a specific
95
+ GitOps or dashboard product, or need a webhook endpoint reachable from
96
+ GitHub.
97
+
98
+ `github-actions-ingester` is the boring alternative: one container, one
99
+ PostgreSQL schema, one read-only GitHub App. No webhooks, no inbound
100
+ traffic, no state outside the database. Point Grafana's PostgreSQL
101
+ datasource at the schema and the dashboard works.
102
+
103
+ | Use case | Interface |
104
+ |---|---|
105
+ | "Show duration, success rate and queue time per workflow over the last quarter" | PostgreSQL + `examples/grafana-dashboard.json` |
106
+ | "Minutes consumed per repository / runner label this month" | PostgreSQL, one `GROUP BY` on `workflow_jobs` |
107
+ | "Page me when the nightly backup workflow did not run" | Prometheus rule on `gha_scheduled_workflow_last_run_timestamp_seconds` |
108
+ | "Page me when the ingester stopped or the API budget is nearly gone" | Prometheus rules on `gha_ingester_*` |
109
+
110
+ ## How it works
111
+
112
+ The ingester runs a **cycle** every `GHA_POLL_INTERVAL_SECONDS` (default 5
113
+ minutes):
114
+
115
+ 1. **Inventory** (refreshed every `GHA_REPO_REFRESH_SECONDS`): list the
116
+ repositories in scope (organizations and/or explicit `owner/name`,
117
+ minus exclusion globs and archived repositories) and the workflow
118
+ files of each one. When `GHA_SYNC_SCHEDULES=true` the workflow file is
119
+ read from the default branch and its `on.schedule` cron expressions
120
+ are stored, together with the *expected interval*: the longest gap
121
+ between two consecutive fires with all crons merged (for
122
+ `0 9 * * 1-5` that is 72 h, Friday to Monday, so the alert stays quiet
123
+ over the weekend).
124
+ 2. **Runs**: for each repository, list the runs created since the
125
+ repository's cursor minus `GHA_LOOKBACK_MINUTES`. The first cycle of a
126
+ repository goes back `GHA_BACKFILL_DAYS`. Windows that would exceed
127
+ GitHub's 1 000-run cap are split in half recursively, so nothing is
128
+ lost on busy repositories.
129
+ 3. **Jobs**: for every run that is new or changed since the last cycle,
130
+ fetch its jobs (`filter=all` to match GitHub billing, or `latest`).
131
+ Runs still open outside the lookback window (long queues, multi-hour
132
+ jobs) are refreshed individually, bounded by `GHA_MAX_OPEN_RUN_REFRESH`.
133
+ 4. **Persist**: upserts inside one transaction per repository, then move
134
+ the cursor. `completed_at` of a run is derived from its jobs
135
+ (`MAX(completed_at)`) or from `updated_at` once the run is completed.
136
+ 5. **Publish**: gauges for the inventory, counters for what was written,
137
+ the rate-limit headers of the last response, and one series per
138
+ scheduled workflow with its last scheduled run and last conclusion.
139
+
140
+ Pacing is client-side (`GHA_API_RATE_LIMIT_RPS`, default 5) plus the
141
+ server's own signals: `403`/`429` with rate-limit markers honour
142
+ `Retry-After` / `X-RateLimit-Reset`, `5xx` and transport errors back off
143
+ exponentially, and when the primary budget drops under
144
+ `GHA_API_MIN_REMAINING` the cycle waits for the reset instead of spending
145
+ the last requests other tools may need.
146
+
147
+ ### Schema lifecycle
148
+
149
+ On every start the ingester connects, creates the schema named by
150
+ `GHA_DATABASE_SCHEMA` if missing, and applies the migrations embedded in
151
+ the package that have not been applied yet (tracked in
152
+ `schema_migrations`, with a checksum). A fresh database is bootstrapped, an
153
+ upgrade applies only what is new, and a start on a current schema does
154
+ nothing. `github-actions-ingester migrate` does the same and exits, for
155
+ init containers or pipelines that want the schema in place before the
156
+ service starts.
157
+
158
+ ### Tables and views
159
+
160
+ | Object | Content |
161
+ |---|---|
162
+ | `repositories` | id, owner, name, default branch, archived, first/last seen |
163
+ | `workflows` | one row per workflow file: name, path, state, `schedules[]`, `schedule_interval_seconds` |
164
+ | `workflow_runs` | one row per run: event, status, conclusion, branch, actor, `created_at`, `run_started_at`, `completed_at`, attempt |
165
+ | `workflow_jobs` | one row per job: runner name / group / labels, `started_at`, `completed_at`, steps |
166
+ | `ingest_cursors` | per-repository resume point |
167
+ | `schema_migrations` | applied migrations, with checksum |
168
+ | `minion_repositories`, `minion_workflow_files`, `minion_workflow_runs`, `minion_workflow_jobs` | views with the column contract of dashboard 24157 |
169
+
170
+ Everything lives in the configured schema; the role needs `CREATE` on the
171
+ database for the first start and plain read/write afterwards. Give
172
+ Grafana a separate read-only role.
173
+
174
+ ## Quick start
175
+
176
+ ### Docker
177
+
178
+ ```bash
179
+ docker run --rm -p 9619:9619 \
180
+ -e GHA_GITHUB_TOKEN=$YOUR_TOKEN \
181
+ -e GHA_ORGS=my-org \
182
+ -e GHA_DATABASE_URL=postgresql://gha:gha@db.example:5432/gha \
183
+ ghcr.io/danielgines/github-actions-ingester:latest
184
+
185
+ curl http://localhost:9619/metrics
186
+ ```
187
+
188
+ ### Docker Compose (PostgreSQL + ingester + Grafana with the dashboard)
189
+
190
+ ```bash
191
+ cd examples
192
+ GHA_GITHUB_TOKEN=$YOUR_TOKEN GHA_ORGS=my-org docker compose up -d
193
+ # Grafana on :3000 (admin/admin) → "GitHub Actions insights"
194
+ ```
195
+
196
+ ### pip
197
+
198
+ ```bash
199
+ pip install github-actions-ingester
200
+ GHA_GITHUB_TOKEN=$YOUR_TOKEN GHA_ORGS=my-org \
201
+ GHA_DATABASE_URL=postgresql://gha:gha@localhost:5432/gha \
202
+ github-actions-ingester run
203
+ ```
204
+
205
+ ### Helm (Kubernetes)
206
+
207
+ ```bash
208
+ helm install github-actions-ingester \
209
+ oci://ghcr.io/danielgines/charts/github-actions-ingester \
210
+ --namespace github-actions-ingester --create-namespace \
211
+ --set auth.token=$YOUR_TOKEN \
212
+ --set database.url=postgresql://gha:gha@postgres.db.svc:5432/gha \
213
+ --set config.orgs=my-org \
214
+ --set serviceMonitor.enabled=true \
215
+ --set prometheusRule.enabled=true
216
+ ```
217
+
218
+ For production, keep credentials out of values: reference existing
219
+ Secrets (fed by External Secrets Operator, CloudNativePG, sealed-secrets,
220
+ whatever you already run):
221
+
222
+ ```bash
223
+ helm install github-actions-ingester \
224
+ oci://ghcr.io/danielgines/charts/github-actions-ingester \
225
+ --set auth.existingSecret=github-actions-ingester-github \
226
+ --set database.existingSecret=gha-db-app \
227
+ --set database.existingSecretKey=uri \
228
+ --set config.orgs=my-org
229
+ ```
230
+
231
+ Full chart reference in [`helm/github-actions-ingester/README.md`](helm/github-actions-ingester/README.md).
232
+
233
+ ## GitHub credential
234
+
235
+ Two shapes are accepted; set exactly one.
236
+
237
+ **GitHub App (recommended).** Least privilege, no human account in the
238
+ loop, tokens rotate hourly on their own. Create the App inside your own
239
+ organization in one click from the manifest page in
240
+ [`examples/github-app/`](examples/github-app/README.md) (GitHub generates
241
+ the private key and hands it only to you), install it on the repositories
242
+ you want, and set:
243
+
244
+ ```bash
245
+ GHA_GITHUB_APP_ID=123456
246
+ GHA_GITHUB_APP_PRIVATE_KEY_FILE=/secrets/github-app.pem # or GHA_GITHUB_APP_PRIVATE_KEY inline
247
+ GHA_ORGS=my-org
248
+ ```
249
+
250
+ Permissions: **Actions: read**, **Metadata: read**, and **Contents: read**
251
+ (only for `on.schedule` discovery; set `GHA_SYNC_SCHEDULES=false` to drop
252
+ it). The installation is discovered automatically.
253
+
254
+ **Personal access token.** Fine for a first look. A fine-grained token
255
+ needs *Actions: read* and *Metadata: read* (plus *Contents: read* for
256
+ schedules) on the repositories in scope; a classic token needs `repo`
257
+ for private repositories.
258
+
259
+ `github-actions-ingester check` validates the credential, lists what it
260
+ can see and prints the remaining rate limit before you commit to a
261
+ backfill.
262
+
263
+ ## Configuration
264
+
265
+ All settings are env vars prefixed `GHA_` (a `.env` in the working
266
+ directory is read too; see [`.env.example`](.env.example)).
267
+
268
+ | Variable | Default | Description |
269
+ |---|---|---|
270
+ | `GHA_GITHUB_TOKEN` | `""` | Personal access token. Mutually exclusive with the App settings |
271
+ | `GHA_GITHUB_APP_ID` | `""` | GitHub App ID (or client ID) |
272
+ | `GHA_GITHUB_APP_PRIVATE_KEY` | `""` | PEM private key of the App; literal newlines or `\n` escapes |
273
+ | `GHA_GITHUB_APP_PRIVATE_KEY_FILE` | `""` | Path to the PEM file (wins over the inline value) |
274
+ | `GHA_GITHUB_APP_INSTALLATION_ID` | `""` | Installation to use; auto-discovered when empty |
275
+ | `GHA_GITHUB_API_BASE` | `https://api.github.com` | `https://ghe.example.com/api/v3` for GitHub Enterprise Server |
276
+ | `GHA_ORGS` | `""` | Comma-separated organizations (or user accounts) to ingest |
277
+ | `GHA_REPOS` | `""` | Comma-separated explicit `owner/name`, union with `GHA_ORGS` |
278
+ | `GHA_EXCLUDE_REPOS` | `""` | Comma-separated globs on `owner/name`, case-insensitive |
279
+ | `GHA_INCLUDE_ARCHIVED` | `false` | Also ingest archived repositories |
280
+ | `GHA_DATABASE_URL` | _required_ | libpq URL, e.g. `postgresql://user:pass@host:5432/db?sslmode=require` |
281
+ | `GHA_DATABASE_SCHEMA` | `gha` | Schema holding every table; created on first start |
282
+ | `GHA_DATABASE_CONNECT_TIMEOUT_SECONDS` | `10` | Connection timeout |
283
+ | `GHA_POLL_INTERVAL_SECONDS` | `300` | Seconds between cycles (min 30) |
284
+ | `GHA_BACKFILL_DAYS` | `30` | How far back the first cycle of a repository goes (1..3660) |
285
+ | `GHA_LOOKBACK_MINUTES` | `180` | Window before the cursor re-listed every cycle to catch status changes |
286
+ | `GHA_REPO_REFRESH_SECONDS` | `3600` | Repository / workflow inventory refresh (min 60) |
287
+ | `GHA_MAX_OPEN_RUN_REFRESH` | `200` | Per-cycle cap on `GET /runs/{id}` for stale open runs; 0 disables |
288
+ | `GHA_JOBS_FILTER` | `all` | `all` = jobs from every attempt (matches billing); `latest` = last attempt only |
289
+ | `GHA_SYNC_SCHEDULES` | `true` | Read workflow files for `on.schedule` (needs Contents: read) |
290
+ | `GHA_SCHEDULE_REFRESH_SECONDS` | `21600` | How often workflow files are re-read (min 300) |
291
+ | `GHA_API_RATE_LIMIT_RPS` | `5.0` | Client-side pacing toward the GitHub API |
292
+ | `GHA_API_MIN_REMAINING` | `200` | Pause until reset when the primary budget drops under this |
293
+ | `GHA_API_TIMEOUT_SECONDS` | `30` | Per-request timeout |
294
+ | `GHA_API_MAX_RETRIES` | `4` | Retries on 5xx / transport errors / secondary limits |
295
+ | `GHA_LISTEN_HOST` | `0.0.0.0` | HTTP bind address |
296
+ | `GHA_LISTEN_PORT` | `9619` | HTTP bind port |
297
+ | `GHA_LOG_LEVEL` | `info` | `debug` / `info` / `warning` / `error` |
298
+ | `GHA_LOG_FORMAT` | `json` | `json` (production) / `console` (dev) |
299
+
300
+ ### API budget
301
+
302
+ A cycle costs roughly **1 request per repository** (the runs listing;
303
+ more only when the window holds more than 100 runs) **plus 1 per run
304
+ that changed** since the previous cycle (its jobs). The inventory adds
305
+ one request per organization page and one per repository (workflows),
306
+ once an hour by default, and schedule discovery one request per
307
+ workflow file every six hours. With 50 repositories and 500 runs a day,
308
+ that is well under 10 % of the 5 000 req/h an App installation gets.
309
+ The backfill is the expensive part: 30 days of a busy repository can be
310
+ a few thousand requests, spread across cycles by the pacing.
311
+
312
+ ## CLI
313
+
314
+ ```
315
+ github-actions-ingester run [--once] # the service (default). --once: one cycle and exit
316
+ github-actions-ingester migrate # bootstrap / upgrade the schema and exit
317
+ github-actions-ingester check # validate config, credential, scope and database
318
+ github-actions-ingester app-manifest # print the GitHub App manifest (--org, --redirect-url)
319
+ github-actions-ingester app-convert CODE --key-file github-app.pem
320
+ # exchange a manifest code for App ID + private key
321
+ ```
322
+
323
+ Exit codes: `0` ok, `1` runtime failure (`check` found a problem, `run
324
+ --once` could not bootstrap), `2` invalid configuration.
325
+
326
+ ## Endpoints
327
+
328
+ | Path | Purpose |
329
+ |---|---|
330
+ | `/metrics` | Prometheus text format |
331
+ | `/healthz` | liveness: the HTTP server is up (`200 ok`) |
332
+ | `/readyz` | readiness: `503` until the schema is bootstrapped and the first cycle finished, then `200 ready` |
333
+ | `/` | index |
334
+
335
+ ## Metrics
336
+
337
+ Ingester health (`gha_ingester_*`):
338
+
339
+ | Metric | Type | Labels | Description |
340
+ |---|---|---|---|
341
+ | `gha_ingester_build_info` | info | `version` | build information |
342
+ | `gha_ingester_up` | gauge | | 1 when the last cycle finished without a fatal error |
343
+ | `gha_ingester_ready` | gauge | | 1 once the schema is bootstrapped and the first cycle completed |
344
+ | `gha_ingester_cycles_total` | counter | `result` (`ok`/`partial`/`error`) | cycles by outcome |
345
+ | `gha_ingester_cycle_duration_seconds` | histogram | | wall-clock duration of a cycle |
346
+ | `gha_ingester_last_cycle_timestamp_seconds` | gauge | | end of the last cycle, any outcome |
347
+ | `gha_ingester_last_success_timestamp_seconds` | gauge | | end of the last successful cycle |
348
+ | `gha_ingester_errors_total` | counter | `stage` | errors by stage |
349
+ | `gha_ingester_github_requests_total` | counter | `status` | requests to the GitHub API by HTTP status |
350
+ | `gha_ingester_github_rate_limit_remaining` | gauge | | `X-RateLimit-Remaining` of the last response |
351
+ | `gha_ingester_github_rate_limit_limit` | gauge | | `X-RateLimit-Limit` |
352
+ | `gha_ingester_github_rate_limit_reset_timestamp_seconds` | gauge | | when the primary window resets |
353
+ | `gha_ingester_repositories` | gauge | | repositories in scope after exclusions |
354
+ | `gha_ingester_workflows` | gauge | | workflow files known |
355
+ | `gha_ingester_stored_runs` / `_stored_jobs` | gauge | | rows in the database |
356
+ | `gha_ingester_open_runs` | gauge | | stored runs not yet completed |
357
+ | `gha_ingester_runs_upserted_total` / `_jobs_upserted_total` | counter | `repository` | rows written |
358
+ | `gha_ingester_repository_cycle_duration_seconds` | histogram | | time per repository within a cycle |
359
+
360
+ Scheduled-workflow liveness (`gha_scheduled_*`, labels `repository`,
361
+ `workflow`, `workflow_name`):
362
+
363
+ | Metric | Description |
364
+ |---|---|
365
+ | `gha_scheduled_workflows` | active workflows declaring at least one cron |
366
+ | `gha_scheduled_workflow_interval_seconds` | expected interval: longest gap between consecutive fires, all crons merged |
367
+ | `gha_scheduled_workflow_last_run_timestamp_seconds` | most recent run triggered by `schedule` (0 = never observed) |
368
+ | `gha_scheduled_workflow_last_conclusion{conclusion}` | 1 for the conclusion of the last completed scheduled run |
369
+
370
+ ## Grafana dashboard
371
+
372
+ [`examples/grafana-dashboard.json`](examples/grafana-dashboard.json) is
373
+ dashboard 24157 "GitHub Actions insights" made provisionable (no
374
+ `__inputs`, fixed uid `github-actions-insights`, datasource picked by the
375
+ `datasource` variable). It queries the `minion_*` views, so it works on
376
+ this ingester's schema unchanged: success rate, duration percentiles,
377
+ queue time, runs by event and conclusion, per-repository rows, and job
378
+ level detail, with an `aggregation` interval variable from 1h to 1M.
379
+
380
+ Provisioning files for both the PostgreSQL datasource and the dashboard
381
+ are in [`examples/grafana-provisioning/`](examples/grafana-provisioning/);
382
+ the Compose stack above mounts them. In Kubernetes, ship the JSON in a
383
+ ConfigMap with the label your Grafana sidecar watches.
384
+
385
+ Every panel is exercised in the test suite against a seeded database
386
+ (all repositories × every aggregation, single-repository rows, display
387
+ toggles), so a schema change that would break the dashboard fails CI.
388
+
389
+ ## Sample alert rules
390
+
391
+ The chart renders these when `prometheusRule.enabled=true`; the raw
392
+ manifest is in [`examples/kubernetes/prometheusrule.yaml`](examples/kubernetes/prometheusrule.yaml).
393
+
394
+ ```yaml
395
+ - alert: GitHubActionsIngesterDown
396
+ expr: gha_ingester_up == 0
397
+ for: 15m
398
+ - alert: GitHubActionsIngesterStale
399
+ expr: time() - gha_ingester_last_success_timestamp_seconds > 1800
400
+ for: 5m
401
+ - alert: GitHubActionsIngesterRateLimitLow
402
+ expr: gha_ingester_github_rate_limit_remaining < 300
403
+ for: 10m
404
+ - alert: GitHubScheduledWorkflowStopped
405
+ expr: |
406
+ (time() - gha_scheduled_workflow_last_run_timestamp_seconds)
407
+ > 2 * gha_scheduled_workflow_interval_seconds + 1800
408
+ and gha_scheduled_workflow_last_run_timestamp_seconds > 0
409
+ for: 10m
410
+ - alert: GitHubScheduledWorkflowNeverRan
411
+ expr: gha_scheduled_workflow_last_run_timestamp_seconds == 0 and gha_scheduled_workflow_interval_seconds > 0
412
+ for: 6h
413
+ - alert: GitHubScheduledWorkflowFailing
414
+ expr: gha_scheduled_workflow_last_conclusion{conclusion="failure"} == 1
415
+ for: 30m
416
+ ```
417
+
418
+ ## Limitations
419
+
420
+ - **Single writer.** One replica per schema. Two ingesters on the same
421
+ schema would double the API spend and fight over the cursors; the chart
422
+ pins `replicaCount: 1` and uses `Recreate`.
423
+ - **No webhooks, so latency is the poll interval.** A run appears at most
424
+ `GHA_POLL_INTERVAL_SECONDS` after it is created and its final status at
425
+ most one interval after it finishes.
426
+ - **History starts at the backfill.** GitHub only serves what it still
427
+ keeps (90 days on most plans); the ingester cannot recover older runs.
428
+ Raising `GHA_BACKFILL_DAYS` later only affects repositories without a
429
+ cursor.
430
+ - **Job-level billing is approximated.** Minutes are computed from
431
+ `started_at`/`completed_at`; GitHub rounds per job and applies OS
432
+ multipliers. Good enough to rank repositories, not to reconcile an
433
+ invoice.
434
+ - **Schedule discovery reads the default branch only.** A cron that exists
435
+ on another branch is not tracked.
436
+ - **Deleted runs stay.** GitHub does not report deletions; a run removed
437
+ from GitHub keeps its last known state in the database.
438
+ - **Requests are sequential.** The client issues one request at a time
439
+ and the jobs endpoint is called once per run, so the first cycle is
440
+ bound by round-trip latency rather than by `GHA_API_RATE_LIMIT_RPS`
441
+ (about 2 requests/s in practice, or roughly 8 minutes per 1000 runs).
442
+ Readiness turns green only after that first cycle, so `helm install
443
+ --wait` can time out on a large backfill while the pod is perfectly
444
+ fine; watch the logs for `cycle.done` instead, or raise `--timeout`.
445
+
446
+ ## Development
447
+
448
+ ```bash
449
+ git clone https://github.com/danielgines/github-actions-ingester.git
450
+ cd github-actions-ingester
451
+
452
+ just install # dev deps + pre-commit hooks
453
+ just ci # ruff + mypy + pytest + helm lint + helm template
454
+ just test # pytest only (integration tests start an embedded PostgreSQL)
455
+ just run-once # one cycle against the .env configuration
456
+ just compose-up # local stack: PostgreSQL + ingester + Grafana
457
+ just # all recipes
458
+ ```
459
+
460
+ Tests need no external database on Python 3.11/3.12: `pgserver` starts a
461
+ throwaway PostgreSQL in a temporary directory. On 3.13 (no `pgserver`
462
+ wheel yet) or to use an existing server, set `GHA_TEST_DATABASE_URL`;
463
+ integration tests are skipped when neither is available. GitHub traffic is mocked with `pytest-httpx`; the
464
+ CLI tests run `migrate`, `check`, `run --once` and the service mode
465
+ end-to-end against the mock API.
466
+
467
+ Pre-commit runs ruff, mypy, yamllint, hadolint, actionlint, gitleaks and
468
+ helm validation on every commit, and the full pytest suite on every push.
469
+ The same checks run in CI as `just ci`.
470
+
471
+ ## Releasing
472
+
473
+ `pyproject.toml` is the canonical version source. `__init__.py` and
474
+ `Chart.yaml` (`version` + `appVersion`) are synced from it by
475
+ `scripts/sync-version.py`, enforced as a pre-commit hook.
476
+
477
+ 1. Add a `## [X.Y.Z]` section to `CHANGELOG.md`
478
+ 2. `just release X.Y.Z` (clean tree on `main`, bumps, syncs, commits
479
+ `chore(release): vX.Y.Z`)
480
+ 3. `git push origin main`
481
+ 4. CI succeeds → `release.yaml` builds the linux/amd64 + linux/arm64 image
482
+ with SBOM and provenance, pushes
483
+ `ghcr.io/<owner>/github-actions-ingester:X.Y.Z` (+ semver + `latest`),
484
+ Trivy-scans it (fails on unfixed CRITICAL), signs it with cosign
485
+ keyless, pushes the chart to
486
+ `oci://ghcr.io/<owner>/charts/github-actions-ingester`, publishes the
487
+ sdist and wheel to [PyPI](https://pypi.org/project/github-actions-ingester/),
488
+ tags `vX.Y.Z` and creates the GitHub Release.
489
+
490
+ No PAT and no PyPI token: everything happens inside one workflow run, and
491
+ PyPI accepts the upload through [trusted
492
+ publishing](https://docs.pypi.org/trusted-publishers/) (OIDC). The
493
+ one-time setup for a fork is to register the trusted publisher on
494
+ pypi.org with owner `<owner>`, repository `github-actions-ingester`,
495
+ workflow `release.yaml` and environment `pypi`; do it as a pending
496
+ publisher before the first release so the first upload claims the
497
+ project name. The release workflow also accepts `workflow_dispatch` with
498
+ a `version` input and `push: tags ["v*"]`.
499
+
500
+ ```bash
501
+ cosign verify ghcr.io/<owner>/github-actions-ingester@<digest> \
502
+ --certificate-identity-regexp 'https://github\.com/<owner>/github-actions-ingester/.*' \
503
+ --certificate-oidc-issuer https://token.actions.githubusercontent.com
504
+ ```
505
+
506
+ ### ArtifactHub indexing
507
+
508
+ ArtifactHub treats each chart as its own repository, so registration is
509
+ per chart with the full OCI URL:
510
+
511
+ 1. Sign in at https://artifacthub.io with GitHub
512
+ 2. Add a Helm OCI repository pointing to
513
+ `oci://ghcr.io/<owner>/charts/github-actions-ingester`
514
+ 3. Copy the assigned `repositoryID` into `helm/artifacthub-repo.yml`
515
+ 4. Commit, push, and run `just artifacthub-publish` (or the
516
+ `artifacthub-publish` workflow)
517
+
518
+ Every later chart release is indexed on ArtifactHub's next scrape.
519
+
520
+ ## License
521
+
522
+ [MIT](LICENSE) — © 2026 Daniel Gines.
@@ -0,0 +1,17 @@
1
+ github_actions_ingester/__init__.py,sha256=TTADWvGAm_J0wyW-3NQ3uoSyzhcWcuYDOczskoZkHrQ,317
2
+ github_actions_ingester/__main__.py,sha256=kW9kvgXtI90DxkN6s87khXHS_kxh9oiOYT-n8MJDLM4,11593
3
+ github_actions_ingester/app_manifest.py,sha256=ExHyJC-zNs_BYBkOtjOaFIZsTBPk455AId_AjW6WWyA,3177
4
+ github_actions_ingester/collector.py,sha256=BhypN2mzMdWe7afkLrHfxgfCFw-OlxoZzf8IUTvZY3c,13130
5
+ github_actions_ingester/config.py,sha256=1OQu8KO-pH6vCF0nyQPvZ1dZJNeM8-xXrCDtW_UpolY,11066
6
+ github_actions_ingester/github.py,sha256=iFbN4fMJ4xxy2sZdMelShVUvf1V7lg_ULLuIViref5o,21578
7
+ github_actions_ingester/metrics.py,sha256=OA4B-wEj2eVcmUMTuoo_zU5RUtwTjgp0mvG_ZP1DZ7I,7081
8
+ github_actions_ingester/ratelimit.py,sha256=PgnswOLrkZeXY4k099r7oU2tWwGyx3qvi9cYTLo6Tis,1298
9
+ github_actions_ingester/server.py,sha256=JGIHdxQ4mDqsqql0miTtEv-CTyzar9fPdEaHXV-Uefs,4114
10
+ github_actions_ingester/store.py,sha256=Q5qaNgHIKLuh88J4nRmDIS_vWc2O43_TTlG24Y0Lmi4,22775
11
+ github_actions_ingester/workflow_schedule.py,sha256=PCmObqYeKtyp6nP6Tdw0ZMXl9AMw1zRdQN0v1Xx-oeI,2656
12
+ github_actions_ingester/migrations/0001_initial.sql,sha256=PB81LItJ0XI7zQU1smIM-WYFKOMtp6SOSFwAQ9paYe8,5449
13
+ github_actions_ingester-0.1.0.dist-info/METADATA,sha256=z3e7G1JLd9Rq68IQULue5eiRoDrNAL2AjEb2OLiAfMU,25870
14
+ github_actions_ingester-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
15
+ github_actions_ingester-0.1.0.dist-info/entry_points.txt,sha256=E-0yhSxqnvJvds4wYcm6_RtCBgIsGDrRJMg-xnceKQ0,82
16
+ github_actions_ingester-0.1.0.dist-info/licenses/LICENSE,sha256=70ypun7z1FHfwr1u2UOzrV6FseZYS-td_v52LiOBOx8,1069
17
+ github_actions_ingester-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ github-actions-ingester = github_actions_ingester.__main__:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Daniel Gines
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.