Skip to content

feat(cli): per-node auto-update channel (decouple testnet from latest)#1295

Merged
branarakic merged 6 commits into
mainfrom
feat/auto-update-channel
Jun 22, 2026
Merged

feat(cli): per-node auto-update channel (decouple testnet from latest)#1295
branarakic merged 6 commits into
mainfrom
feat/auto-update-channel

Conversation

@branarakic

@branarakic branarakic commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds autoUpdate.channel — an npm dist-tag a node tracks for auto-updates — so each network tracks its own release line on the same npm package, and no node depends on what latest points at.

Why: the node auto-updater follows dist-tags.latest (+ dev/beta/next). Without per-node channels, the moment latest becomes mainnet every testnet node auto-updates onto it (junk graphs, surprise upgrades). Dedicated channels decouple latest from auto-update entirely.

Commits

  1. feat(cli): per-node auto-update channel — the channel field, threaded through the resolver, resolveLatestNpmVersion/checkForNpmVersionUpdate, the daemon poll loop, and manual dkg update; 5 unit tests.
  2. test(cli): harden mcp-setup F14 probe — a pre-existing port-race flake surfaced by this PR's test-scheduling shift (F14 probed the shared default 9200 for an "unreachable" assertion). Now probes a guaranteed-free ephemeral port. Independent — cherry-pickable.
  3. feat(config): mainnet tracks a dedicated mainnet channel — see below.

Final config (symmetric — latest fully decoupled)

  • testnet.jsonchannel: "testnet"
  • mainnet-{base,gnosis,neuroweb}.jsonchannel: "mainnet", allowPrerelease: false (stable-only), enabled. No-ops until a build is published to the mainnet tag — launch-ready with no later enable-flip.
  • latest is followed by no auto-updating node (free for default npm install).

Resolution semantics

  • channel set → follow ONLY dist-tags[channel] (honours allowPrerelease; forward-only via the existing compareSemver gate; graceful no-op if the tag is absent).
  • channel unset → unchanged legacy max(latest, dev, beta, next).

Rollout (manual — NOT performed here)

  1. Publish this build to latest (existing testnet fleet's last old-logic hop) and to the testnet tag (target for the now-pinned nodes).
  2. ⚠️ Confirm the whole testnet fleet has converged onto this channel-aware build before you stop publishing testnet builds to latest. Long-offline nodes (still on old code following latest) need a manual dkg update.
  3. At mainnet launch: publish the mainnet stable build to the mainnet tag; mainnet nodes (already channel:mainnet, enabled) converge on it. Optionally point latest→mainnet for default installs.

Verification

  • Full Bura: cli suite green locally; tsc --noEmit clean.

🤖 Generated with Claude Code

Branimir Rakic and others added 3 commits June 22, 2026 20:17
Adds `autoUpdate.channel` — an npm dist-tag a node tracks for auto-updates.
When set, the updater follows ONLY `dist-tags[channel]` instead of the
default `max(latest, dev, beta, next)`, so a cohort tracks its own release
line without being captured by whatever `latest` points at.

Why: mainnet will ship on `latest` as the official product, but the existing
testnet fleet also follows `latest` and must NOT roll onto mainnet (junk
graphs, unexpected upgrades). Channel pinning decouples the two — testnet
nodes track the `testnet` tag, freeing `latest` to become mainnet later.

Code:
- `AutoUpdateConfig.channel` + `NetworkConfig.autoUpdate.channel`, threaded
  through `resolveAutoUpdateConfig`.
- `resolveLatestNpmVersion(log, allowPrerelease, channel?)`: when channel is
  set, resolve exactly that tag (still honouring allowPrerelease; graceful
  no-op when the tag does not exist yet). Default path unchanged.
- threaded through `checkForNpmVersionUpdate`, the daemon poll loop, and the
  manual `dkg update` path.
- 5 unit tests incl. the isolation property + missing-tag no-op.

Config (Option Y — decouple, do not hard-freeze):
- testnet.json: `channel: "testnet"` (stays live on its own tag).
- mainnet-{base,gnosis,neuroweb}.json: `enabled: false` (held off until
  launch; no node chases `latest`).

Rollout (NOT performed here): publish this build to BOTH `latest` (so the
fleet picks it up via the old logic on its last hop) AND `testnet` (so pinned
nodes have a target). Afterwards `latest` is free to point at mainnet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
F14 ("--no-start → faucet skipped when daemon not reachable") probed the
shared default port 9200, assuming nothing listens there. Under full-suite
parallelism another test file's daemon can bind 9200 the instant F14's
`/api/status` probe runs, making the daemon look reachable → faucet fires →
the assertion fails. The race is latent on main and surfaced by any change
that perturbs test scheduling (here: the added auto-update channel tests).

Fix: probe a guaranteed-unused ephemeral port (bind → capture → close) so the
connect() ECONNREFUSEs deterministically. Preserves the "real round-trip, no
fetch stub" design; only the probed port changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per review: rather than hold mainnet frozen (enabled:false) and later rely on
`latest`=mainnet, give mainnet its own dist-tag channel. Now NO network
depends on what `latest` points at — it's fully decoupled from auto-update
(free for default `npm install` convenience only).

- mainnet-{base,gnosis,neuroweb}: `channel: "mainnet"`, `allowPrerelease: false`
  (production = stable-only), enabled. No-ops until a build is published to the
  `mainnet` tag, so it's launch-ready with no later enable-flip needed (removes
  the "frozen node can't re-enable itself" wrinkle).
- testnet stays on `channel: "testnet"`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@otReviewAgent otReviewAgent left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Operational Notice: Review Agent could not complete this review.

Business logic reviewer failed: retry_exhausted

`dkg init` rebuilds the autoUpdate block when auto-update is enabled,
preserving `buildTimeoutMs` and `sshCommand` but (until now) dropping a
locally-pinned `channel`. An operator who pinned a node to a custom cohort
(e.g. "staging"/"qa") lost the pin on any rerun → silently fell back to the
network default (or legacy `latest` behaviour), defeating the per-node
channel guarantee. Preserve it alongside the other advanced unprompted fields.

Scope: only the rebuild-on-enable path (init.ts) was affected. The disabled
path keeps `existing.autoUpdate` verbatim; `ensure-dkg-node-config` never
rebuilds an existing block; the openclaw strip only removes a local field
that equals the network default (a differing pin is kept). Reported in #1295
review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@otReviewAgent otReviewAgent left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Operational Notice: Review Agent could not complete this review.

Business logic reviewer failed: retry_exhausted

…er guard

Addresses PR #1295 review round 2 (P1/P2/P3):

P1 — a pinned channel with no acceptable target (tag unpublished / prerelease
rejected / non-semver) was reported as a false "up-to-date". It now resolves
to a distinct `no-target` status, surfaced in `dkg update --check`, a daemon
WARNING log, and `/api/status` (`updateChannelTargetMissing`). The
runCheck→status mapping is extracted to a pure, unit-tested
`deriveUpdateCheckState`: `no-target` reports `upToDate:true` so `/api/status`
does NOT flip to `updateAvailable:true` (the trap), with `channelTargetMissing`
carrying the signal and cleared on the available/up-to-date paths. The
`dkg update` apply path handles `no-target` too. (The node already behaved
correctly — never updated to a missing target — this adds visibility.)

P2 — disabled-auto-apply + manual `dkg update`, and the daemon
version-check-only path, hardcoded `allowPrerelease:true` and dropped the
channel, letting an operator bypass the shipped `allowPrerelease:false`
intent. Both now read allowPrerelease + channel from config/network.

P3 — channel dist-tag values are validated as semver (`isLikelySemver`)
before use; malformed → `no-target` instead of an update attempt on garbage
(compareSemver NaN slipping the forward-only gate). The same guard covers the
default tag set at the gate without changing its candidate selection.

The `dkg init` channel-preservation finding was already fixed in 9f0eaed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@branarakic

Copy link
Copy Markdown
Contributor Author

Thanks for both reviews — all findings addressed across two commits.

Round 1 — dkg init drops local channel → fixed in 9f0eaed8e

dkg init now preserves a locally-pinned autoUpdate.channel across reruns, alongside buildTimeoutMs/sshCommand. Verified the sibling reconcilers are channel-safe: ensure-dkg-node-config only writes autoUpdate when none exists (never rebuilds), and the openclaw strip only removes a field that equals the network default (a differing pin is kept). So init was the only gap.

Round 2 (P1/P2/P3) — fixed in 0c73a0e33

  • P1 (false green): a pinned channel with no acceptable target (tag unpublished / prerelease rejected / non-semver) now resolves to a distinct no-target status, surfaced in dkg update --check, a daemon WARNING, and /api/status (updateChannelTargetMissing).

    Note the node already behaved correctly (it never updates to a missing target) — this adds visibility, not a behavior change. The runCheck→/api/status mapping is extracted to a pure, unit-tested deriveUpdateCheckState: no-target reports upToDate:true so updateAvailable does not flip to true (that mapping was the real trap), with channelTargetMissing cleared on the available/up-to-date paths. The dkg update apply path handles no-target too, not just --check.

  • P2 (manual/disabled bypass): the manual dkg update fallback and the daemon version-check-only path now read allowPrerelease+channel from config/network instead of hardcoding allowPrerelease:true/none — disabling auto-apply no longer drops the shipped stable-only intent.

  • P3 (semver): channel targets are isLikelySemver-validated before use (malformed → no-target, not an update attempt on garbage); the same guard sits at the gate to cover the default tag set without changing its candidate selection.

Test gaps

Added the caller-level deriveUpdateCheckState mapping tests (the runCheck→status derivation) + channel no-target/semver cases. The interactive dkg init wizard has no unit harness (its existing preserved fields buildTimeoutMs/sshCommand are untested for the same reason) — a dkg init test harness is a worthwhile separate follow-up rather than bundling it here.

Open question (mainnet enabled:false vs head)

Already reconciled — the PR body and head agree: mainnet is enabled:true + channel:"mainnet" + allowPrerelease:false, which no-ops until a build is published to the mainnet tag. (The earlier enabled:false was superseded by the 3rd commit; you reviewed before the body update.)

Full Bura: cli suite green locally (2092 passed, 0 failed); tsc --noEmit clean.

@otReviewAgent otReviewAgent left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Operational Notice: Review Agent could not complete this review.

Business logic reviewer failed: retry_exhausted

…aleness, real semver

Addresses PR #1295 review round 3:

P1 — `dkg init` could not persist "auto-update disabled": declining left the
local block absent, so resolveAutoUpdateConfig fell through to the enabled
network default. The persist decision is now a pure, exported
`buildInitAutoUpdate` (enable + decline paths) consumed unconditionally, so
declining writes `{ enabled: false }` (preserving existing advanced fields).
Covered by test/init.test.ts (decline→disabled, decline-preserves-channel,
enable-preserves-channel, persist-only-if-differs) — closing the init test gap.

P2 — the daemon version-check-only path resolved allowPrerelease/channel
network-before-config, reversing the resolver precedence (a round-2 regression).
Now config-before-network, matching resolveAutoUpdateConfig + manual dkg update.

P2 — `/api/status` could show a stale `latestVersion`. `deriveUpdateCheckState`
always emits `latestVersion`, cleared on up-to-date / no-target; daemon always
writes it.

P2/P3 — semver hardening: official semver grammar (rejects e.g.
`10.0.0-alpha..1`); build-metadata-aware prerelease detection so stable
`10.0.0+mainnet-build.1` is not rejected under allowPrerelease=false.

P3 — typed the auto-update status group (incl. `updateChannelTargetMissing`)
on `DaemonStatusResponse`.

P3 — OpenClaw setup strip no longer removes an explicit `channel` pin equal to
the network default.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@branarakic

Copy link
Copy Markdown
Contributor Author

Round 3 addressed — commit 3077b539c.

  • P1 — dkg init couldn't persist "disabled": declining auto-update now writes { enabled: false } (was leaving the block absent → resolveAutoUpdateConfig fell through to the enabled network default). The persist decision is extracted to a pure, exported buildInitAutoUpdate consumed unconditionally, and covered by a new test/init.test.ts (decline→disabled, decline-preserves-channel, enable-preserves-channel, persist-only-if-differs) — closing the init test gap flagged across rounds.
  • P2 — disabled-check precedence: the daemon version-check-only path resolved allowPrerelease/channel network-before-config (a round-2 regression). Now config-before-network, matching resolveAutoUpdateConfig + the manual dkg update fallback.
  • P2 — stale latestVersion: deriveUpdateCheckState now always emits latestVersion, cleared on up-to-date/no-target, and the daemon always writes it — no stale version can linger behind updateChannelTargetMissing:true.
  • P2/P3 — semver: replaced the loose regex with the official semver grammar (10.0.0-alpha..1 now rejected) and made prerelease detection build-metadata-aware (isPrerelease ignores +build), so 10.0.0+mainnet-build.1 is no longer rejected under allowPrerelease:false. Both cases have tests.
  • P3 — client type: typed the auto-update status group (incl. updateChannelTargetMissing) on DaemonStatusResponse.
  • P3 — OpenClaw strip: an explicit channel pin is no longer stripped when it equals the current network default (it's a deliberate cohort lock).

Remaining nit (not in this commit): updateChannelTargetMissing is on /api/status and logged by the daemon (WARNING), but not yet surfaced in dkg CLI status output / node-ui. Happy to wire that here or track as a fast-follow — your call.

Full Bura: cli suite green locally (2099 passed, 0 failed); tsc --noEmit clean.

@otReviewAgent otReviewAgent left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Operational Notice: Review Agent could not complete this review.

Business logic reviewer failed: retry_exhausted

@branarakic branarakic merged commit 539fa01 into main Jun 22, 2026
38 checks passed
@branarakic

Copy link
Copy Markdown
Contributor Author

Round-4 findings fixed in a follow-up PR (this one is merged): #1296.

  • P1 compareSemver build metadata → strips +build before parsing, so 10.0.0+mainnet-build.1 correctly outranks 10.0.0-rc.19 (round-3 patched isPrerelease but missed compareSemver). Direct regression test added.
  • P3 candidate masking → filter to valid semver before sorting; a garbage latest no longer masks a valid beta. Repro test added.
  • P2 manual-update bypass → explicit dkg update <rc> / dkg update latest-→-RC now refused on allowPrerelease:false nodes unless --allow-prerelease, via a tested resolveExplicitUpdateTarget (dist-tags resolved first; fails closed on a transient registry error, matching the no-arg path).
  • P2 GELF telemetryversionStatus now emits "channel-missing" instead of falsely "latest".

All four verified + tested; full Bura: cli suite green, compareSemver executed against a real version matrix (no ordering regression). The remaining P2/P3 — dkg status / node-ui surfacing of updateChannelTargetMissing is intentionally deferred per the team's call and tracked as a separate follow-up. Thanks again @jurij for the thorough passes.

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.

2 participants