fix(update): follow the latest dist-tag for every build - #170
Conversation
channelTag returns 'latest' for every parseable version, so the nudge, `tenjin update`, and `--check` all resolve against one tag. resolveTarget collapses to that single lookup; an unparseable version still follows no tag, keeping the nudge quiet and the command's refusal of a foreign build. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Your trial has ended. Reactivate Greptile to resume code reviews.
A1igator
left a comment
There was a problem hiding this comment.
Review — round 1 (head 528c12d3)
The mechanism is sound and I could not find a way to make it install an unasked-for build. Three findings, all about the justification and the removed fallback rather than the happy path.
1. The changeset's rationale is factually wrong about what the removed code did
.changeset/update-follows-latest.md and the new resolveTarget doc comment in src/lib/update-check.ts both say a second tag "only gave a stale answer a chance to win", citing alpha frozen at 0.1.0-alpha.7.
The removed loop took the max, not the first hit:
if (best === null || isNewer(candidate, best)) best = candidate;A lower alpha could never win that comparison. alpha sitting at 0.1.0-alpha.7 while latest moved to .13 was already resolved correctly on main — that is what the deleted test follows latest when the channel tag has fallen behind it pinned.
This PR's own rewritten tests prove the point: to make the new pins fail under the old code, both resolveTarget reads latest for a prerelease build, whatever alpha holds and nudges a prerelease build toward latest, ignoring a newer alpha tag had to raise alpha to 0.1.0-alpha.99, i.e. ahead of latest. That is not staleness.
It matters because the changeset ships verbatim into CHANGELOG.md, and this sentence is the sole stated justification for deleting a fallback on the self-update path. The truthful version: a second tag lets a tag sitting ahead of latest redirect the install, and one tag is simpler and matches what the pipeline actually does.
Related framing: for the live registry this PR changes no user-visible outcome. Every behavioral delta is the removal of a fallback. fix(update) reads as a defect repair; this is a hardening/simplification.
2. The code now depends on a pipeline property the repo itself denies
channelTag hard-codes "every build follows latest" on the premise that the pipeline moves latest and nothing else. The repo's own release state says the opposite in two committed places:
.changeset/pre.jsonis{"mode": "pre", "tag": "alpha", ...}RELEASING.md:37— "Prereleases use Changesets' pre mode (pnpm changeset pre enter alpha) and publish to thealphadist-tag; stable goes tolatest."
I verified the premise holds today against the live registry: {"next":"0.1.0-alpha.1","alpha":"0.1.0-alpha.7","latest":"0.1.0-alpha.13"}, and every publish from 0.1.0-alpha.8 (2026-08-01) through 0.1.0-alpha.13 (2026-08-16) moved latest while alpha stayed put. So the behavior is real — but it holds as an artifact of what changeset publish does here, not because anything in this repo asserts it.
The PR body defers RELEASING.md:37 to an owner pass on the grounds that it describes the pipeline rather than the CLI. After this PR the CLI depends on the pipeline, so that separation no longer holds. If a future operator reads line 37 and "fixes" the publish to match it, latest stops moving, every user is told "up to date" forever, and resolveTarget has no second tag left to recover through. Correcting RELEASING.md:37 (and noting that pre.json's tag is inert here) belongs in the PR that takes the dependency, or the risk is invisible to the next person at the console.
3. RESOURCE_NOT_FOUND misdescribes the case this PR newly makes unrecoverable
In runUpdate (src/commands/update.ts):
`npm has no published tenjin-cli on the ${channel} tag`resolveTarget returns null for two different facts: latest is absent, and latest holds a version VERSION_RE cannot parse. In the second case npm does have a published tenjin-cli on latest; this build just cannot read it. The user is sent after a fault that is not there — the same objection the comment three lines above raises against NETWORK_ERROR.
On main that second case had a recovery for prerelease builds (channel was alpha, so the loop still had a tag to fall back to), which is every user today. The deleted test skips an unparseable candidate instead of letting it win called it "Load-bearing, and nothing else catches it... reachable the first time this project publishes something the regex does not admit." VERSION_RE admits only -alpha.N, so any future -beta.N, -rc.N, or +build on latest reaches it.
Second consequence, on the nudge rather than the command: maybeUpdate has if (latest === null) return; // asked and learned nothing: cache nothing either. An unparseable latest therefore makes every CLI invocation hit registry.npmjs.org indefinitely, with nothing printed and nothing cached to stop it.
Ask: separate the two nulls so the message and its fix: are true — either have resolveTarget report which fact it hit, or test tags[channel] === undefined at the runUpdate call site before choosing the message.
Nit in the same line: channel is now a single-valued literal, so ${channel} is a constant interpolation and channel on UpdateData is a constant field on the envelope. Fine to keep deliberately, worth dropping if nothing reads it.
Checked and clean
- The
'alpha'union removal is type-only; nothing persisted reads it.channelis absent fromsrc/schemas.ts(onlyUpdateAvailableSchema, which is{current, latest}), is not read bysrc/lib/hook-scripts.ts(that readscache.signalalone), and appears in no doc or skill. The one persisted artifact that can still sayalphais atags.alphaentry in the update-check cache:CacheSchema'sz.record(z.string(), TagEntrySchema)still accepts it,maybeUpdatereads onlycached?.tags[tag]withtagnow alwayslatest, and the read-modify-write spread carries the legacy entry forward untouched. No throw, no wrong answer, and the first post-upgrade invocation rewritessignal.ignores an entry under another tag, and leaves it intactpins this. - The self-update path refuses rather than proceeding on a null.
runUpdatethrowsREFUSEDbefore any fetch whenchannelTag(current)is null, andRESOURCE_NOT_FOUNDbefore any spawn whenresolveTargetis null. The spawn is reached only pastisNewer(latest, current), so alatestolder than the running build yields "up to date" and no install — no silent downgrade, and no path that leaves the CLI unrunnable. - Silent-on-unparseable holds for every input shape, not only well-formed semver.
maybeUpdateandreadUpdateSignalgate on the samechannelTag, which is now exactlyparseVersion(v) === null ? null : 'latest'. Any stringVERSION_RErejects — empty,1.2.3-beta.1,1.2.3+build, a coerced non-string — returns null and returns early before any I/O. Nothing can reach the emit path with an unparseable current. - Interaction with #136 is intact. The exact-version argv pin past the
isNewergate, bothrefuse(moduleDir, ...)stages, and the manager-delegation path are untouched by this diff. - Test coverage is real, not adjusted-to-pass. Both rewritten pins are negative controls with an
alphadeliberately ahead oflatest, andnudges a prerelease build toward latest, ignoring a newer alpha tagassertsnot.toContain('0.1.0-alpha.99')rather than only asserting the expected line.
No approval and no merge from me.
resolveTarget's null has two causes and they take opposite instructions: an absent latest tag has nothing to install, while a latest naming a version VERSION_RE does not admit means npm has a build this copy is too old to read. runUpdate now reports each truthfully, quoting the offending value truncated. maybeUpdate records the check clock whenever the registry ANSWERED, so an unreadable latest costs one request per window instead of one per command. A registry that could not be reached still caches nothing and is retried on the next command. Both clocks keep their existing meaning. Also corrects RELEASING.md, which claimed prereleases publish to the alpha dist-tag, and restates the changeset's rationale: the removed loop took the max, so the risk it carried was a tag AHEAD of latest redirecting the install, not a stale one winning. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Your trial has ended. Reactivate Greptile to resume code reviews.
|
All three findings accepted and fixed in 1. Changeset rationale. You are right, and the diff proved it against me: the removed loop took the max, so a lower On the framing point, agreed that for the live registry the tag change alone is a hardening rather than a defect repair. The title stays 2. RELEASING.md. Folded in, since you are right that the CLI-versus-pipeline separation stops holding the moment the CLI takes the dependency. Line 37 now states the observed behaviour rather than the intent: every publish lands on 3. Both consequences fixed. The two nulls are separated at the The nudge no longer re-asks forever. One consequence worth flagging, since it changed an existing test rather than adding one. Nit. Dropped. Verification. |
Applies the anti-slop test to the comments these commits introduced: what remains states an invariant or a trap the code cannot show, and the narration, the restated code, and the justification prose are gone. Untouched code is left at its existing density. RELEASING.md folds the dist-tag correction back into the step-3 sentence rather than appending blocks, and the changeset is one paragraph. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Comment trim in |
There was a problem hiding this comment.
Your trial has ended. Reactivate Greptile to resume code reviews.
A1igator
left a comment
There was a problem hiding this comment.
Review — round 2, delta only (528c12d3 → aef65126)
All three findings are addressed. One is addressed and then partly un-addressed by the trim commit; one new nit. Nothing from round 1's verified-clean list is re-opened.
1. Wrong rationale — FIXED, then thinned
The false sentence is gone from both places it appeared. What replaced it is true, and I checked it against the code rather than reading it: latest is the one tag this pipeline moves (verified live again — alpha still frozen at 0.1.0-alpha.7 since 2026-07-31, latest at 0.1.0-alpha.13), and the risk actually removed is a tag sitting ahead of latest, which is what the rewritten pins encode with alpha: '0.1.0-alpha.99'.
The catch: b599f3e6 wrote that reasoning out in full, in both the changeset and resolveTarget's doc comment — "Consulting a second tag as well added no reachable version and one way to be wrong: a tag sitting ahead of latest could redirect the self-update install to a build the release line never promoted." That is exactly the correction, and aef65126 deleted it from both.
At head, resolveTarget's doc explains what the nulls mean but no longer says why there is one tag; channelTag carries only the pointer "Every build follows latest; see RELEASING.md, which changes with this." So the tree now encodes the ahead-of-latest hazard in test fixtures that nothing explains: a reader meeting alpha: '0.1.0-alpha.99' in reads latest for a prerelease build, whatever alpha holds has no line telling them why the fixture is shaped that way.
Not a re-raise — a wrong claim replaced by no claim is the fix, and the trim pass is otherwise good (the comment blocks this PR added were genuinely over-weight for what they guarded). Putting that one sentence back on resolveTarget is worth more than the two lines it costs.
2. Undocumented premise — FIXED
RELEASING.md now says every publish lands on latest prerelease or stable, names .changeset/pre.json's "tag": "alpha" as pre-mode bookkeeping that does not decide it, and states the coupling out loud: "publish tagging and src/lib/update-check.ts must change together." That is the dependency made visible, the contradiction with the committed release state resolved rather than left standing, and the coupled file named so the next operator has somewhere to look. Nothing further from me.
3. Conflated RESOURCE_NOT_FOUND — FIXED, and the caching half was fixed too
Both halves, stated plainly since the framing could have carried one without the other:
The two nulls are now distinguishable. runUpdate re-reads tags[channel] and branches: absent gives "npm has no published tenjin-cli on the latest tag" with the pick-a-version fix; present-but-unparseable gives "npm's latest tenjin-cli (<value>) is not a version this build can read" with "Install it by name: npm i -g tenjin-cli@<version>". Different message, different instruction, and the second no longer tells a user npm has nothing when it has something.
The per-command re-fetch was fixed, not just accepted. This was the part I expected to survive. resolveLatest now returns null only when the registry could not be asked, and { latest: null } when it answered with nothing usable; maybeUpdate writes a tag entry carrying checkedAtMs and no latest in the second case, so the 24h window applies and an unreadable latest costs one request per day. TagEntrySchema.latest went optional to carry it, upgradeable and signal are null-guarded, and the entry's stale latest is dropped rather than kept — correct, since the tag no longer names it.
I also verified the claim in the truncation comment instead of taking it: emitFailure in src/lib/output.ts runs sanitizeForTerminal over both message and fix on the human path and writeJson on the machine path, so "bounded here, escaped by the error emitter" holds for the registry-controlled value.
4. New nit: the cache schema widened under the same schemaVersion
TagEntrySchema.latest went from z.string() to z.string().optional() while CacheSchema.schemaVersion stays z.literal(1). readCache rejects the WHOLE file on a parse failure, so a new build writing { latest: { checkedAtMs } } hands an older installed build sharing the data dir a cache it discards entirely — losing any other tag's entry and nudge clock with it, which is the one thing the per-tag keying exists to prevent. Self-corrects on the next write and only in the already-exceptional unparseable-latest state, and the cache is a documented pure optimization, so this is a note rather than a change request.
The new tests: do they fail on the pre-fix code?
I built the new tests against the old source to check, rather than reading them. Reverting only src/lib/update-check.ts and src/commands/update.ts to 528c12d3 and keeping this delta's tests: 7 failed, 69 passed.
Real pins, all failing on revert:
asks once per window when latest carries a version it cannot readasks once per window when the map has no latest tag at allclears a stale signal when the answer stops being readableseparates a latest it cannot parse from a latest that is not theretruncates a registry version before quoting it back
Two of the seven are not defect pins: installs the exact version npm just named, never the tag and --check reports the newer version and never installs fail only because the reverted runUpdate puts channel: 'latest' back into a toEqual. That is the type change, not a behavior guard.
One new test does not fail on revert: records nothing when the registry could not be reached at all passes against the old code, because the old code also cached nothing there. It asserts alongside the fix rather than pinning it. It still earns its place as the contrast case — it is what would fail if someone later made the unreachable path record a clock too — but it is a forward guard, not evidence for this change. Its present form is await expect(readFile(updateCheckPath(dir), 'utf8')).rejects.toThrow(), i.e. the cache file must not exist at all, which is the right shape for that job.
At head both files are green: 76 passed, 2 files.
On the framing
Agreed, with a correction to my own round-1 wording. I said the tag change is hardening rather than defect repair, and that is true of the resolved version — the max-across-tags on main already returned 0.1.0-alpha.13 for the live registry. It was not true of what the CLI told users: the update help line in src/cli.ts said "an alpha build follows @Alpha", which is every user of this package, pointing them at a tag frozen since 2026-07-31. That is a shipped falsehood in help text and this PR repairs it. So: defect repair on the disclosure surface, hardening on the resolution. Fine to characterize it as fix(update).
No approval and no merge from me.
The trim cut the one sentence explaining why there is a single tag, which left the hazard encoded only in a test fixture (alpha ahead of latest) that nothing described. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Your trial has ended. Reactivate Greptile to resume code reviews.
|
Both points, in 1. Sentence restored. Fair catch, the trim took one line too many. 2. Schema nit taken as a note, no code change. Bumping Also agreed on Full suite at head: 1941 passed, 10 skipped, 69 files. |
A1igator
left a comment
There was a problem hiding this comment.
Approving at 395f5380. All three round-1 findings are fixed and verified, and the one item left open was answered rather than ignored.
The schema argument checks out. It was accepted because it was verified against main, not because it sounded reasonable:
CacheSchemaonmainreally does pinschemaVersion: z.literal(1), so a bump to2failssafeParseon an older build exactly as a widenedTagEntrySchemadoes.readCachereturnsnullon any parse failure, so the discard is whole-file either way, not per-entry.TagEntrySchema.latestis a requiredz.string()onmain, so the cross-version window is real, and it is bounded as described: it opens only whilelatestis unparseable and closes on the next write by either build.- The cache's own docstring on
mainstates it is a pure optimization that is re-fetched rather than repaired.
So the proposed remedy genuinely buys nothing, and the finding is correctly recorded as the constraint that changes if the cache ever stops being disposable. One clarification for that record: the cost of the whole-file discard is not the re-fetch, it is that the other tag's notifiedAtMs goes with it and its nudge can print twice. That cost is identical under a version bump, so it does not change the conclusion.
Verified before approving: head unchanged since the round-2 review plus the restored-rationale commit, CI (lint, typecheck, build, test, audit) green, no unresolved threads, MERGEABLE, not a draft. The 395f538 diff is what it claims to be: three lines of JSDoc on resolveTarget, nothing else re-expanded.
On the finding that mattered most. The changeset originally justified deleting the second-tag fallback with a failure the deleted code could not have produced, since the removed loop took a max and a lower alpha could never win. That was worth catching for a reason beyond the wording: a self-update path is exactly where a fallback should not be removed on a rationale nobody checked. The replacement rationale was verified against the code rather than accepted, and the sentence explaining why the alpha: '0.1.0-alpha.99' fixtures are shaped as they are is now back on resolveTarget where a future reader will find it.
Also noted: the concession on records nothing when the registry could not be reached at all is right, and volunteering it is the useful habit. It passes against the pre-fix code, so it is a forward guard on the contrast case rather than a pin of this change, and counting it would have overstated what the delta's tests actually hold down.
Not merging. Merging another author's PR is outside what this loop does, so the button is yours.
Scope
channelTag()returnslatestfor every parseable version, so the daily check,tenjin update, and--checkall resolve against one tag (src/lib/update-check.ts). Unparseable versions still returnnull, which keeps the check silent and hastenjin updaterefuse a build it cannot place.resolveTarget()collapses to the singlelatestlookup, and its two null causes are now told apart at therunUpdatecall site: an absent tag has nothing to install, a tag naming a versionVERSION_REdoes not admit means npm has a build this copy is too old to read (src/commands/update.ts).maybeUpdaterecords the check clock whenever the registry answered, so an unreadablelatestcosts one request per window rather than one per command.channelfield is gone from the update envelope, andRELEASING.mdno longer claims prereleases publish to thealphadist-tag.Not doing
No npm dist-tag changes; retiring the stale
@alphatag on npm is an owner action after this releases.DoD
pnpm run check(build + full suite): 1941 passed, 10 skipped, 69 files. Baseline onorigin/mainmeasured in the same worktree was 1935 passed / 10 skipped; the delta is the six tests added here and no suite dropped into a skip.pnpm run lint,pnpm run typecheck,pnpm run format:check: clean.alphabranch fails 30 tests. Merging the twoRESOURCE_NOT_FOUNDmessages back into one fails the two new message pins. Restoringcache nothingon an unusable answer fails the three new clock pins.latestredirecting the install, not a stale one winning.🤖 Generated with Claude Code