Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
07ea28b
feat: the Nyuchi App's backend — release tagging, review and triage (…
bryanfawcett Oct 6, 2026
8775b8c
fix: security review and /code-review findings (#90)
bryanfawcett Oct 6, 2026
4265cee
fix: fail closed on unverified state; per-repo lock; re-review fixes …
bryanfawcett Oct 7, 2026
bfe3804
fix: close remaining fail-open paths from re-review #2 (#90)
bryanfawcett Oct 7, 2026
353088c
fix: one strict version parser, shared with the policy (#90)
bryanfawcett Oct 7, 2026
3a953de
fix: re-review #3 — ledger provenance, event allowlist, strict histor…
bryanfawcett Oct 7, 2026
5a6d186
fix: provenance — protected branches, object-pinned write-ahead ledge…
bryanfawcett Oct 7, 2026
65c0a9e
fix: full-review findings — REST-verified repair, branch-true PRs, id…
bryanfawcett Oct 7, 2026
e58abbc
fix: full-review round — linear history, own tagger at commits, ledge…
bryanfawcett Oct 7, 2026
c4dadae
fix: full-review round — nightly grace, head facts, per-entry repair …
bryanfawcett Oct 7, 2026
e04b6e5
fix: grace by branch activity, partial planning, bounded repair (#90)
bryanfawcett Oct 7, 2026
48cac27
fix: hold the whole channel on a just-arrived unindexed commit; repai…
bryanfawcett Oct 7, 2026
efda012
fix: repair on full scans only; per-tag release check; activity guard…
bryanfawcett Oct 7, 2026
a86fd18
fix: bounded waits and retries everywhere; drafts read once (#90)
bryanfawcett Oct 7, 2026
17a9622
fix: drafts read in full; bounded pre-loop failures (#90)
bryanfawcett Oct 7, 2026
40c3e2a
fix: repair publishes over drafts; lazy batched shared reads (#90)
bryanfawcett Oct 7, 2026
574138b
refactor: commitFacts batches for every caller (#90)
bryanfawcett Oct 7, 2026
8f8e627
build: pin the policy to nyuchi/.github#91's merge commit (#90)
bryanfawcett Oct 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Tests, types and the policy drift check on every pull request and on the
# release branches. Org lint (required by the org ruleset) runs separately.

name: CI

on:
pull_request:
push:
branches: [main, staging]

permissions:
contents: read

concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
test:
name: test
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v6
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run build
- run: npm test
- run: npm run check
- name: The bundled policy is nyuchi/.github's, unchanged
run: scripts/check-policy.sh
env:
GH_TOKEN: ${{ github.token }}
- name: The Worker bundles
run: npx wrangler deploy --dry-run --outdir "$RUNNER_TEMP/bundle"
87 changes: 87 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Ships the Worker. Checks run in CI and the org-required Org lint.
#
# push to main wrangler deploy -> nyuchi-github-app.nyuchi.workers.dev
# push to staging wrangler versions upload -> preview alias "staging"
# (never deployed, so production keeps serving main)
#
# Both are gated on the repository variable DEPLOY_ENABLED == 'true'. The
# first deploy is made by hand (the owner checklist in nyuchi/.github#90);
# the variable is set after it, so CI never creates the Worker.
#
# CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID are the nyuchi org secrets.
# The App's key and webhook secret are in the Cloudflare Secrets Store and
# never pass through CI.

name: Deploy

on:
push:
branches: [main, staging]
workflow_dispatch:

concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: false

permissions:
contents: read

jobs:
deploy:
name: Deploy (main)
if: vars.DEPLOY_ENABLED == 'true' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v6
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run build
- run: npm test
- run: npm run check
- name: The bundled policy is nyuchi/.github's, unchanged
run: scripts/check-policy.sh
env:
GH_TOKEN: ${{ github.token }}
- name: Deploy to Cloudflare
run: npx wrangler deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
- name: Health answers
run: |
curl -fsS https://nyuchi-github-app.nyuchi.workers.dev/ | jq -e '.name == "nyuchi-github-app"'

beta:
name: Beta (preview version from staging)
if: vars.DEPLOY_ENABLED == 'true' && github.ref == 'refs/heads/staging'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v6
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run build
- run: npm test
- run: npm run check
- name: The bundled policy is nyuchi/.github's, unchanged
run: scripts/check-policy.sh
env:
GH_TOKEN: ${{ github.token }}
- name: Upload a preview version aliased staging
run: >-
npx wrangler versions upload --preview-alias staging
--message "staging ${GITHUB_SHA::7}"
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
node_modules/
.wrangler/
.dev.vars*
.DS_Store
*.pem
41 changes: 41 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# AGENTS.md

nyuchi-github-app: the Cloudflare Worker behind the **Nyuchi** GitHub App
(app id 4980118, moving to the bundu-labs enterprise). It answers GitHub
webhooks (tagging, review, triage) and runs the nightly tag backfill. Plan,
decisions and status: nyuchi/.github#90.

## Rules

- Big work lives in GitHub issues (nyuchi/.github#90 for this app). Link it
from every PR.
- Feature PRs target `staging`; `staging` to `main` is a release. Squash or
rebase, linear history, no ruleset bypass, no `--no-verify`. Merge only when
done, CI is green, it is verified at runtime and `/code-review` ran clean.
- The versioning policy is NEVER edited here. `src/policy/next-version.mjs` is
nyuchi/.github's file at the commit in `POLICY_SOURCE`; CI fails on drift.
- Review code is the Shamwari engine (git dependency, pinned). Improve it
upstream in shamwari-ai/github-app and bump the pin; do not fork it here.
- Secrets live in the Cloudflare Secrets Store (`secrets_store_secrets`,
`await env.X.get()`). Never print, commit or pass one in argv. Non-secret
settings live in `wrangler.toml` `[vars]` (deploy replaces `[vars]`).
- Every setting that widens what the app touches (`ALLOWED_ORGS`,
`TAGGING_MODE = "live"`, `BACKFILL_SINCE`) changes by PR only.
- nyuchiGOV is isolated: never add it to `ALLOWED_ORGS`.
- No Anthropic API. Inference is Workers AI through the `fundi` gateway.
- Names: Shamwari is the consumer AI and Nyuchi AI is internal. Never "Ubuntu
AI". British spelling: licence (noun), license (verb).

## Layout

- `src/index.ts` routes, health and the nightly cron
- `src/webhook.ts` delivery handling (push, pull_request, issue_comment, issues)
- `src/tagging.ts` pure: which commits are releases, workflow facts
- `src/scan.ts` the per-repo GraphQL read and tag/release creation
- `src/triage.ts` issue labelling
- `src/app.ts` App JWT, installation tokens, REST/GraphQL helpers
- `src/policy/` the vendored org policy and its pin
- `scripts/plan.ts` read-only dry run with your own token
- `test/` `node:test` via tsx

Checks: `npm run build && npm test && npm run check && scripts/check-policy.sh`.
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# CLAUDE.md

Read [AGENTS.md](AGENTS.md); it holds every rule for this repo.
191 changes: 189 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,189 @@
# github-app
The Nyuchi GitHub App: release tagging, code review and triage across the bundu-labs enterprise (Cloudflare Worker)
# Nyuchi GitHub App

> The backend of the **Nyuchi** GitHub App (app id `4980118`, slug `nyuchi`):
> release tagging, code review and triage across every organisation in the
> `bundu-labs` enterprise. It runs as a Cloudflare Worker, `nyuchi-github-app`.

Tracking issue: [nyuchi/.github#90](https://github.com/nyuchi/.github/issues/90).
Versioning policy: [nyuchi/.github#80](https://github.com/nyuchi/.github/issues/80).

Shamwari for GitHub ([shamwari-ai/github-app](https://github.com/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.

## What it does

| 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.

### How a version is chosen

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_REPO` tags per channel (branch) per run, and
`NIGHTLY_MAX_TAGS` across 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 `TagLock` Durable 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.

- 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.

### What it will not tag

- **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_dispatch` and
similar). Any other event counts as publishing, and so does a `push`
without a real `branches` filter, because GitHub runs that for tags too. A tag made by an App starts workflows, which a `GITHUB_TOKEN` tag 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 a `v0.1.0`.
- **Archived repositories, forks, empty repositories, and `sandbox-*` or
`archive-*` 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.

### Review and triage

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 `fundi` AI 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.

## Configuration

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).

## Develop

```sh
npm ci
npm run build && npm test && npm run check
scripts/check-policy.sh
```

To see what the app would tag, without creating anything, use your own token
from the environment (never in argv):

```sh
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-dev
```

## Deploy

`staging` 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`.
Loading
Loading