The backend of the Nyuchi GitHub App (app id
4980118, slugnyuchi): release tagging, code review and triage across every organisation in thebundu-labsenterprise. It runs as a Cloudflare Worker,nyuchi-github-app.
Tracking issue: nyuchi/.github#90. Versioning policy: nyuchi/.github#80.
Shamwari for GitHub (shamwari-ai/github-app) is the consumer-facing app and the MCP server. This Worker serves no people. It answers GitHub's webhooks and runs one nightly job.
| Trigger | Action |
|---|---|
push to staging |
Tags the next PATCH and creates a GitHub pre-release vX (beta) |
push to the default branch |
Tags the next MINOR and creates a GitHub release |
| Nightly cron (01:17 UTC) | Backfills every merge that was never tagged, in every installed org |
pull_request (opened, ready, new commits) |
Code review, using the shared Shamwari engine on Workers AI |
issue_comment mentioning @nyuchi on a PR |
Code review on request |
issues opened |
Triage: labels from the repository's existing labels |
A MAJOR version is never automatic. A person releases one by hand.
The policy is not re-implemented here. src/policy/next-version.mjs is a
byte-for-byte copy of nyuchi/.github's
.github/actions/next-version/next-version.mjs, taken at the commit named in
src/policy/POLICY_SOURCE. CI fails if the two ever differ
(scripts/check-policy.sh). To change the policy, change it in nyuchi/.github,
then bump POLICY_SOURCE and run scripts/check-policy.sh --sync.
This Worker only decides which commits are releases:
-
For each branch it walks back from the head to the first commit that already carries a
v<semver>tag. Every commit after that is unreleased. -
Unreleased commits are grouped by the pull request that merged them into this branch. A rebase merge is one release, tagged on its newest commit. A commit with no pull request is a release on its own. A commit whose only pull requests merged into another branch (it reached this one by a fast-forward) is not a release of this branch and is never tagged on it. A back-merge made through its own pull request into this branch is a release like any other. If a commit without an indexed pull request may have just arrived, the whole channel waits for a later run, so one rebase merge is never split. On push it always waits. At night it waits when GitHub's activity record for the ref shows the commit arrived within the last hour, or cannot say, unless the branch head was committed more than two days ago, or more than a day in the future (a bad clock), when the record simply does not reach back that far.
-
Releases are tagged oldest first, each at the next version above the highest existing tag. Only the newest default-branch release is marked "latest".
-
At most
BACKFILL_MAX_PER_REPOtags per channel (branch) per run, andNIGHTLY_MAX_TAGSacross one night. The rest are reported as pending. The org order rotates daily. -
If a branch has more untagged history than the scan reads (10 pages) and no tag in sight, nothing is tagged and the run says so. Tagging only the visible part would strand the older merges for good.
-
Immediately before each write, the live tags are read again. The write is abandoned when any of these has happened since planning:
- the name was taken;
- the commit gained a version tag;
- the highest version moved.
Creating the ref is the final atomic guard: GitHub answers 422 when it already exists.
-
Tags only commits that GitHub reports on a protected branch (one with deletion, non-fast-forward and linear-history rules; linear history makes the history the walk reads equal the merge order), read with the app's own authenticated calls. Immediately before the write, the commit is proved reachable from that branch's head again. A webhook payload only says which repository and which channel to look at.
-
Tags are annotated, with the App's bot as tagger, and carry the merging pull request (
Pull-request: #N). Each release links that pull request above GitHub's generated notes. -
Each tag this app writes is recorded before its ref is created, in a ledger in the repository's
TagLockDurable Object storage, and the record is cleared once its release exists. A later run gives a recorded tag its missing release, but only while the live tag is still the very tag object recorded and its commit is still on a protected branch. A tag deleted and re-made by anyone else is never touched. -
One tagging run per repository at a time. Every run, push or nightly, goes through a Durable Object (
TagLock) keyed by the repository's numeric id, so a rename cannot split it, and runs under its lock. Two runs can therefore never put two different versions on one commit. A live run refuses to start without the lock. -
Fail closed on anything unverified:
- an unset or unparseable
BACKFILL_SINCE; - a mistyped limit;
- a commit GitHub cannot find;
- an unexpected response shape;
- a binary, truncated or non-mapping workflow file.
Each of these skips the repository and logs why. It never tags.
- an unset or unparseable
-
A staging-tagged commit that reaches the default branch by fast-forward gets no minor tag, because the walk stops at its tag. Rulesets allow only squash and rebase merges through pull requests, and both create new commits, so this only happens through a ruleset bypass.
- Repositories with workflows that might start on a tag push or a
release.
on:is read against an allowlist of events that never fire for tags or releases (pull_request,schedule,workflow_dispatchand similar). Any other event counts as publishing, and so does apushwithout a realbranchesfilter, because GitHub runs that for tags too. A tag made by an App starts workflows, which aGITHUB_TOKENtag does not, so a beta tag could run a production publish.- This is checked against the default branch's workflows and the workflows at every commit about to be tagged. A tag push runs the files at the tagged commit.
- These repositories are reported and left alone.
- A channel the repository still tags itself (
reusable-staging-release,reusable-auto-tag,reusable-release). These are skipped on push, so the two never race. The nightly pass still fills any gap. - Repositories whose tags follow another scheme (
@scope/pkg@1.2.3), so they do not suddenly grow av0.1.0. - Archived repositories, forks, empty repositories, and
sandbox-*orarchive-*names. - Merges before
BACKFILL_SINCE. - Any organisation outside
ALLOWED_ORGS. nyuchiGOV is outside the enterprise, and an enterprise app cannot be installed there anyway.
TAGGING_MODE = "dry-run" (the default) logs the plan and creates nothing.
Reviews are the Shamwari engine (shamwari-ai/github-app, a git dependency
pinned to a commit), not a copy. The engine is set up to:
- run Workers AI through the
fundiAI Gateway; - skip drafts;
- comment and never approve;
- answer only owners, members and collaborators.
This Worker adds the enterprise org allowlist and the @nyuchi handle. A team
mention such as @nyuchi/platform does not count as asking.
Triage asks the same model to choose up to three of the repository's existing
labels. It never creates a label, and never closes, assigns or comments. Only
issues from owners, members, collaborators and contributors are triaged
(TRIAGE_TRIGGER_ASSOCIATIONS), so a stranger cannot spend model calls.
No Anthropic API is used anywhere.
Every setting is in wrangler.toml [vars], versioned. wrangler deploy
replaces [vars], so a setting made only in the dashboard is lost on the next
deploy. The kill switches are TAGGING_MODE, REVIEW_ENABLED and
TRIAGE_ENABLED.
There are two secrets. Both live in the account's Cloudflare Secrets Store,
are bound with secrets_store_secrets and are read with await env.X.get():
| Binding | Secrets Store name | What |
|---|---|---|
APP_PRIVATE_KEY |
NYUCHI_GITHUB_APP_PRIVATE_KEY |
The App's private key (PEM) |
APP_WEBHOOK_SECRET |
NYUCHI_GITHUB_WEBHOOK_SECRET |
The webhook secret set on the App |
Until both exist, /webhook answers 503 and the nightly run logs that the key
is missing. GET / reports whether each secret is bound (true or false, never
its value).
npm ci
npm run build && npm test && npm run check
scripts/check-policy.shTo see what the app would tag, without creating anything, use your own token from the environment (never in argv):
GH_TOKEN="$(gh auth token)" npx tsx scripts/plan.ts nyuchi/api-gateway
GH_TOKEN="$(gh auth token)" npx tsx scripts/plan.ts --org mukoko-devstaging uploads a preview version. main deploys. Both are gated on the
repository variable DEPLOY_ENABLED. The first deploy is made by hand, as
listed in the owner checklist on nyuchi/.github#90.
The webhook URL is https://nyuchi-github-app.nyuchi.workers.dev/webhook.