Skip to content

feat: the Nyuchi App's backend — release tagging, review and triage - #1

Merged
bryanfawcett merged 18 commits into
stagingfrom
feat/tagging-review-triage
Oct 7, 2026
Merged

bryanfawcett merged 18 commits into
stagingfrom
feat/tagging-review-triage

Conversation

@bryanfawcett

Copy link
Copy Markdown
Member

Part of nyuchi/.github#90. The design is in this comment.

What

nyuchi-github-app is the Cloudflare Worker behind the Nyuchi GitHub App (id 4980118). It covers every org in the bundu-labs enterprise.

Trigger Action
push to staging Next PATCH tag and a pre-release vX (beta)
push to the default branch Next MINOR tag and a release. Never MAJOR
nightly cron 01:17 UTC Backfills untagged merges since BACKFILL_SINCE, grouped by PR, oldest first, at most 10 per repo per run
pull_request, or @nyuchi in a PR comment Code review with the shared Shamwari engine (git dependency pinned to shamwari-ai/github-app@ae94b6a), Workers AI through fundi
issues opened Labels chosen only from the repo's existing labels. It never creates labels, closes, assigns or comments

Rules it keeps

  • Policy is not forked. src/policy/next-version.mjs is nyuchi/.github's file at a0d2d79, copied byte-for-byte. CI runs scripts/check-policy.sh, which fails on any drift.
  • Never publishes by accident. A tag or release made by an App starts workflows, unlike one made by GITHUB_TOKEN. So a repo with a workflow that starts on push: tags, release or create is reported and never tagged.
  • No racing per-repo workflows. These are not removed yet. On push, the app skips a channel the repo still tags itself (reusable-staging-release, reusable-auto-tag, reusable-release). The nightly pass fills gaps.
  • Org allowlist ALLOWED_ORGS: the 7 enterprise orgs. nyuchiGOV is never in it, and tests cover that.
  • Skipped repos: sandbox-*, archive-*, archived repos, forks, empty repos, and repos with non-v tag schemes.
  • Secrets come from the Cloudflare Secrets Store (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.
  • No Anthropic API.
  • Ships in TAGGING_MODE = "dry-run". Going live is a separate PR.
  • This repo dogfoods the app: it has no per-repo tagging workflow.

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: matches nyuchi/.github@a0d2d79. wrangler deploy --dry-run: bundles at 268 KiB, with the Secrets Store bindings listed.
  • Runtime, local workerd (wrangler dev):
    • GET / returns the health JSON.
    • /webhook with no secret returns 503. With a local-only test secret:
      • ping returns 200 pong;
      • a nyuchiGOV push returns "organisation not allowed";
      • a feature-branch push returns "ref not released";
      • a bot issue is skipped;
      • a @nyuchi/platform team mention is skipped;
      • a bad signature returns 401.
    • __scheduled fails closed with "APP_PRIVATE_KEY is not bound".
    • The first run caught a real bug, fixed here: the vendored policy imports node:url, so the Worker needs nodejs_compat.
  • Real GitHub, read-only (scripts/plan.ts, my token, dry-run) across all 7 orgs, 83 repos:
    • The query and plan work.
    • nyuchi-licence-server and api-gateway are up to date, so the hand backfill (v0.1.0..v0.3.0) is recognised.
    • 9 repos are skipped as publishing on tags (auto-seo-manager, mongodb-mcp, nyuchi-docs, nyuchi-travel-addons, mukoko-events-mcp, bundu-labs/marketing, openNTL/ntl, siafudb, and similar).
    • Most repos without a staging flow would get many backfilled minors since 2026-10-04. Two examples: nyuchi/.github +45 and mukoko-weather +50. Whether that is wanted is an owner question on #90.

Code review: see the comment below.

🤖 Generated with Claude Code

https://claude.ai/code/session_01GHjaH1Vt2dVLWU68iz8tFq

…#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>
@bryanfawcett

Copy link
Copy Markdown
Member Author

Security review findings: both fixed in 8775b8c

The background commit security review flagged two merge-gate blockers. /code-review (high) independently found the same two, plus eight more.

1. Publish-guard bypass (src/tagging.ts, security-control-bypass).
The guard stops the app creating tags or releases that would start a publish workflow. Unlike GITHUB_TOKEN tags, App-made tags do start workflows. The guard had two gaps:

  • It counted push: tags, release and create only. It missed:
    • on: push, [push], a bare push:, and push: {paths: ...}, all of which GitHub runs for tag pushes because there is no branches filter. A publish job behind if: startsWith(github.ref, 'refs/tags/') is a common shape.
    • workflow_run, which can chain off a tag-started run.
  • It read only HEAD:.github/workflows, the default branch. A tag push runs the workflows at the tagged commit. So a staging branch, or an old backfilled merge, with a tag-triggered workflow got tagged anyway.

Reproduced by tests:

  • finding 1: triggers that run on a tag push...
  • publish guard: a tag-triggered workflow only on the tagged commit (not HEAD) blocks tagging
  • publish guard: an old merge whose workflows published on tags is not backfilled

All three fail on the old code.

Fix:

  • The trigger list is conservative: an unrecognised on: also counts.
  • publishingCommits() reads .github/workflows at every commit about to be tagged (one GraphQL query). If any of them publishes, the channel is not tagged.

On real GitHub (read-only dry run, 83 repos) 27 repos are now left alone, up from 9.

2. TOCTOU between plan and write (src/scan.ts).
The next version and "is this commit already tagged" were decided from one snapshot and written later. Meanwhile a repo's own workflow, another push, or the nightly run could tag the same commit or take a higher version, giving a second version on one commit or a version below the highest.

Fix:

  • assertStillValid() re-reads the live tags immediately before each write. It refuses if the name is taken, the commit now carries a version tag, or the highest version is no longer the one the tag was planned on top of (PlannedTag.after).
  • Ref creation stays the atomic last guard (422 if it exists), and no release is created without its tag.

Reproduced by tests:

  • TOCTOU: a version tag that lands on the commit after planning stops the write
  • TOCTOU: a higher version appearing elsewhere after planning stops the write
  • TOCTOU: the ref create is the atomic last guard

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 /code-review findings fixed in the same commit:

  • triage spending gate (author_association);
  • the team-mention guard now uses the engine's left boundary, so emails are not counted;
  • the PR merged into this branch wins over an open release PR;
  • a failed release is repaired on the next run;
  • history is paginated, and fails closed rather than stranding older merges;
  • a nightly tag budget, subrequests = 50000, and the org order rotated daily.

Answered rather than fixed:

Tests: 54 pass. tsc clean, vp check 0 errors, policy drift check passes, wrangler bundle builds. Re-review is running.

bryanfawcett and others added 15 commits October 7, 2026 08:06
…(#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>
@bryanfawcett

Copy link
Copy Markdown
Member Author

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 (src/scan.ts)

  • Found: several checks let tagging go ahead on missing or odd data:
    • an unset or non-ISO BACKFILL_SINCE widened the window to all history (new Date("1") is the year 2001);
    • a null per-commit workflow tree counted as "no workflows" even when the commit itself could not be found;
    • truncated workflow blobs were parsed as if complete;
    • a mistyped limit fell back to its default.
  • Fix: strict ISO and strict integers; one treeFacts validator for every tree; Commit-typed checks; nested tag peeling, where an unresolved version tag skips the repo; scanRepo never throws, so the reason ends up in the report.
  • This also caught a real bug: scripts/plan.ts never parsed [vars], so the first dry-run numbers covered all history. They have been re-run.

4. Parser differential (.github/actions/next-version/read-version.mjs, and the App)

  • Found: the reader, the policy and the App could read the same version differently (v, whitespace, suffixes, duplicate keys).
  • Fix, feat(workflows): release-version-check, the enterprise required version check (#90) .github#91: ONE strict parser, isStrictVersion in next-version.mjs (MAJOR.MINOR.PATCH, each 0..999, nothing else). Every reader goes through it. TOML is read with tomllib and JSON with json in one isolated python3 -I run, and duplicate root version keys are invalid.
  • App side: it uses the vendored isStrictVersion and runs every shared version-fixtures.json entry through all its readers (differential test).

5. Provenance regression (src/scan.ts)

  • Found: the release-repair ledger recorded only tag names, after the fact. A tag deleted and re-made by someone else could be treated as ours, and a crash could strand a tag with no release.
  • Fix:
    • Tags are written only on branches GitHub reports as protected (deletion, non-fast-forward and linear-history rules), from the App's own reads.
    • Every commit is re-proven reachable from refs/heads/<branch> just before its write.
    • The ledger is written ahead: it records {tag, tag-object sha, commit, prerelease, latest, PR} before the ref is created.
    • Repair trusts an entry only while the REST ref still points at that exact object.
    • Tags are annotated, with the App as tagger, and carry Pull-request: #N; the release links the PR.
    • Test: a commit not reachable from the protected branch head is never tagged.

Since then: /code-review (high) re-ran on the whole PR after each round, until it found no confirmed medium or higher defects.

  • App (40c3e2a, delta 574138b): clean.
  • Notable later fixes:
    • a per-repo TagLock Durable Object, keyed by repository id;
    • an hour's grace before tagging commits whose PR GitHub has not indexed yet, read from GitHub's activity record for the ref;
    • bounded, classified repair retries;
    • drafts no longer count as releases;
    • batched GraphQL.

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>
@bryanfawcett

Copy link
Copy Markdown
Member Author

Merge gate: met

  • Scope complete: the App's backend covers release tagging (push to staging and to the default branch), the nightly backfill, review and triage, and the security hardening from five security findings and the review rounds.

  • CI green at 8f8e627: test, Org lint and vite-plus all pass. The test job runs build, 165 tests, npm run check, the policy drift check (pinned to nyuchi/.github@93d089f, the merge of #91, on staging) and the wrangler bundle.

  • Verified at runtime:

    • local workerd (wrangler dev) against a fake GitHub:
      • two concurrent pushes under the TagLock make v0.1.0 and v0.2.0 once, even under a repo rename;
      • failed releases are repaired by the nightly cron;
      • every webhook gate answers as designed (503 without a secret, 401 on a bad signature, nyuchiGOV refused);
    • a read-only dry-run plan against real GitHub across all 7 orgs (scripts/plan.ts).
  • /code-review:

    • the full high-effort review was clean at 40c3e2a, with no confirmed defects of medium or higher severity;
    • the delta reviews of 574138b and 8f8e627 were clean too.

    Every earlier finding was fixed or answered. They are listed in the comments above and on Enterprise Nyuchi App: release tagging, code review and triage from the bundu-labs enterprise .github#90.

The Worker ships in TAGGING_MODE = "dry-run". Owner steps (app transfer, secrets, first deploy, installs) are on #90.

@bryanfawcett
bryanfawcett merged commit 276a319 into staging Oct 7, 2026
10 checks passed
@bryanfawcett
bryanfawcett deleted the feat/tagging-review-triage branch October 7, 2026 02:55
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