Repository navigation
feat: the Nyuchi App's backend — release tagging, review and triage - #1
Conversation
…#90) A Cloudflare Worker (nyuchi-github-app) behind the Nyuchi GitHub App (app id 4980118), for every org in the bundu-labs enterprise: - push to staging: next PATCH tag + pre-release; push to the default branch: next MINOR tag + release; MAJOR never automatic - nightly cron: backfill every untagged merge since BACKFILL_SINCE, grouped by pull request, oldest first, capped per repo - pull_request / @nyuchi: code review with the shared Shamwari engine (git dependency, pinned) on Workers AI via the fundi gateway - issues opened: label from the repo's existing labels only The versioning policy is nyuchi/.github's next-version.mjs, vendored byte-for-byte with a CI drift check. Repos whose workflows start on tags or releases are never tagged (an App-made tag starts them); a channel a repo still tags itself is skipped on push. ALLOWED_ORGS excludes nyuchiGOV. Secrets come from the Cloudflare Secrets Store. Ships in TAGGING_MODE=dry-run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GHjaH1Vt2dVLWU68iz8tFq Signed-off-by: Bryan Fawcett <bryan@nyuchi.com>
Security review findings (merge-gate blockers): 1. Publish-guard bypass (src/tagging.ts). The guard that keeps the app from creating tags that start publish workflows missed `on: push` with no branches filter (GitHub runs it for tag pushes) and `workflow_run`. It also read only the default branch's workflows, while a tag push runs the workflows AT THE TAGGED COMMIT. Now any push without a branches filter, workflow_run, release, create and push:tags count as publishing. The workflows at every commit about to be tagged are checked too (publishingCommits). 2. TOCTOU (src/scan.ts). The version and "is this commit tagged" were decided from a snapshot and acted on later. Now assertStillValid re-reads the live tags immediately before each write and refuses if the name is taken, the commit gained a version tag, or the highest version moved. Ref creation (422 on exists) stays as the atomic final guard, and no release is made without its tag. Code review findings: - triage: author_association spending gate (TRIAGE_TRIGGER_ASSOCIATIONS) - team-mention guard uses the engine's left boundary (emails excluded) - the PR that merged a commit INTO THIS BRANCH wins over an open release PR - a failed release is repaired on the next run - history is paginated; an unreachable last tag fails closed instead of stranding older merges - nightly tag budget, subrequests limit 50,000, org order rotated daily - unanchored count logged; upstream asks filed as shamwari-ai/github-app#7 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GHjaH1Vt2dVLWU68iz8tFq Signed-off-by: Bryan Fawcett <bryan@nyuchi.com>
Security review findings: both fixed in 8775b8cThe background commit security review flagged two merge-gate blockers. 1. Publish-guard bypass (
Reproduced by tests:
All three fail on the old code. Fix:
On real GitHub (read-only dry run, 83 repos) 27 repos are now left alone, up from 9. 2. TOCTOU between plan and write ( Fix:
Reproduced by tests:
These use a stateful fake GitHub where the race happens between plan and write. Residual: GitHub has no compare-and-swap for "create tag only if the commit has none". A different-name tag created in the milliseconds between the re-read and the ref creation is still possible. The next run sees both, and tag protection forbids deleting either. Other
Answered rather than fixed:
Tests: 54 pass. tsc clean, |
…(#90)
Security review finding 3, fail-open validation (src/scan.ts). Several
validation paths let tagging proceed on missing or unexpected data:
- an unset or unparseable BACKFILL_SINCE silently became the epoch, which
widened the walk to all history
- the per-commit workflow check treated a null result as "no workflows",
even when the commit itself could not be found or the response shape
was unexpected
- truncated workflow blobs were parsed as if complete, and a workflow
that is not a mapping with on: counted as harmless
- a mistyped numeric limit fell back to a default ("0" became 10/300)
Each now fails closed: the repository is skipped, the reason is logged,
and nothing is tagged. Tests cover each case. (This also caught a real
bug: scripts/plan.ts never parsed [vars], so the earlier dry-run numbers
were computed against all history. Fixed.)
Re-review fixes:
- One tagging run per repository at a time: every push and nightly run
goes through a Durable Object (TagLock) mutex. Two runs can no longer
put two different versions on one commit. A live run without the lock
refuses to start.
- Release repair touches only tags this app made ("Tagged-by:
nyuchi-github-app"). Channel and latest come from the tag, and repairs
are reported in dry runs. createRelease is shared by both paths.
- The nightly guards an installation with no org login, rotates across
all (org, repo) pairs, and uses a strict NIGHTLY_MAX_TAGS where 0 means 0.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GHjaH1Vt2dVLWU68iz8tFq
Signed-off-by: Bryan Fawcett <bryan@nyuchi.com>
- BACKFILL_SINCE must be strict ISO-8601 (new Date("1") is the year 2001)
- one validator (treeFacts) for every workflow tree, HEAD included: only
a Tree of readable Blobs, or confirmed absence, clears a commit
- publishingCommits requires the object to be a Commit
- tags are peeled through nested annotated tags; a version tag that does
not reach a commit skips the repository
- on push, a commit whose PR GitHub has not indexed yet holds tagging
- the re-check before each write also confirms the commit is still on
the branch (compare API), and counts this run's own writes so a lagging
GraphQL index cannot stall the run
- a tag whose release failed still counts (budget, report); scanRepo no
longer throws once writing may have started
- release repair needs proof: a GitHub-verified tag object whose tagger
is the App's bot address; "latest" ignores staging betas
- `branches:` with no value is not a branch filter
- scan.ts restructured around shared GraphQL fragments
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GHjaH1Vt2dVLWU68iz8tFq
Signed-off-by: Bryan Fawcett <bryan@nyuchi.com>
Security finding 4, parser differential (src/tagging.ts). The app decided what a version tag is with its own regex. It accepted pre-release and build suffixes the policy's parser treats differently, so the app, the policy and the required check could read one tag three ways. versionTags now uses the policy's own isStrictVersion (MAJOR.MINOR.PATCH, each 0..999, nothing else), vendored from nyuchi/.github at a70c277 with its shared version-fixtures.json. Both files are drift-checked in CI (check-policy.sh now pins several files). test/differential.test.ts runs every shared fixture through every reader (isStrictVersion, versionTags, foreignTagScheme, highest, and planning against nextVersion) and requires them all to agree with the fixture. The pin moves to the squash commit once nyuchi/.github#91 lands on staging. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GHjaH1Vt2dVLWU68iz8tFq Signed-off-by: Bryan Fawcett <bryan@nyuchi.com>
…y (#90) - release repair repairs only tags in a ledger of this app's own writes, kept in the repository's TagLock Durable Object storage. GitHub does not sign API-made tag objects, so the "verified bot tagger" check could never pass in production; a record of our writes is real provenance - the "has a release" probe lists releases, drafts included; if the list cannot be read in full, nothing is created - on: is an allowlist of events that never fire for tags or releases; anything else (new, misspelt) counts as publishing - a history page without pageInfo is an error, not "complete" - the branch check compares against heads/<branch>, so a same-named tag cannot answer for the branch - check-policy.sh fails if either vendored file is unpinned - nightly budget: dry runs spend it as live would; an RPC failure assumes the repository used its full allowance - next-version.d.mts matches the vendored module, checked by a test - since is computed once per scan Verified end to end in workerd against a fake GitHub: two concurrent pushes under the TagLock make v0.1.0 and v0.2.0 once; failed releases are recorded and repaired on a later run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GHjaH1Vt2dVLWU68iz8tFq Signed-off-by: Bryan Fawcett <bryan@nyuchi.com>
…r (#90)
Security finding 5, provenance regression (src/scan.ts). The release
ledger kept only tag NAMES and was written only after a release failed.
A tag deleted and re-made by someone else under the same name would have
been treated as ours, and a run that died, or lost the ref response,
between the ref and the ledger write left a tag no one would ever repair.
Now:
- tags are written only on branches GitHub reports as protected
(deletion + non-fast-forward rules), from the app's own reads. Each
commit is re-proven reachable from refs/heads/<branch> just before its
write. A test shows a commit off the branch is never tagged.
- the ledger records {tag, tag object sha, commit} write-ahead: after the
tag object exists, before its ref. Repair reads the live tags fresh and
trusts an entry only while the live tag is that exact object, on that
commit, still on a protected branch. Otherwise it drops the entry.
- tags stay annotated with the App's bot as tagger and carry
"Pull-request: #N". The release links the merging PR above GitHub's
generated notes.
- scanRepo never throws. The nightly charges the budget only for RPC/lock
failures, and a lost ref response is charged too.
- the on: allowlist drops status/check_run/check_suite/repository_dispatch,
which can chain off a tag.
- check-policy.sh requires both files to be pinned to nyuchi/.github itself.
- the types test checks the vendored module both ways. The README matches
the code.
Verified in workerd against a fake GitHub: two concurrent pushes make
v0.1.0 and v0.2.0 once, with branch checks against refs/heads/main;
failed releases are repaired on a later push.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GHjaH1Vt2dVLWU68iz8tFq
Signed-off-by: Bryan Fawcett <bryan@nyuchi.com>
…-keyed lock (#90) - repair reads each ledger tag's ref through REST, which is consistent with this app's REST writes, instead of a GraphQL list that can lag. An absent ref drops the entry; a different object (someone else's tag) drops it; a lagging index keeps it for the next run - only a PR merged into THIS branch counts. A commit merged into another branch (a back-merge) is foreign: never tagged here, never grouped, and never names the wrong PR in a release - the TagLock (and its ledger) is keyed by the repository's numeric id, so a rename cannot split the lock; a live run without an id is refused - branch rules are read with paging (per_page=100, following Link) - a workflow tree entry without a name counts as publishing - REPO_QUERY fetches only the history a push needs (@include) - check-policy.sh also requires the pinned commit to be on nyuchi/.github staging or main (raw URLs serve fork-network commits too); CI passes GH_TOKEN. The pin moves to #91's merge commit before this PR merges. - dead code removed (unused repair parameter, unreachable name loop) Verified in workerd against a fake GitHub: two concurrent pushes for the same repository id under two names make v0.1.0 and v0.2.0 once; the failed releases are repaired by a later push under the old name. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GHjaH1Vt2dVLWU68iz8tFq Signed-off-by: Bryan Fawcett <bryan@nyuchi.com>
…r fields (#90) - a release line must also require linear history: the walk reads GraphQL history (all ancestors by date), which equals the merge order only without merge commits. Otherwise one PR could split into several releases tagged on side commits - the push-time "PR not indexed" hold ignores back-merged (foreign) commits, which are never tagged anyway - the workflow facts AT the planned commits (commitFacts) decide both the publish guard and, on push, whether the repo tags this channel itself (a staging-only reusable-staging-release is no longer missed) - ledger entries record prerelease/latest/PR at write time. Repair uses them and marks latest only if no higher non-beta tag has appeared since, so it no longer depends on parsing GraphQL tag messages - repair does its per-repo reads once (releases, protection, live tags, facts for all entries) - the nightly charges what each run reports as charged (a lost ref response included), and a spent budget skips reading a channel at all - CI runs `npm run check`; dead code removed Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GHjaH1Vt2dVLWU68iz8tFq Signed-off-by: Bryan Fawcett <bryan@nyuchi.com>
…(#90) - night-time hold: a commit with no indexed PR that is younger than an hour waits for the next night (a merge just before the cron could otherwise be split into several versions). A missing date counts as young - on push, the facts at the pushed head are read too, so a repo's own tagger at the head is seen even when the plan's cap leaves the head out - repair: one entry's failure is reported and no longer blocks the rest; it reuses the run's tag list rather than reading every page again - BACKFILL_MAX_PER_REPO is documented as per channel (what it is) - docs: a back-merge through its own PR into this branch is released; only commits reaching a branch by fast-forward from another branch are foreign - deploy runs `npm run check` and the policy drift check before deploying - dead code: installationForOrg removed Answered, not changed: repair's "newer" check treats unmarked tags as main releases. The repo's own staging workflow marks its tags "(staging)" (reusable-staging-release writes "$TAG (staging)"), so a mismatch needs a hand-made beta. In that case the fallback is not to mark latest, which is safe. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GHjaH1Vt2dVLWU68iz8tFq Signed-off-by: Bryan Fawcett <bryan@nyuchi.com>
- A commit without an indexed PR, and everything newer, waits; older merges are still planned, so the whole channel is no longer held. At night it waits only while the branch moved within the last hour. That is read from GitHub's activity record for the ref (when the commit reached the branch), not from committedDate (when it was written), which a local fast-forward makes old. Unknown counts as moved. - head facts are read on push only (no extra nightly query) - repair judges "newer" against a fresh tag read joined with the run's writes; a permanently failing entry is retried up to 10 times, then dropped and reported - the staging beta deploy also runs `npm run check` and the policy check Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GHjaH1Vt2dVLWU68iz8tFq Signed-off-by: Bryan Fawcett <bryan@nyuchi.com>
…r outcomes (#90) Review of e04b6e5: - Planning only the part older than an unindexed commit could still split a rebase merge whose commits GitHub indexes one at a time. So when such a commit may have just arrived, the whole channel waits again. At night, "just arrived" now means GitHub's activity record for the ref places the commit within the last hour: every commit above the oldest recent `before`. That is not whether the branch moved at all, so an old direct push no longer stalls a busy branch. Anything that cannot be placed counts as recent. - repair sorts outcomes: done, transient (rate limit, 5xx, network; never counted) and stuck (validation error, or a proof that keeps failing; dropped and reported after 10 runs). Ledger writes in that path are guarded so one entry never blocks the rest. - committedDate is no longer read (dead); README says push always waits. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GHjaH1Vt2dVLWU68iz8tFq Signed-off-by: Bryan Fawcett <bryan@nyuchi.com>
…s (#90) Review of 48cac27: - Repair runs only on full (nightly) scans. A one-channel push lacks the other release branch, so it would have counted good staging entries as stuck. - "Has a release" is answered per tag: GET /releases/tags/{tag}, then drafts from the list. More than 1,000 releases is a reported, bounded "stuck" rather than a silent forever-transient. - Outcome classification: only a rate limit (429, or a 403 that says so), a 5xx or a network failure is transient. Any other 403 (no permission), 4xx or unexpected error is stuck, which is bounded and reported. Each stuck outcome now says why. - arrivedRecently waits when the activity record lags behind the head (newest event's `after` is not the head), or when a full page of 100 events falls inside the window. - a waiting channel's pending count is in releases, not commits Verified in workerd against a fake GitHub: two concurrent pushes make v0.1.0 and v0.2.0 once; their failed releases are repaired by the nightly cron (/__scheduled), not by pushes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GHjaH1Vt2dVLWU68iz8tFq Signed-off-by: Bryan Fawcett <bryan@nyuchi.com>
Review of efda012: - A night run no longer waits forever when the activity record cannot place the head (empty, or it predates the record: an import or a template). A head committed more than two days ago did not arrive through a merge GitHub is still indexing. - Repair failures are counted in two bounded classes: stuck (10 runs) and transient (30 runs: rate limit, 5xx, network, or an unexpected runtime error). workerd's plain-Error network failures are now transient. Every give-up is reported. - Drafts are read once per repair pass, stopping at releases created over a day before the oldest pending entry (ledger entries record when they were written). GET /releases/tags answers for published ones. - repairOne returns {outcome, why}; no shared mutable reason - a waiting channel's summary shows its pending releases Verified in workerd against a fake GitHub: concurrent pushes tag once; the nightly cron repairs their failed releases. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GHjaH1Vt2dVLWU68iz8tFq Signed-off-by: Bryan Fawcett <bryan@nyuchi.com>
Review of a86fd18: - draftTags no longer stops early by date. A draft can be older than our tag (Release Drafter, or a maintainer's draft), and release created_at is the tagged commit's date, so the full list is read (up to 10 pages; more is a reported, bounded "stuck"). Tested with an older draft: no duplicate release. - If the per-repository reads before the repair loop fail, every pending entry gets a transient run counted, so a read that keeps failing still ends and is reported. A failed draft read is remembered, not repeated per entry. - A committer date in the future is treated as old for the "cannot place the head" case, so a bad clock cannot hold a channel until that date. - README matches the 48h rule. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GHjaH1Vt2dVLWU68iz8tFq Signed-off-by: Bryan Fawcett <bryan@nyuchi.com>
Review of 17a9622: - A draft is not a release. Repair asks only whether the tag has a PUBLISHED release (GET /releases/tags/{tag}), the same as the new-tag path, so a Release Drafter draft no longer suppresses the release, and repos with >1,000 releases repair normally - the per-repository reads run lazily, once an entry passes its cheap checks (an absent or foreign ref is cleared first, without them), and their failure is classified like any other (stuck vs transient) - workflow facts in repair are queried in batches of 10 commits - the unused `at` field is gone; README covers future committer dates Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GHjaH1Vt2dVLWU68iz8tFq Signed-off-by: Bryan Fawcett <bryan@nyuchi.com>
The review of 40c3e2a noted that the scan path still sent one facts query for every planned commit, while repair batched its own. Batching now lives in commitFacts itself (10 commits per query), so neither caller can send one huge query, even with a large BACKFILL_MAX_PER_REPO. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GHjaH1Vt2dVLWU68iz8tFq Signed-off-by: Bryan Fawcett <bryan@nyuchi.com>
Security review findings 3–5 (and the convergence that followed)Records 1 and 2 are in the earlier comment. All five are fixed on #1, and each has a test that fails on the old code. 3. Fail-open validation (
4. Parser differential (
5. Provenance regression (
Since then:
|
src/policy/next-version.mjs and version-fixtures.json are re-synced from nyuchi/.github@93d089f (the squash of #91, on staging). The types follow the module's new exports (decide, currentVersion, UsageError, isMain, DEFAULT_PREFIX), and countTags is gone. check-policy.sh confirms the commit is on nyuchi/.github staging. The differential test still agrees on every shared fixture. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GHjaH1Vt2dVLWU68iz8tFq Signed-off-by: Bryan Fawcett <bryan@nyuchi.com>
Merge gate: met
The Worker ships in |
Part of nyuchi/.github#90. The design is in this comment.
What
nyuchi-github-appis the Cloudflare Worker behind the Nyuchi GitHub App (id 4980118). It covers every org in the bundu-labs enterprise.stagingvX (beta)BACKFILL_SINCE, grouped by PR, oldest first, at most 10 per repo per runpull_request, or@nyuchiin a PR commentshamwari-ai/github-app@ae94b6a), Workers AI throughfundiissuesopenedRules it keeps
src/policy/next-version.mjsis nyuchi/.github's file ata0d2d79, copied byte-for-byte. CI runsscripts/check-policy.sh, which fails on any drift.GITHUB_TOKEN. So a repo with a workflow that starts onpush: tags,releaseorcreateis reported and never tagged.reusable-staging-release,reusable-auto-tag,reusable-release). The nightly pass fills gaps.ALLOWED_ORGS: the 7 enterprise orgs. nyuchiGOV is never in it, and tests cover that.sandbox-*,archive-*, archived repos, forks, empty repos, and repos with non-vtag schemes.NYUCHI_GITHUB_APP_PRIVATE_KEY,NYUCHI_GITHUB_WEBHOOK_SECRET). The Worker fails closed (503) without them.GET /reports whether each one is bound, as true or false only.TAGGING_MODE = "dry-run". Going live is a separate PR.Verification
npm run build(tsc on src and tests): clean.npm test: 42 pass, 0 fail.vp check: formatted, 0 errors.scripts/check-policy.sh: matchesnyuchi/.github@a0d2d79.wrangler deploy --dry-run: bundles at 268 KiB, with the Secrets Store bindings listed.wrangler dev):GET /returns the health JSON./webhookwith no secret returns 503. With a local-only test secret:pingreturns 200 pong;@nyuchi/platformteam mention is skipped;__scheduledfails closed with "APP_PRIVATE_KEY is not bound".node:url, so the Worker needsnodejs_compat.scripts/plan.ts, my token, dry-run) across all 7 orgs, 83 repos:Code review: see the comment below.
🤖 Generated with Claude Code
https://claude.ai/code/session_01GHjaH1Vt2dVLWU68iz8tFq