Skip to content

release: the Nyuchi App's backend (#1) - #2

Merged
Bryan Fawcett (bryanfawcett) merged 1 commit into
mainfrom
staging
Oct 7, 2026
Merged

Bryan Fawcett (bryanfawcett) merged 1 commit into
mainfrom
staging

Conversation

@bryanfawcett

Copy link
Copy Markdown
Member

Releases staging to main, so the owner's first deploy (the checklist on nyuchi/.github#90) is made from main.

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_ENABLED is set after the first manual deploy.

🤖 Generated with Claude Code

https://claude.ai/code/session_01GHjaH1Vt2dVLWU68iz8tFq

* 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>
@bryanfawcett
Bryan Fawcett (bryanfawcett) merged commit 6c4adee into main Oct 7, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant