Polls the GitHub API for repositories with a non-empty ## Unreleased block in CHANGELOG.md and publishes a CreateTaskCommand to Kafka per affected repo so the github-releaser-agent picks each up and cuts the release autonomously.
The watcher is the producer half of the pipeline. It never modifies the target repo — no commit, no tag, no push. The agent owns release execution.
Dev: https://dev.quant.benjamin-borbe.de/admin/maintainer-watcher-github-release/setloglevel/3 https://dev.quant.benjamin-borbe.de/admin/maintainer-watcher-github-release/metrics https://dev.quant.benjamin-borbe.de/admin/maintainer-watcher-github-release/trigger
Prod: https://prod.quant.benjamin-borbe.de/admin/maintainer-watcher-github-release/setloglevel/3 https://prod.quant.benjamin-borbe.de/admin/maintainer-watcher-github-release/metrics https://prod.quant.benjamin-borbe.de/admin/maintainer-watcher-github-release/trigger
On each poll cycle:
- List repos under
OWNER(non-archived, non-fork). - For each repo in scope (filtered against
REPO_ALLOWLIST):- Fetch master HEAD SHA.
- Fetch
CHANGELOG.md; parse## Unreleasedbullet count + first-section flag + latest version header. - Fetch
.maintainer.yamlto readrelease.autoRelease; the watcher proceeds only when this istrue(trust gate — repos without the file are skipped).
- Apply filter chain (skip if ANY votes skip):
RepoAllowlistFilter— host-qualified scope filterEmptyUnreleasedFilter— skip repos whose## Unreleasedhas zero bulletsAutoReleaseFilter— gate; passes only when.maintainer.yaml: release.autoRelease: true, skips every other shape (file absent, key absent, false)SHAUnchangedFilter— skip if cursor already records this master HEAD (cursor-aware, composed per-cycle)
- Publish
CreateTaskCommandper non-skipped repo. - Save cursor at
/data/cursor.json(per-repoLastSeenMasterSHA, atomic temp+rename).
Cycle abort (no cursor save) on GitHub 5xx, rate limit, or ListRepos failure — next cycle resumes from the same cursor.
Per the agent task-file contract — every emitted CreateTaskCommand carries this frontmatter shape:
task_type: github-release
assignee: github-releaser-agent
phase: planning
status: in_progress
stage: dev|prod
task_identifier: <UUID5(owner, repo, head_sha)>
title: Release <owner>-<repo> <sha[:7]>
repo: owner/name
clone_url: git@github.com:owner/name.git
ref: <full HEAD SHA>
current_version: vX.Y.Z # or v0.0.0 if no prior releaseThe title field uses a dash between owner+repo (not slash) — the controller's CreateCommand validator rejects / in titles.
Body is an operator-readable header only (title + version + HEAD + changelog URL + repo link). The downstream agent clones the repo at ref and reads CHANGELOG itself — never parses the body.
| Variable | Required | Default | Description |
|---|---|---|---|
OWNER |
yes | — | GitHub owner / org to scan (e.g. bborbe) |
KAFKA_BROKERS |
yes | — | Comma-separated Kafka broker list |
STAGE |
yes | — | Deployment stage (dev or prod) |
APP_ID |
no | — | GitHub App ID (set all three of APP_ID + INSTALLATION_ID + PEM_KEY for App auth) |
INSTALLATION_ID |
no | — | GitHub App Installation ID |
PEM_KEY |
no | — | GitHub App private key (PEM content from k8s Secret envFrom — see teamvault-conventions.md) |
GH_TOKEN |
no | — | Legacy PAT fallback when App credentials are absent |
LISTEN |
no | :9090 |
HTTP listen address (/healthz, /readiness, /metrics) |
POLL_INTERVAL |
no | 10m |
Poll interval (Go duration string) |
REPO_ALLOWLIST |
no | — | Comma-separated host-qualified repo allowlist (host/owner/repo); empty = allow-all within OWNER |
CURSOR_PATH |
no | /data/cursor.json |
Cursor persistence path (PVC mount) |
SENTRY_DSN |
no | — | Sentry DSN for error tracking |
SENTRY_PROXY |
no | — | HTTP proxy URL for Sentry transport |
Entries are comma-separated. A leading ! marks an exclusion. A target is allowed iff (includes is empty OR any include matches) AND (no exclude matches); excludes always override includes.
| Entry shape | Example | Meaning |
|---|---|---|
| Literal include | github.com/bborbe/maintainer |
Allow exactly this repo |
| Wildcard include | github.com/bborbe/* |
Allow every repo under this owner |
| Literal exclude | !github.com/bborbe/go-skeleton |
Reject exactly this repo (overrides any matching include) |
| Wildcard exclude | !github.com/bborbe/* |
Reject every repo under this owner |
An allowlist consisting of only exclude entries is treated as allow-all-except: every target passes the include gate, and only the exclude gate filters. Example: REPO_ALLOWLIST=!github.com/bborbe/go-skeleton rejects go-skeleton and allows every other repo (including all other bborbe repos). To allow every bborbe repo except go-skeleton, write github.com/bborbe/*,!github.com/bborbe/go-skeleton.
| Path | Method | Purpose |
|---|---|---|
/healthz |
GET | Liveness probe (always returns 200 OK) |
/readiness |
GET | Readiness probe (always returns 200 OK) |
/metrics |
GET | Prometheus metrics |
No /check or /trigger endpoint — release work is one-task-per-repo-per-master-SHA; operator-triggered single-repo runs go through cmd/run-once instead (see below).
| Metric | Cardinality | Purpose |
|---|---|---|
github_release_watcher_poll_cycle_total{result} |
result=success|github_error|rate_limited |
Poll health |
github_release_watcher_published_total{status} |
status=create|skipped|error |
Per-cycle task emission |
github_release_watcher_repos_scanned_total |
none | Sanity check on scope filter |
github_release_watcher_filter_skipped_total{reason} |
reason=empty_unreleased|auto_release|sha_unchanged|scope |
Filter chain visibility |
make test # run unit tests
make generate # regenerate counterfeiter mocks
make precommit # format + lint + test + security checksSingle Poll cycle against real dev Kafka, then exits. Use to verify the watcher↔controller↔vault chain without deploying.
cd cmd/run-once
make run-once REPO_ALLOWLIST=github.com/bborbe/<repo>Authenticates via gh auth token (PAT mode); cursor at /tmp/cursor.json; defaults to the same dev Kafka brokers as the deployed StatefulSet. See docs/verifying-specs.md for the full rung-1/2/3 evidence procedure.
The cursor at /data/cursor.json is a per-repo map:
{
"repos": {
"bborbe/disk-status": {"last_seen_master_sha": "6893c206..."},
"bborbe/lib-foo": {"last_seen_master_sha": "deadbeef..."}
}
}SHAUnchangedFilter consults this on each poll — only emits a task when a repo's HEAD has advanced since the last successful publish. Re-publish at the same SHA is suppressed at the filter layer; deterministic UUID5 (owner, repo, head_sha) at the controller layer provides a second defence (controller dedup makes re-emit a no-op).
Atomic write: temp file + rename. Corrupt cursor refuses startup — see pkg/cursor.go.
The watcher publishes CreateTaskCommand events on agent-task-v1-request. The controller materialises each into a vault task file at <vault>/24 Tasks/Release <owner>-<repo> <sha[:7]>.md with assignee: github-releaser-agent. Tasks at phase: planning, status: in_progress get picked up by the agent's Pattern B Job, which classifies the bump (patch/minor/major), rewrites the CHANGELOG, commits, tags, pushes — handling branch protection via PR + auto-merge fallback.
The github-releaser-agent is
live and consumes these tasks. The slash-command /github-release-repo performs
the same release manually when a human wants to drive it.
BSD 2-Clause License. See LICENSE.