Repository navigation
release: the Nyuchi App's backend (#1) - #2
Merged
Merged
Conversation
* feat: the Nyuchi App's backend — release tagging, review and triage (#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> * fix: security review and /code-review findings (#90) 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> * fix: fail closed on unverified state; per-repo lock; re-review fixes (#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> * fix: close remaining fail-open paths from re-review #2 (#90) - 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> * fix: one strict version parser, shared with the policy (#90) 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> * fix: re-review #3 — ledger provenance, event allowlist, strict history (#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> * fix: provenance — protected branches, object-pinned write-ahead ledger (#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> * fix: full-review findings — REST-verified repair, branch-true PRs, id-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> * fix: full-review round — linear history, own tagger at commits, ledger 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> * fix: full-review round — nightly grace, head facts, per-entry repair (#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> * fix: grace by branch activity, partial planning, bounded repair (#90) - 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> * fix: hold the whole channel on a just-arrived unindexed commit; repair 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> * fix: repair on full scans only; per-tag release check; activity guards (#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> * fix: bounded waits and retries everywhere; drafts read once (#90) 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> * fix: drafts read in full; bounded pre-loop failures (#90) 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> * fix: repair publishes over drafts; lazy batched shared reads (#90) 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> * refactor: commitFacts batches for every caller (#90) 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> * build: pin the policy to nyuchi/.github#91's merge commit (#90) 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> --------- Signed-off-by: Bryan Fawcett <bryan@nyuchi.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Releases
stagingtomain, so the owner's first deploy (the checklist on nyuchi/.github#90) is made frommain.Contains #1, the Nyuchi App's backend, which was merged under the merge gate (the record is on #1). CI on staging at 276a319 is green. Deploy is skipped until the repo variable
DEPLOY_ENABLEDis set after the first manual deploy.🤖 Generated with Claude Code
https://claude.ai/code/session_01GHjaH1Vt2dVLWU68iz8tFq