diff --git a/.github/workflows/pr_validation.yml b/.github/workflows/pr_validation.yml
index a2522e263..8f1bb7380 100644
--- a/.github/workflows/pr_validation.yml
+++ b/.github/workflows/pr_validation.yml
@@ -156,3 +156,20 @@ jobs:
if: runner.os == 'Windows'
shell: pwsh
run: ./scripts/smoke/install-smoke.ps1
+
+ semver-conformance:
+ name: SemVer Conformance
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+
+ steps:
+ - name: "Checkout"
+ uses: actions/checkout@v6
+ with:
+ fetch-depth: 1
+
+ # The C# side (SemVerConformanceTests) runs in the Test job; this asserts the
+ # release-manifest generator's precedence key agrees with the same shared fixture,
+ # so the two implementations can't silently drift.
+ - name: "Generator precedence key matches the shared fixture"
+ run: python3 feeds/scripts/semver_key.py --check feeds/scripts/semver-order.txt
diff --git a/.github/workflows/publish_release_binaries.yml b/.github/workflows/publish_release_binaries.yml
index 46c934179..82d242a71 100644
--- a/.github/workflows/publish_release_binaries.yml
+++ b/.github/workflows/publish_release_binaries.yml
@@ -37,6 +37,22 @@ jobs:
TAG_SUFFIX="${TAG_VERSION#*-}"
fi
+ # Enforce the dotted prerelease convention: each '.'-separated identifier in the
+ # suffix must be all-letters or all-digits (e.g. 'beta.1'), never mixed ('beta1').
+ # A mixed identifier is one opaque alphanumeric token that SemVer orders
+ # lexically, so 'beta10' would rank BELOW 'beta2' — in this repo's comparator and
+ # in the release manifest. The dotted form makes the number a numeric identifier.
+ if [ -n "$TAG_SUFFIX" ]; then
+ IFS='.' read -ra _suffix_ids <<< "$TAG_SUFFIX"
+ for _id in "${_suffix_ids[@]}"; do
+ if ! [[ "$_id" =~ ^[A-Za-z]+$ || "$_id" =~ ^[0-9]+$ ]]; then
+ echo "Invalid prerelease identifier '$_id' in tag '$TAG_VERSION': mixes letters and digits." >&2
+ echo " Use the dotted form so the number orders numerically, e.g. '${TAG_PREFIX}-beta.1' (not '${TAG_PREFIX}-beta1')." >&2
+ exit 1
+ fi
+ done
+ fi
+
PROPS_PREFIX=$(python3 -c "import xml.etree.ElementTree as ET; root=ET.parse('Directory.Build.props').getroot(); node=root.find('.//VersionPrefix'); print((node.text or '').strip() if node is not None else '')")
PROPS_SUFFIX=$(python3 -c "import xml.etree.ElementTree as ET; root=ET.parse('Directory.Build.props').getroot(); node=root.find('.//VersionSuffix'); print((node.text or '').strip() if node is not None else '')")
diff --git a/Directory.Packages.props b/Directory.Packages.props
index e2c815ea6..199c2b07a 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -75,6 +75,7 @@
+
diff --git a/README.md b/README.md
index e3b19c5fe..42e4c218c 100644
--- a/README.md
+++ b/README.md
@@ -134,7 +134,7 @@ docker run -d --name netclawd \
```
Use `ghcr.io/netclaw-dev/netclaw:beta` to track the newest prerelease, or a pinned
-tag like `:0.19.0-beta1`. `:latest` only ever points at the latest stable release.
+tag like `:0.19.0-beta.1`. `:latest` only ever points at the latest stable release.
See the [Docker deployment guide](https://netclaw.dev/deployment/docker/) for
volume setup, environment variables, and Docker Compose examples.
@@ -164,8 +164,8 @@ docker pull ghcr.io/netclaw-dev/netclaw:beta
```
To pin an exact build instead of following the channel, name the version directly:
-`NETCLAW_VERSION=0.19.0-beta1` (Linux/macOS), `-Version 0.19.0-beta1` (Windows), or
-the `:0.19.0-beta1` image tag (Docker).
+`NETCLAW_VERSION=0.19.0-beta.1` (Linux/macOS), `-Version 0.19.0-beta.1` (Windows), or
+the `:0.19.0-beta.1` image tag (Docker).
For the full installation reference (including building from source), see the
[installation docs](https://netclaw.dev/getting-started/installation/).
diff --git a/feeds/scripts/generate-release-manifest.sh b/feeds/scripts/generate-release-manifest.sh
index 338fa48e2..4ada34f41 100755
--- a/feeds/scripts/generate-release-manifest.sh
+++ b/feeds/scripts/generate-release-manifest.sh
@@ -118,8 +118,13 @@ if ! command -v python3 >/dev/null 2>&1; then
exit 1
fi
-POINTERS=$(python3 - "$VERSION" "$MANIFEST_PATH" <<'PY'
-import json, sys
+POINTERS=$(SCRIPT_DIR="$SCRIPT_DIR" python3 - "$VERSION" "$MANIFEST_PATH" <<'PY'
+import json, os, sys
+
+# Import the shared precedence key (also used by the conformance check) so the generator
+# and the C# comparator are guaranteed to use the same SemVer ordering.
+sys.path.insert(0, os.environ["SCRIPT_DIR"])
+from semver_key import semver_key
version = sys.argv[1]
manifest_path = sys.argv[2]
@@ -132,27 +137,9 @@ try:
except Exception:
pass # no existing manifest (first release) — just this version
-
-def key(v):
- # Semver precedence: compare (major, minor, patch), then a stable release
- # outranks any prerelease of the same core, then prerelease identifiers
- # (numeric < alphanumeric, shorter prefix lower).
- core, _, pre = v.partition("-")
- pre = pre.split("+")[0] # drop build metadata
- parts = (core.split(".") + ["0", "0", "0"])[:3]
- try:
- nums = tuple(int(x) for x in parts)
- except ValueError:
- nums = (0, 0, 0)
- if not pre:
- return (nums, 1, ())
- ids = [(0, int(p), "") if p.isdigit() else (1, 0, p) for p in pre.split(".")]
- return (nums, 0, tuple(ids))
-
-
stable = [v for v in versions if "-" not in v]
-print(max(stable, key=key) if stable else "")
-print(max(versions, key=key))
+print(max(stable, key=semver_key) if stable else "")
+print(max(versions, key=semver_key))
PY
)
LATEST=$(printf '%s\n' "$POINTERS" | sed -n '1p')
diff --git a/feeds/scripts/semver-order.txt b/feeds/scripts/semver-order.txt
new file mode 100644
index 000000000..a8e9e4b6d
--- /dev/null
+++ b/feeds/scripts/semver-order.txt
@@ -0,0 +1,24 @@
+# Canonical SemVer 2.0.0 precedence fixture, in strictly ascending order.
+#
+# Shared conformance fixture for the two precedence implementations that MUST agree:
+# - C#: src/Netclaw.Configuration/Feeds/SemVer.cs (SemVerConformanceTests asserts this)
+# - bash/python: feeds/scripts/semver_key.py (`--check` asserts this)
+# If either implementation drifts, its check against this file fails CI.
+#
+# Lines are blank/`#`-comment tolerant. Keep entries in ascending precedence order.
+0.18.0
+0.18.1
+0.19.0-alpha.1
+0.19.0-alpha.2
+0.19.0-beta.1
+0.19.0-beta.2
+0.19.0-beta.9
+0.19.0-beta.10
+0.19.0-rc.1
+0.19.0
+0.19.1
+0.20.0-beta.1
+0.20.0
+1.0.0-alpha
+1.0.0-alpha.1
+1.0.0
diff --git a/feeds/scripts/semver_key.py b/feeds/scripts/semver_key.py
new file mode 100644
index 000000000..6afd7c501
--- /dev/null
+++ b/feeds/scripts/semver_key.py
@@ -0,0 +1,63 @@
+#!/usr/bin/env python3
+"""Shared SemVer 2.0.0 precedence key.
+
+Used by generate-release-manifest.sh to compute `latest` / `latestPrerelease`, and by the
+conformance check (`--check `) that guards against this key drifting from the C#
+comparator in src/Netclaw.Configuration/Feeds/SemVer.cs. Both sides are asserted against
+the same ordered fixture (feeds/scripts/semver-order.txt), so a divergence fails CI.
+
+Keep semver_key() in lockstep with SemVer.Compare in the C# comparator.
+"""
+import sys
+
+
+def semver_key(version):
+ """Return a tuple that sorts version strings by SemVer 2.0.0 precedence.
+
+ Build metadata is ignored. A version without a prerelease outranks one with the same
+ core; prerelease identifiers compare per spec (numeric < alphanumeric, numeric
+ compared as integers, longer identifier set wins when the shared prefix is equal).
+ """
+ core, _, pre = version.partition('-')
+ pre = pre.split('+')[0] # drop build metadata
+ parts = (core.split('.') + ['0', '0', '0'])[:3]
+ try:
+ nums = tuple(int(x) for x in parts)
+ except ValueError:
+ nums = (0, 0, 0)
+ if not pre:
+ return (nums, 1, ())
+ ids = [(0, int(p), '') if p.isdigit() else (1, 0, p) for p in pre.split('.')]
+ return (nums, 0, tuple(ids))
+
+
+def _read_versions(path):
+ with open(path) as f:
+ return [ln.strip() for ln in f if ln.strip() and not ln.lstrip().startswith('#')]
+
+
+def _main(argv):
+ # --check : assert the fixture's lines are already in ascending precedence
+ # order (i.e. sorting by semver_key reproduces the file). This is the bash side of the
+ # cross-language conformance test; the C# SemVerConformanceTests asserts the same file.
+ if len(argv) >= 3 and argv[1] == '--check':
+ versions = _read_versions(argv[2])
+ ordered = sorted(versions, key=semver_key)
+ if ordered != versions:
+ print('SemVer conformance FAILED: generator key disagrees with fixture order',
+ file=sys.stderr)
+ print(f' fixture order: {versions}', file=sys.stderr)
+ print(f' sorted order: {ordered}', file=sys.stderr)
+ return 1
+ print(f'SemVer conformance OK: {len(versions)} versions ordered identically')
+ return 0
+
+ # Default: sort the version strings on stdin and print them in ascending order.
+ versions = [ln.strip() for ln in sys.stdin if ln.strip()]
+ for v in sorted(versions, key=semver_key):
+ print(v)
+ return 0
+
+
+if __name__ == '__main__':
+ sys.exit(_main(sys.argv))
diff --git a/openspec/changes/release-channels/.openspec.yaml b/openspec/changes/release-channels/.openspec.yaml
new file mode 100644
index 000000000..0ba725fbf
--- /dev/null
+++ b/openspec/changes/release-channels/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-06-03
diff --git a/openspec/changes/release-channels/design.md b/openspec/changes/release-channels/design.md
new file mode 100644
index 000000000..d64cdef97
--- /dev/null
+++ b/openspec/changes/release-channels/design.md
@@ -0,0 +1,92 @@
+## Context
+
+Netclaw distributes self-contained binaries via a signed release feed
+(`releases.netclaw.dev/manifest.json`, minisign-verified per the
+`manifest-signature-verification` capability), Docker images on GHCR, and `curl | sh` /
+`iwr | iex` install scripts. The daemon and CLI poll the feed to notify operators of
+updates. Before this change there was exactly one channel: whatever tag was pushed last
+became "latest" everywhere, so a prerelease could not be published without leaking to all
+users (issue #1027). This design adds an opt-in **beta** channel across the publish and
+consume paths with one unifying invariant: *stable users never see prereleases.*
+
+## Goals / Non-Goals
+
+**Goals:**
+- Publish `x.y.z-beta.n` builds installable only by opt-in testers.
+- Keep default installs, Docker `:latest`, and the GitHub "Latest" release on stable.
+- Make the update check semver-correct and channel-aware.
+- Add zero new runtime dependencies; keep build-side and runtime-side version precedence
+ identical so the two never disagree.
+
+**Non-Goals:**
+- A `nightly` channel (tracked separately; see proposal follow-ups).
+- Bounding manifest growth (tracked in #1310).
+- Auto-downloading updates — the check stays advisory.
+
+## Decisions
+
+**One manifest with two pointers (not two manifests/feeds).** `latest` = newest stable,
+`latestPrerelease` = newest of all. Additive field, `schemaVersion` stays `1`, so old
+clients ignore it. *Alternative considered:* a separate `manifest-beta.json` / beta feed
+— rejected because betas are a small fraction of entries (it wouldn't reduce size) and it
+would add a second signed artifact, a second cache surface, and a separate URL to route.
+
+**Beta = newest of {stable, prerelease}, not "newest prerelease only".** Testers roll
+onto a stable release once it supersedes their beta, mirroring `dotnet --prerelease` /
+npm prerelease tags. *Alternative:* sticky prerelease line — rejected because it strands
+testers behind shipped stables.
+
+**Self-contained `SemVer` comparator, not `NuGet.Versioning`.** Inputs are our own
+well-formed tags, and the bash manifest generator already computes `latest`/
+`latestPrerelease` with the same precedence rules in ~10 lines of Python — a ~40-line C#
+comparator keeps the two in lockstep with no new dependency. *Alternative:*
+`NuGet.Versioning` — rejected as an avoidable direct dependency for a narrow need.
+
+**`BuildInfo.FullVersion` from `AssemblyInformationalVersion`.** `BuildInfo.Version`
+reads the numeric `AssemblyVersion`, which the .NET SDK strips the `-beta.1` suffix from —
+a beta build would report `0.19.0` and never see `0.19.0-beta.2`. `FullVersion` reads the
+informational version (SourceLink `{version}+{sha}`) and strips the `+sha`. The update
+check uses `FullVersion`; user-agent/`--version` display is left on `Version` to avoid
+churn on unrelated surfaces.
+
+**Channel rides the existing `DaemonConfig` seam.** `UpdateChannel` is read wherever
+`DisableSelfUpdate` already flows (background check, `netclaw update`, status). `doctor`
+loads it from `NetclawPaths` like its sibling checks. *Alternative:* a new injected
+"channel provider" — rejected; the value is already reachable at every call site.
+
+**Docker `:beta` is a retag, not a rebuild.** A dedicated job re-points `:beta` at the
+`latestPrerelease` image via `docker buildx imagetools create`, gated behind
+`publish-docker` + `publish-binary-manifest` so the source image exists and the
+semver-correct `latestPrerelease` is known. This handles "stable supersedes beta" and
+"old line patched while a newer beta is live" without the publish-docker job needing to
+reason about precedence.
+
+## Risks / Trade-offs
+
+- **Transitional manifest lacks `latestPrerelease`** → installers and the update check
+ fall back to `latest` (loud note in installers); the next release republishes the field.
+- **Prerelease ordering (`beta10` vs `beta2`)** → a single mixed-alphanumeric identifier
+ compares lexically (so `beta10 < beta2`), which is SemVer-correct but surprising.
+ Mitigation: the **dotted convention** (`beta.10`) makes the number a numeric identifier
+ that orders numerically in both the C# comparator and the bash generator, and the
+ release version gate **rejects** mixed identifiers like `beta1` so a non-dotted tag can
+ never ship.
+- **`latestPrerelease` drift between bash and C#** → mitigated by a shared ordered-version
+ fixture that BOTH a C# test (over `SemVer`) and a python check (over the generator's
+ precedence key) assert against, so a change to one precedence implementation that
+ diverges from the other fails CI.
+- **Doctor channel resolution on malformed config** → falls back to stable so the check
+ still runs; invalid enum values are surfaced by `ConfigSchemaDoctorCheck`, not masked.
+
+## Migration Plan
+
+No data migration. Schema change is additive (`Daemon.UpdateChannel` has a default;
+`netclaw doctor --fix` can insert it). Rollout is the normal release: the first publish
+after merge republishes the manifest with `latestPrerelease`. Rollback is reverting the
+release workflow / installer scripts; older clients are unaffected (they read only
+`latest`).
+
+## Open Questions
+
+- Whether to add a `nightly` channel later (a `:nightly` tag + `latestNightly` pointer +
+ `--channel nightly`) — out of scope here, captured for a future change.
diff --git a/openspec/changes/release-channels/proposal.md b/openspec/changes/release-channels/proposal.md
new file mode 100644
index 000000000..b3a9521a6
--- /dev/null
+++ b/openspec/changes/release-channels/proposal.md
@@ -0,0 +1,66 @@
+## Why
+
+Netclaw had no concept of a release *channel*: any pushed tag — including a semver
+prerelease like `0.19.0-beta.1` — became the de-facto "latest" everywhere (install
+scripts, Docker `:latest`, the GitHub "Latest" release, and the update check). A real
+`0.19.0-beta.1` was abandoned and a plain `0.18.1` shipped instead, because the pipeline
+could not publish a prerelease for opt-in testers without leaking it to every fresh
+install (GitHub issue #1027). This change introduces a public **beta** channel so we can
+ship prereleases to testers who explicitly opt in, while default installs and stable
+users are never affected.
+
+## What Changes
+
+- **Release feed manifest** gains an additive `latestPrerelease` pointer
+ (newest of {stable, prerelease}) alongside `latest` (newest stable). `schemaVersion`
+ stays `1`; the field is ignored by older clients (not breaking).
+- **Installers** gain channel selection: `install.sh --channel beta` and
+ `install.ps1 -Channel beta` resolve to `latestPrerelease`. An explicit version pin
+ (`NETCLAW_VERSION` / `-Version`) overrides the channel; an unknown channel fails loudly.
+- **Docker** gains a rolling `:beta` tag that tracks `latestPrerelease`; `:latest`
+ (and `:major.minor`) only ever point at the newest stable.
+- **Prerelease-aware publishing**: a tag containing `-` is marked a GitHub *prerelease*,
+ does not move the floating stable tags, and the CI version gate validates
+ `VersionPrefix` + `VersionSuffix` (not the verbatim tag).
+- **Channel-aware update check**: a new `Daemon.UpdateChannel` (`stable` default | `beta`)
+ governs the daemon notification, `netclaw update`, the CLI startup notice,
+ `netclaw status`, and `netclaw doctor`. Stable clients are never offered a prerelease.
+- **SemVer-2.0.0-correct version comparison** replaces `System.Version`, which could not
+ parse prerelease suffixes (and so silently never offered a prerelease). The running
+ binary's self-version is read from the assembly informational version
+ (`BuildInfo.FullVersion`, retains `-beta.1`) rather than the suffix-stripped
+ `AssemblyVersion`.
+- The update check remains **advisory only** — it emits a notice/alert and never
+ auto-downloads; `Daemon.DisableSelfUpdate` continues to block in-place update while the
+ check still runs.
+
+## Capabilities
+
+### New Capabilities
+- `release-channels`: the stable/beta channel model end-to-end — manifest pointer
+ semantics, prerelease-aware publishing (GitHub release flag, Docker tag policy, CI
+ version gate), installer + Docker channel selection, and the channel-aware update-check
+ policy (`Daemon.UpdateChannel`, stable-never-prerelease invariant, SemVer comparison,
+ full-version self-identification, advisory-only behavior).
+
+### Modified Capabilities
+
+
+## Impact
+
+- **Build/release**: `feeds/scripts/generate-release-manifest.sh`,
+ `.github/workflows/publish_release_binaries.yml` (GitHub release flag, Docker tags +
+ `:beta` job, version gate), `scripts/install.sh`, `scripts/install.ps1`,
+ `scripts/smoke/install-smoke.{sh,ps1}`, `README.md`.
+- **Runtime (.NET)**: `BinaryFeedManifest` (`LatestPrerelease`), new `SemVer` comparator,
+ `UpdateCheckService` (channel-aware `EvaluateManifest`/`CheckForUpdateAsync`, semver
+ `IsNewerVersion`), `BuildInfo.FullVersion` (Configuration + Daemon facade),
+ `DaemonConfig.UpdateChannel` + `netclaw-config.v1.schema.json`, and the consumers:
+ `BinaryUpdateCheckService`, `UpdateCommand`, `StatusUpdateChecker`,
+ `UpdateAvailableDoctorCheck`, `Program.cs` wiring.
+- **No new dependencies**: the SemVer comparator is self-contained (no `NuGet.Versioning`),
+ kept in sync with the bash manifest generator's precedence rules.
+- **Delivery**: merged PR netclaw-dev/netclaw#1314 (publish + install half) and the
+ in-flight update-check PR on `feat/update-check-channel-aware`. Follow-up: manifest
+ unbounded-growth (#1310).
diff --git a/openspec/changes/release-channels/specs/release-channels/spec.md b/openspec/changes/release-channels/specs/release-channels/spec.md
new file mode 100644
index 000000000..2b0b19b12
--- /dev/null
+++ b/openspec/changes/release-channels/specs/release-channels/spec.md
@@ -0,0 +1,221 @@
+# release-channels Specification
+
+## ADDED Requirements
+
+### Requirement: Release feed manifest channel pointers
+
+The release feed manifest (`releases/manifest.json`) SHALL expose two version
+pointers: `latest` (the newest **stable** version) and `latestPrerelease` (the newest
+version of any kind — stable or prerelease). `latestPrerelease` SHALL always be greater
+than or equal to `latest` by SemVer precedence. Both pointers SHALL be computed over the
+union of the version being published and all versions already in the manifest. The field
+is additive and `schemaVersion` SHALL remain `1`.
+
+#### Scenario: Publishing a prerelease does not move `latest`
+
+- **WHEN** a prerelease version (e.g. `0.19.0-beta.1`) is published while the newest
+ stable is `0.18.1`
+- **THEN** `latest` remains `0.18.1`
+- **AND** `latestPrerelease` becomes `0.19.0-beta.1`
+- **AND** the prerelease's assets are appended to `releases[]`
+
+#### Scenario: A stable release supersedes a prior prerelease
+
+- **WHEN** stable `0.19.0` is published after prerelease `0.19.0-beta.1`
+- **THEN** `latest` becomes `0.19.0`
+- **AND** `latestPrerelease` becomes `0.19.0` (the newest of all versions)
+
+#### Scenario: Older clients ignore the new field
+
+- **WHEN** a client built before this change deserializes the manifest
+- **THEN** the unknown `latestPrerelease` field is ignored and deserialization succeeds
+
+### Requirement: Installer channel selection
+
+The install scripts SHALL accept a channel selector (`install.sh --channel `,
+`install.ps1 -Channel `) defaulting to `stable`. The `beta` channel SHALL
+resolve to the manifest's `latestPrerelease`, falling back to `latest` only when
+`latestPrerelease` is absent (a manifest published before this capability). An explicit
+version pin (`NETCLAW_VERSION` / `-Version`) SHALL override the channel. An unrecognized
+channel value SHALL fail loudly rather than silently defaulting.
+
+#### Scenario: Default install resolves to latest stable
+
+- **WHEN** the installer is run with no channel argument
+- **THEN** it installs the manifest's `latest` (stable) version
+
+#### Scenario: Beta channel resolves to the newest prerelease
+
+- **WHEN** the installer is run with `--channel beta` (or `-Channel beta`)
+- **THEN** it installs the manifest's `latestPrerelease` version
+
+#### Scenario: Explicit version pin overrides the channel
+
+- **WHEN** `NETCLAW_VERSION` (or `-Version`) is set alongside `--channel beta`
+- **THEN** the pinned version is installed regardless of channel
+
+#### Scenario: Unknown channel is rejected
+
+- **WHEN** the installer is run with an unrecognized channel value
+- **THEN** the installer exits non-zero with an error and installs nothing
+
+### Requirement: Prerelease-aware publishing
+
+The release pipeline SHALL treat a tag containing a hyphen (`-`) as a prerelease.
+A prerelease publish SHALL be marked a GitHub *prerelease*, SHALL NOT move the floating
+stable Docker tags (`:latest`, `:major.minor`), and SHALL publish only its exact-version
+Docker tag. A rolling Docker `:beta` tag SHALL track `latestPrerelease`. The CI version
+gate SHALL validate the tag against `` + `` rather than the
+verbatim tag string.
+
+#### Scenario: Prerelease tag is marked and does not move `:latest`
+
+- **WHEN** a tag like `0.19.0-beta.1` is published
+- **THEN** the GitHub release is flagged as a prerelease
+- **AND** Docker `:latest` and `:major.minor` are unchanged
+- **AND** only `ghcr.io/netclaw-dev/netclaw:0.19.0-beta.1` is pushed for that build
+
+#### Scenario: Stable tag moves the floating stable tags
+
+- **WHEN** a stable tag like `0.19.0` is published
+- **THEN** Docker `:latest` and `:0.19` are moved to it
+- **AND** the GitHub release is not flagged as a prerelease
+
+#### Scenario: `:beta` tracks the newest prerelease
+
+- **WHEN** `latestPrerelease` is `0.19.0-beta.1`
+- **THEN** `ghcr.io/netclaw-dev/netclaw:beta` resolves to that image
+
+#### Scenario: Version gate validates prefix and suffix
+
+- **WHEN** tag `0.19.0-beta.1` is published with `0.19.0`
+ and `beta.1`
+- **THEN** the version gate passes
+- **AND** a tag whose prefix/suffix do not match the props fails the gate
+
+### Requirement: Prerelease tags use dotted identifiers
+
+A prerelease tag's suffix SHALL use dot-separated identifiers where each identifier is
+either all-letters or all-digits (e.g. `beta.1`, `rc.10`) — never a mixed token like
+`beta1`. The release version gate SHALL reject a tag containing a mixed-alphanumeric
+identifier. This keeps a numeric part a numeric identifier, so `beta.10` outranks
+`beta.2` consistently in both the C# comparator and the manifest generator (a mixed
+`beta10` would compare lexically and rank below `beta2`).
+
+#### Scenario: Dotted prerelease tag is accepted
+
+- **WHEN** a tag `0.19.0-beta.1` is published
+- **THEN** the release version gate accepts it
+
+#### Scenario: Mixed-identifier prerelease tag is rejected
+
+- **WHEN** a tag `0.19.0-beta1` is published
+- **THEN** the release version gate fails with guidance to use the dotted form `0.19.0-beta.1`
+
+### Requirement: Update channel configuration
+
+The daemon configuration SHALL expose `Daemon.UpdateChannel` with values `stable`
+(default) and `beta`, validated by the config schema. An unrecognized value SHALL fail
+loudly. This single setting SHALL govern every client-side update surface (daemon
+notification, `netclaw update`, CLI startup notice, `netclaw status`, `netclaw doctor`).
+
+#### Scenario: Defaults to stable when unset
+
+- **WHEN** `Daemon.UpdateChannel` is absent from configuration
+- **THEN** the resolved channel is `stable`
+
+#### Scenario: Unknown channel value is rejected
+
+- **WHEN** `Daemon.UpdateChannel` is set to an unrecognized value
+- **THEN** configuration binding throws rather than silently defaulting
+
+### Requirement: Stable clients are never offered a prerelease
+
+When the configured channel is `stable`, the update check SHALL only ever compare the
+running version against the manifest's `latest` pointer. It SHALL NOT read
+`latestPrerelease` and SHALL NOT offer a prerelease to a stable client under any
+manifest contents.
+
+#### Scenario: A newer prerelease is not offered to a stable client
+
+- **WHEN** a stable client checks for updates and the manifest has `latest=0.18.1`,
+ `latestPrerelease=0.19.0-beta.1`
+- **THEN** no update is reported
+
+#### Scenario: A newer stable is offered to a stable client
+
+- **WHEN** a stable client on `0.18.1` checks and the manifest has `latest=0.19.0`
+- **THEN** an update to `0.19.0` is reported
+
+### Requirement: Beta clients track the newest version
+
+When the configured channel is `beta`, the update check SHALL compare the running
+version against `latestPrerelease` (the newest of {stable, prerelease}). A beta client
+SHALL be offered a newer prerelease, and SHALL be rolled onto a stable release once it
+supersedes the running prerelease.
+
+#### Scenario: Beta client is offered the next prerelease
+
+- **WHEN** a beta client on `0.19.0-beta.1` checks and `latestPrerelease=0.19.0-beta.2`
+- **THEN** an update to `0.19.0-beta.2` is reported
+
+#### Scenario: Beta client rolls onto a superseding stable
+
+- **WHEN** a beta client on `0.19.0-beta.1` checks after stable `0.19.0` shipped
+ (`latestPrerelease=0.19.0`)
+- **THEN** an update to `0.19.0` is reported
+
+#### Scenario: Beta client on the newest prerelease has no update
+
+- **WHEN** a beta client on `0.19.0-beta.1` checks and `latestPrerelease=0.19.0-beta.1`
+- **THEN** no update is reported
+
+### Requirement: SemVer-correct version comparison
+
+The update check SHALL compare versions using SemVer 2.0.0 precedence: a version with a
+prerelease suffix has lower precedence than the same core version without one, and
+prerelease identifiers are compared per the specification (numeric identifiers lower than
+alphanumeric, longer identifier sets higher when prefixes match). Build metadata SHALL be
+ignored. An unparseable version SHALL be treated as "no update available" (fail safe).
+
+#### Scenario: A prerelease precedes its own release
+
+- **WHEN** comparing `0.19.0-beta.1` against `0.19.0`
+- **THEN** `0.19.0` is the newer version
+
+#### Scenario: Unparseable version yields no update
+
+- **WHEN** either the running version or the candidate cannot be parsed as SemVer
+- **THEN** no update is reported
+
+### Requirement: Self-version includes the prerelease suffix
+
+The update check SHALL identify the running binary's version from the assembly
+informational version (which retains the prerelease suffix, e.g. `0.19.0-beta.1`), not the
+numeric assembly version (which strips it). This prevents a beta build from reporting its
+core version and stranding on its own prerelease line.
+
+#### Scenario: A beta build reports its full version
+
+- **WHEN** a binary built from tag `0.19.0-beta.1` reports its version for the update check
+- **THEN** the reported version is `0.19.0-beta.1`, not `0.19.0`
+
+### Requirement: Update check is advisory only
+
+The update check SHALL never download or install an update on its own. When an update is
+available it SHALL surface a notice (CLI) and/or an operational alert (daemon). In-place
+self-update SHALL require an explicit `netclaw update`. When `Daemon.DisableSelfUpdate`
+is true, in-place update SHALL be blocked while the availability check still runs and
+notifies.
+
+#### Scenario: Available update notifies without downloading
+
+- **WHEN** the background check finds an available update
+- **THEN** a notice/alert is produced
+- **AND** no binary is downloaded or replaced
+
+#### Scenario: Self-update disabled still checks and notifies
+
+- **WHEN** `Daemon.DisableSelfUpdate` is true and an update is available
+- **THEN** the availability check still runs and emits a notice
+- **AND** `netclaw update` declines to perform an in-place update
diff --git a/openspec/changes/release-channels/tasks.md b/openspec/changes/release-channels/tasks.md
new file mode 100644
index 000000000..7236feab7
--- /dev/null
+++ b/openspec/changes/release-channels/tasks.md
@@ -0,0 +1,47 @@
+# Tasks
+
+Most work is already implemented across PR #1314 (merged) and the in-flight update-check
+PR; checkboxes reflect actual state.
+
+## 1. Release feed manifest pointers (PR #1314)
+
+- [x] 1.1 Compute `latest` (newest stable) + additive `latestPrerelease` (newest of all) with semver precedence in `feeds/scripts/generate-release-manifest.sh`
+- [x] 1.2 Keep `schemaVersion` at 1; preserve `releases[]` accumulation
+- [x] 1.3 Add `LatestPrerelease` to `BinaryFeedManifest` (additive, ignored by old clients)
+
+## 2. Installer + Docker channel selection (PR #1314)
+
+- [x] 2.1 `install.sh --channel ` with precedence pin > channel > stable; loud failure on unknown channel; loud fallback to `latest` when `latestPrerelease` absent
+- [x] 2.2 `install.ps1 -Channel ` mirroring the same precedence
+- [x] 2.3 Docker `:beta` retag job tracking `latestPrerelease`; `:latest`/`:major.minor` suppressed for prerelease tags
+- [x] 2.4 README "Beta / prerelease versions" section
+
+## 3. Prerelease-aware publishing (PR #1314)
+
+- [x] 3.1 GitHub release `prerelease: ${{ contains(github.ref_name, '-') }}`
+- [x] 3.2 CI version gate validates `VersionPrefix` + `VersionSuffix`
+
+## 4. Update channel configuration (update-check PR)
+
+- [x] 4.1 Add `Daemon.UpdateChannel` (`stable` default | `beta`) to `DaemonConfig` + `ParseUpdateChannel` (loud on unknown)
+- [x] 4.2 Add `UpdateChannel` enum to schema (`netclaw-config.v1.schema.json`, string enum + default)
+
+## 5. Channel-aware, semver-correct update check (update-check PR)
+
+- [x] 5.1 Add self-contained `SemVer` comparator (no `NuGet.Versioning`); reimplement `IsNewerVersion` on it
+- [x] 5.2 Add `BuildInfo.FullVersion` (Configuration + Daemon facade) reading informational version
+- [x] 5.3 Make `EvaluateManifest`/`CheckForUpdateAsync` channel-aware (stable → `latest` only; beta → `latestPrerelease`)
+- [x] 5.4 Thread channel + `FullVersion` through `BinaryUpdateCheckService`, `UpdateCommand`, `StatusUpdateChecker`, `UpdateAvailableDoctorCheck`, and `Program.cs`
+
+## 6. Tests
+
+- [x] 6.1 `SemVerTests` (precedence rules, build metadata, unparseable fail-safe)
+- [x] 6.2 `UpdateChannelEvaluationTests` (stable-never-prerelease, beta tracks/rolls onto stable, fallback)
+- [x] 6.3 `DaemonConfig.ParseUpdateChannel` tests (known/empty/unknown)
+- [x] 6.4 `install-smoke.{sh,ps1}` beta-channel assertions (default→stable, `--channel beta`→prerelease, pin overrides, unknown rejected)
+
+## 7. Delivery
+
+- [x] 7.1 PR #1314 (publish + install half) merged to `dev`
+- [ ] 7.2 Open the update-check PR (draft) into `dev` and get CI green
+- [ ] 7.3 Sync delta spec to `openspec/specs/release-channels/` and archive this change once both PRs land
diff --git a/scripts/install.ps1 b/scripts/install.ps1
index 3bfcbb1f6..41647b7ba 100644
--- a/scripts/install.ps1
+++ b/scripts/install.ps1
@@ -9,7 +9,7 @@
# .\install.ps1 -DryRun
#
# -Channel beta installs the newest prerelease (or latest stable if no prerelease
-# exists). -Version pins an exact version and overrides -Channel (e.g. 0.19.0-beta1).
+# exists). -Version pins an exact version and overrides -Channel (e.g. 0.19.0-beta.1).
param(
[ValidateSet("all", "cli", "daemon")]
diff --git a/scripts/install.sh b/scripts/install.sh
index cf443a5e9..42bde7148 100755
--- a/scripts/install.sh
+++ b/scripts/install.sh
@@ -16,7 +16,7 @@
#
# Environment variables:
# INSTALL_DIR — Install directory (default: ~/.netclaw/bin)
-# NETCLAW_VERSION — Specific version to install (overrides --channel; e.g. 0.19.0-beta1)
+# NETCLAW_VERSION — Specific version to install (overrides --channel; e.g. 0.19.0-beta.1)
set -euo pipefail
diff --git a/src/Netclaw.Cli/Doctor/UpdateAvailableDoctorCheck.cs b/src/Netclaw.Cli/Doctor/UpdateAvailableDoctorCheck.cs
index 3e2013085..20ba169b1 100644
--- a/src/Netclaw.Cli/Doctor/UpdateAvailableDoctorCheck.cs
+++ b/src/Netclaw.Cli/Doctor/UpdateAvailableDoctorCheck.cs
@@ -1,4 +1,4 @@
-// -----------------------------------------------------------------------
+// -----------------------------------------------------------------------
//
// Copyright (C) 2026 - 2026 Petabridge, LLC
//
@@ -9,13 +9,20 @@
namespace Netclaw.Cli.Doctor;
///
-/// Checks if a newer version of Netclaw is available.
+/// Checks if a newer version of Netclaw is available on the configured update channel.
/// Uses a short timeout to avoid slowing down doctor runs.
///
public sealed class UpdateAvailableDoctorCheck : IDoctorCheck
{
private const string CheckName = "Update";
+ private readonly UpdateChannel _channel;
+
+ // Take the channel from the bound DaemonConfig (the same value every other update
+ // surface uses) rather than re-reading netclaw.json — no bespoke config parsing, and
+ // a malformed value is reported by ConfigSchemaDoctorCheck instead of crashing here.
+ public UpdateAvailableDoctorCheck(DaemonConfig daemonConfig) => _channel = daemonConfig.UpdateChannel;
+
public async Task RunAsync(CancellationToken cancellationToken = default)
{
try
@@ -24,8 +31,11 @@ public async Task RunAsync(CancellationToken cancellationToke
cts.CancelAfter(TimeSpan.FromSeconds(5));
using var httpClient = new HttpClient();
+ // FullVersion so a beta build reports its prerelease suffix; the configured
+ // channel keeps doctor consistent with the daemon/CLI (a beta user is told
+ // about the next beta, a stable user only about stable releases).
var result = await UpdateCheckService.CheckForUpdateAsync(
- httpClient, BuildInfo.Version, cts.Token);
+ httpClient, BuildInfo.FullVersion, cts.Token, _channel);
if (result.IsUpdateAvailable)
{
@@ -41,7 +51,7 @@ public async Task RunAsync(CancellationToken cancellationToke
catch
{
return DoctorCheckResult.Pass(CheckName,
- $"Could not check for updates (v{BuildInfo.Version}).");
+ $"Could not check for updates (v{BuildInfo.FullVersion}).");
}
}
}
diff --git a/src/Netclaw.Cli/Program.cs b/src/Netclaw.Cli/Program.cs
index 93ed4afff..311ce985e 100644
--- a/src/Netclaw.Cli/Program.cs
+++ b/src/Netclaw.Cli/Program.cs
@@ -90,7 +90,7 @@ static async Task RunAsync(string[] args)
{
var backgroundUpdateConfig = BuildCliConfig();
var backgroundDaemonConfig = DaemonConfig.BindFromConfiguration(backgroundUpdateConfig.GetSection("Daemon"));
- _ = UpdateCommand.BackgroundUpdateCheckAsync(backgroundDaemonConfig.DisableSelfUpdate);
+ _ = UpdateCommand.BackgroundUpdateCheckAsync(backgroundDaemonConfig.DisableSelfUpdate, backgroundDaemonConfig.UpdateChannel);
}
// ── Lightweight modes (no Akka, no persistence) ──
@@ -864,7 +864,7 @@ static async Task RunAsync(string[] args)
using var host = builder.Build();
var paths = host.Services.GetRequiredService();
var daemonConfig = host.Services.GetRequiredService();
- Environment.ExitCode = await UpdateCommand.RunAsync(args, paths, daemonConfig.DisableSelfUpdate);
+ Environment.ExitCode = await UpdateCommand.RunAsync(args, paths, daemonConfig.DisableSelfUpdate, daemonConfig.UpdateChannel);
return;
}
@@ -1338,7 +1338,9 @@ static async Task RunStatusAsync(IServiceProvider services, bool jsonOutput
// Start CLI update check concurrently with daemon status fetch (3s timeout, non-blocking).
using var updateCts = new CancellationTokenSource();
var updateClient = httpClientFactory.CreateClient();
- var updateTask = StatusUpdateChecker.CheckAsync(updateClient, BuildInfo.Version, updateCts.Token);
+ var updateChannel = services.GetRequiredService().UpdateChannel;
+ var updateTask = StatusUpdateChecker.CheckAsync(
+ updateClient, BuildInfo.FullVersion, updateCts.Token, channel: updateChannel);
try
{
diff --git a/src/Netclaw.Cli/Update/StatusUpdateChecker.cs b/src/Netclaw.Cli/Update/StatusUpdateChecker.cs
index c79f8b210..248bfc291 100644
--- a/src/Netclaw.Cli/Update/StatusUpdateChecker.cs
+++ b/src/Netclaw.Cli/Update/StatusUpdateChecker.cs
@@ -3,6 +3,7 @@
// Copyright (C) 2026 - 2026 Petabridge, LLC
//
// -----------------------------------------------------------------------
+using Netclaw.Configuration;
using Netclaw.Configuration.Feeds;
namespace Netclaw.Cli.Update;
@@ -23,7 +24,8 @@ internal static async Task CheckAsync(
HttpClient httpClient,
string currentVersion,
CancellationToken cancellationToken = default,
- TimeSpan? timeout = null)
+ TimeSpan? timeout = null,
+ UpdateChannel channel = UpdateChannel.Stable)
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(timeout ?? TimeSpan.FromSeconds(3));
@@ -35,7 +37,7 @@ internal static async Task CheckAsync(
return new StatusUpdateResult("unknown", currentVersion, null, null,
$"{fetchResult.Status}: {fetchResult.ErrorMessage}");
- var result = UpdateCheckService.EvaluateManifest(fetchResult.Manifest!, currentVersion);
+ var result = UpdateCheckService.EvaluateManifest(fetchResult.Manifest!, currentVersion, channel);
return result.IsUpdateAvailable
? new StatusUpdateResult("update-available", result.CurrentVersion, result.LatestVersion, result.ReleaseNotesUrl)
: new StatusUpdateResult("up-to-date", result.CurrentVersion, null, null);
diff --git a/src/Netclaw.Cli/Update/UpdateCommand.cs b/src/Netclaw.Cli/Update/UpdateCommand.cs
index 58b551296..d68dfb463 100644
--- a/src/Netclaw.Cli/Update/UpdateCommand.cs
+++ b/src/Netclaw.Cli/Update/UpdateCommand.cs
@@ -47,7 +47,7 @@ internal static bool ShouldRunStartupUpdateCheck(string mode, string[] args)
}
}
- public static async Task RunAsync(string[] args, NetclawPaths paths, bool selfUpdateDisabled = false)
+ public static async Task RunAsync(string[] args, NetclawPaths paths, bool selfUpdateDisabled = false, UpdateChannel channel = UpdateChannel.Stable)
{
var checkOnly = false;
var force = false;
@@ -72,7 +72,7 @@ public static async Task RunAsync(string[] args, NetclawPaths paths, bool s
}
}
- var currentVersion = BuildInfo.Version;
+ var currentVersion = BuildInfo.FullVersion;
using var httpClient = TestHttpMessageHandlerFactory is { } createHandler
? new HttpClient(createHandler())
@@ -99,7 +99,7 @@ public static async Task RunAsync(string[] args, NetclawPaths paths, bool s
return 1;
}
- var result = UpdateCheckService.EvaluateManifest(fetchResult.Manifest!, currentVersion);
+ var result = UpdateCheckService.EvaluateManifest(fetchResult.Manifest!, currentVersion, channel);
if (!result.IsUpdateAvailable)
{
@@ -394,14 +394,14 @@ internal static void WriteHelp()
/// notification in a static buffer if an update is available; emitted by
/// when the program is about to exit.
///
- internal static async Task BackgroundUpdateCheckAsync(bool selfUpdateDisabled = false)
+ internal static async Task BackgroundUpdateCheckAsync(bool selfUpdateDisabled = false, UpdateChannel channel = UpdateChannel.Stable)
{
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
using var httpClient = new HttpClient();
var result = await UpdateCheckService.CheckForUpdateAsync(
- httpClient, BuildInfo.Version, cts.Token);
+ httpClient, BuildInfo.FullVersion, cts.Token, channel);
if (result.IsUpdateAvailable)
{
diff --git a/src/Netclaw.Configuration.Tests/DaemonConfigTests.cs b/src/Netclaw.Configuration.Tests/DaemonConfigTests.cs
index be567d31a..8eff8eac7 100644
--- a/src/Netclaw.Configuration.Tests/DaemonConfigTests.cs
+++ b/src/Netclaw.Configuration.Tests/DaemonConfigTests.cs
@@ -96,6 +96,30 @@ public void ParseExposureMode_returns_local_for_null_or_empty(string? value)
Assert.Equal(ExposureMode.Local, DaemonConfig.ParseExposureMode(value));
}
+ [Theory]
+ [InlineData("stable", UpdateChannel.Stable)]
+ [InlineData("beta", UpdateChannel.Beta)]
+ [InlineData("BETA", UpdateChannel.Beta)]
+ public void ParseUpdateChannel_parses_known_values(string value, UpdateChannel expected)
+ {
+ Assert.Equal(expected, DaemonConfig.ParseUpdateChannel(value));
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ public void ParseUpdateChannel_returns_stable_for_null_or_empty(string? value)
+ {
+ Assert.Equal(UpdateChannel.Stable, DaemonConfig.ParseUpdateChannel(value));
+ }
+
+ [Fact]
+ public void ParseUpdateChannel_throws_on_unknown_value()
+ {
+ Assert.Throws(
+ () => DaemonConfig.ParseUpdateChannel("nightly"));
+ }
+
[Fact]
public void BindFromConfiguration_reads_DisableSelfUpdate_true()
{
diff --git a/src/Netclaw.Configuration.Tests/Netclaw.Configuration.Tests.csproj b/src/Netclaw.Configuration.Tests/Netclaw.Configuration.Tests.csproj
index 3e4e0195f..762769752 100644
--- a/src/Netclaw.Configuration.Tests/Netclaw.Configuration.Tests.csproj
+++ b/src/Netclaw.Configuration.Tests/Netclaw.Configuration.Tests.csproj
@@ -10,11 +10,19 @@
+
+
+
+
+
+
diff --git a/src/Netclaw.Configuration.Tests/SemVerConformanceTests.cs b/src/Netclaw.Configuration.Tests/SemVerConformanceTests.cs
new file mode 100644
index 000000000..651a2d42a
--- /dev/null
+++ b/src/Netclaw.Configuration.Tests/SemVerConformanceTests.cs
@@ -0,0 +1,62 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (C) 2026 - 2026 Petabridge, LLC
+//
+// -----------------------------------------------------------------------
+using Netclaw.Configuration.Feeds;
+using Xunit;
+
+namespace Netclaw.Configuration.Tests;
+
+///
+/// Cross-language conformance: the C# comparator must order the
+/// shared fixture (feeds/scripts/semver-order.txt) identically to the bash/python
+/// release-manifest generator key (feeds/scripts/semver_key.py, asserted in CI via its
+/// --check mode). Both sides validate against the same file, so if either
+/// precedence implementation drifts from the canonical order, its check fails.
+///
+public sealed class SemVerConformanceTests
+{
+ private static List ReadFixture()
+ {
+ var path = Path.Combine(AppContext.BaseDirectory, "TestData", "semver-order.txt");
+ Assert.True(File.Exists(path), $"conformance fixture not found at {path}");
+ return File.ReadAllLines(path)
+ .Select(line => line.Trim())
+ .Where(line => line.Length > 0 && !line.StartsWith('#'))
+ .ToList();
+ }
+
+ [Fact]
+ public void Fixture_is_in_strictly_ascending_precedence_order()
+ {
+ var versions = ReadFixture();
+ Assert.True(versions.Count > 1, "fixture should contain multiple versions");
+
+ for (var i = 1; i < versions.Count; i++)
+ {
+ Assert.True(SemVer.IsNewer(versions[i - 1], versions[i]),
+ $"expected '{versions[i]}' to outrank '{versions[i - 1]}'");
+ Assert.False(SemVer.IsNewer(versions[i], versions[i - 1]),
+ $"'{versions[i - 1]}' must not outrank '{versions[i]}'");
+ }
+ }
+
+ [Fact]
+ public void Sorting_by_SemVer_reproduces_the_fixture_order()
+ {
+ var fixture = ReadFixture();
+
+ // Start from the reversed fixture (deterministic, not already in canonical order)
+ // and sort with the comparator — the result must equal the canonical fixture.
+ var sorted = Enumerable.Reverse(fixture)
+ .OrderBy(v => v, Comparer.Create((a, b) =>
+ {
+ Assert.True(SemVer.TryCompare(a, b, out var c), $"unparseable version: '{a}' or '{b}'");
+ return c;
+ }))
+ .ToList();
+
+ Assert.Equal(fixture, sorted);
+ }
+}
diff --git a/src/Netclaw.Configuration.Tests/SemVerPropertyTests.cs b/src/Netclaw.Configuration.Tests/SemVerPropertyTests.cs
new file mode 100644
index 000000000..36295dbdd
--- /dev/null
+++ b/src/Netclaw.Configuration.Tests/SemVerPropertyTests.cs
@@ -0,0 +1,126 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (C) 2026 - 2026 Petabridge, LLC
+//
+// -----------------------------------------------------------------------
+using CsCheck;
+using Netclaw.Configuration.Feeds;
+using Xunit;
+
+namespace Netclaw.Configuration.Tests;
+
+///
+/// Property-based tests for : instead of fixed examples, generate
+/// thousands of random valid versions and assert the comparator obeys the algebraic laws
+/// of a SemVer-2.0.0 total order plus the spec's precedence anchors. A failure shrinks to
+/// a minimal counterexample.
+///
+public sealed class SemVerPropertyTests
+{
+ private const long Iter = 100_000;
+
+ // A prerelease identifier: a numeric identifier (multi-digit, so the dotted form
+ // beta.10 vs beta.2 is exercised) or an alphanumeric one.
+ private static readonly Gen GenPreId =
+ Gen.OneOf(
+ Gen.Int[0, 30].Select(n => n.ToString()),
+ Gen.OneOfConst("alpha", "beta", "rc", "x", "a1", "1a"));
+
+ // A valid SemVer string: small core components (so collisions exercise the
+ // equal/transitive paths) plus 0-3 prerelease identifiers.
+ private static readonly Gen GenVersion =
+ Gen.Select(Gen.Int[0, 3], Gen.Int[0, 3], Gen.Int[0, 3], GenPreId.Array[0, 3],
+ (major, minor, patch, pre) =>
+ {
+ var core = $"{major}.{minor}.{patch}";
+ return pre.Length == 0 ? core : $"{core}-{string.Join('.', pre)}";
+ });
+
+ [Fact]
+ public void Every_generated_version_parses_and_equals_itself()
+ => GenVersion.Sample(
+ v => SemVer.TryCompare(v, v, out var c) && c == 0,
+ iter: Iter);
+
+ [Fact]
+ public void Comparison_is_antisymmetric()
+ => Gen.Select(GenVersion, GenVersion).Sample(
+ pair =>
+ {
+ var (a, b) = pair;
+ SemVer.TryCompare(a, b, out var ab);
+ SemVer.TryCompare(b, a, out var ba);
+ return Math.Sign(ab) == -Math.Sign(ba);
+ },
+ iter: Iter);
+
+ [Fact]
+ public void Comparison_is_transitive()
+ => Gen.Select(GenVersion, GenVersion, GenVersion).Sample(
+ triple =>
+ {
+ var (a, b, c) = triple;
+ SemVer.TryCompare(a, b, out var ab);
+ SemVer.TryCompare(b, c, out var bc);
+ SemVer.TryCompare(a, c, out var ac);
+ // a <= b and b <= c => a <= c
+ return !(ab <= 0 && bc <= 0) || ac <= 0;
+ },
+ iter: Iter);
+
+ [Fact]
+ public void IsNewer_is_consistent_with_compare()
+ => Gen.Select(GenVersion, GenVersion).Sample(
+ pair =>
+ {
+ var (a, b) = pair;
+ SemVer.TryCompare(a, b, out var ab);
+ return SemVer.IsNewer(a, b) == (ab < 0);
+ },
+ iter: Iter);
+
+ [Fact]
+ public void Build_metadata_does_not_affect_precedence()
+ => Gen.Select(GenVersion, Gen.OneOfConst("build", "sha.1", "abc123", "2026.06.03"))
+ .Sample(
+ pair =>
+ {
+ var (v, meta) = pair;
+ SemVer.TryCompare(v, $"{v}+{meta}", out var c);
+ return c == 0;
+ },
+ iter: Iter);
+
+ [Fact]
+ public void Stable_outranks_its_own_prerelease()
+ => Gen.Select(Gen.Int[0, 3], Gen.Int[0, 3], Gen.Int[0, 3], GenPreId.Array[1, 3],
+ (major, minor, patch, pre) =>
+ {
+ var core = $"{major}.{minor}.{patch}";
+ return (stable: core, prerelease: $"{core}-{string.Join('.', pre)}");
+ })
+ .Sample(
+ pair =>
+ {
+ SemVer.TryCompare(pair.stable, pair.prerelease, out var c);
+ return c > 0;
+ },
+ iter: Iter);
+
+ [Fact]
+ public void Numeric_identifier_ranks_below_alphanumeric()
+ => Gen.Select(Gen.Int[0, 3], Gen.Int[0, 3], Gen.Int[0, 3], Gen.Int[0, 9],
+ Gen.OneOfConst("alpha", "beta", "x"),
+ (major, minor, patch, num, alpha) =>
+ {
+ var core = $"{major}.{minor}.{patch}";
+ return (numeric: $"{core}-{num}", alphanumeric: $"{core}-{alpha}");
+ })
+ .Sample(
+ pair =>
+ {
+ SemVer.TryCompare(pair.numeric, pair.alphanumeric, out var c);
+ return c < 0;
+ },
+ iter: Iter);
+}
diff --git a/src/Netclaw.Configuration.Tests/SemVerTests.cs b/src/Netclaw.Configuration.Tests/SemVerTests.cs
new file mode 100644
index 000000000..deaa6bce0
--- /dev/null
+++ b/src/Netclaw.Configuration.Tests/SemVerTests.cs
@@ -0,0 +1,66 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (C) 2026 - 2026 Petabridge, LLC
+//
+// -----------------------------------------------------------------------
+using Netclaw.Configuration.Feeds;
+using Xunit;
+
+namespace Netclaw.Configuration.Tests;
+
+public sealed class SemVerTests
+{
+ [Theory]
+ // Core (major.minor.patch) precedence.
+ [InlineData("0.18.1", "0.19.0", true)]
+ [InlineData("0.19.0", "0.18.1", false)]
+ [InlineData("0.19.0", "0.19.0", false)]
+ [InlineData("0.18.1", "0.18.2", true)]
+ [InlineData("1.0.0", "2.0.0", true)]
+ // A stable release outranks its own prereleases.
+ [InlineData("0.19.0-beta1", "0.19.0", true)]
+ [InlineData("0.19.0", "0.19.0-beta1", false)]
+ // Prerelease progression.
+ [InlineData("0.19.0-beta1", "0.19.0-beta2", true)]
+ [InlineData("0.19.0-beta2", "0.19.0-beta1", false)]
+ [InlineData("0.19.0-beta1", "0.19.0-beta1", false)]
+ // Cross-version: a higher-minor beta beats a lower stable patch.
+ [InlineData("0.18.2", "0.19.0-beta1", true)]
+ [InlineData("0.19.0-beta1", "0.18.2", false)]
+ // Numeric prerelease identifiers have LOWER precedence than alphanumeric ones.
+ [InlineData("1.0.0-1", "1.0.0-alpha", true)]
+ [InlineData("1.0.0-alpha", "1.0.0-1", false)]
+ // Dot-separated numeric identifiers compare numerically (the robust beta form).
+ [InlineData("1.0.0-beta.2", "1.0.0-beta.10", true)]
+ [InlineData("1.0.0-beta.10", "1.0.0-beta.2", false)]
+ // The repo's dotted prerelease convention: beta.10 must outrank beta.2. The release
+ // version gate rejects the non-dotted form (beta10), which would compare lexically
+ // and (incorrectly) rank below beta2.
+ [InlineData("0.19.0-beta.2", "0.19.0-beta.10", true)]
+ [InlineData("0.19.0-beta.9", "0.19.0-beta.10", true)]
+ // When a prefix is equal, more identifiers => higher precedence.
+ [InlineData("1.0.0-alpha", "1.0.0-alpha.1", true)]
+ // Build metadata is ignored.
+ [InlineData("1.0.0+aaa", "1.0.0+bbb", false)]
+ [InlineData("1.0.0", "1.0.0+bbb", false)]
+ // Unparseable input is never "newer" (fail safe).
+ [InlineData("garbage", "1.0.0", false)]
+ [InlineData("1.0.0", "garbage", false)]
+ public void IsNewer_FollowsSemVerPrecedence(string current, string candidate, bool expected)
+ => Assert.Equal(expected, SemVer.IsNewer(current, candidate));
+
+ [Theory]
+ [InlineData("garbage", "1.0.0")]
+ [InlineData("1.0.0", "")]
+ [InlineData("1.2.3.4", "1.0.0")] // too many core components
+ [InlineData("1.0.0-", "1.0.0")] // empty prerelease identifier
+ public void TryCompare_ReturnsFalse_ForUnparseable(string a, string b)
+ => Assert.False(SemVer.TryCompare(a, b, out _));
+
+ [Fact]
+ public void TryCompare_TreatsBuildMetadataAsEqual()
+ {
+ Assert.True(SemVer.TryCompare("1.2.3+abc", "1.2.3+def", out var cmp));
+ Assert.Equal(0, cmp);
+ }
+}
diff --git a/src/Netclaw.Configuration/BuildInfo.cs b/src/Netclaw.Configuration/BuildInfo.cs
index d5a527245..4e4187c02 100644
--- a/src/Netclaw.Configuration/BuildInfo.cs
+++ b/src/Netclaw.Configuration/BuildInfo.cs
@@ -34,6 +34,16 @@ public static Assembly TargetAssembly
///
public static string Version => TargetAssembly.GetName().Version?.ToString(3) ?? "0.0.0";
+ ///
+ /// Full semver version including any prerelease suffix (e.g. "0.19.0-beta.1").
+ /// Read from (which retains the
+ /// suffix), with the SourceLink "+{sha}" build metadata stripped. Unlike
+ /// — which reads the numeric AssemblyVersion and so
+ /// loses the prerelease suffix — this is what the update check must compare, or a
+ /// beta build (e.g. "0.19.0-beta.1") would report "0.19.0" and strand on its beta.
+ ///
+ public static string FullVersion => GetFullVersion(TargetAssembly);
+
///
/// Short git commit hash (first 7 chars of the SHA embedded by SourceLink),
/// or "unknown" if the assembly was built outside a git repository.
@@ -54,6 +64,25 @@ public static Assembly TargetAssembly
public static string GetVersion(Assembly assembly) =>
assembly.GetName().Version?.ToString(3) ?? "0.0.0";
+ ///
+ /// Reads the full semver (incl. prerelease suffix) from a specific assembly's
+ /// , stripping SourceLink's
+ /// "+{sha}" build metadata. Falls back to the numeric
+ /// only when the informational attribute is absent.
+ ///
+ public static string GetFullVersion(Assembly assembly)
+ {
+ var informational = assembly
+ .GetCustomAttribute()
+ ?.InformationalVersion;
+
+ if (string.IsNullOrEmpty(informational))
+ return GetVersion(assembly);
+
+ var plusIndex = informational.IndexOf('+', StringComparison.Ordinal);
+ return plusIndex >= 0 ? informational[..plusIndex] : informational;
+ }
+
public static string ResolveCommitHash(Assembly assembly)
{
var informational = assembly
diff --git a/src/Netclaw.Configuration/DaemonConfig.cs b/src/Netclaw.Configuration/DaemonConfig.cs
index 139577cdb..166dc0ea9 100644
--- a/src/Netclaw.Configuration/DaemonConfig.cs
+++ b/src/Netclaw.Configuration/DaemonConfig.cs
@@ -45,6 +45,14 @@ public sealed record DaemonConfig
///
public bool DisableSelfUpdate { get; init; }
+ ///
+ /// Release channel the update check follows.
+ /// (default) only ever sees stable releases;
+ /// opts into prereleases (and rolls onto a stable release once it supersedes a beta).
+ /// Stable clients are never offered a prerelease.
+ ///
+ public UpdateChannel UpdateChannel { get; init; } = UpdateChannel.Stable;
+
///
/// Explicitly trusted reverse-proxy source addresses or CIDR ranges. Only used when
/// is .
@@ -72,6 +80,7 @@ public static DaemonConfig BindFromConfiguration(IConfigurationSection? section)
var modeStr = section["ExposureMode"];
var mode = ParseExposureMode(modeStr);
var disableSelfUpdate = section.GetValue("DisableSelfUpdate") ?? false;
+ var updateChannel = ParseUpdateChannel(section["UpdateChannel"]);
var trustedProxies = section.GetSection("TrustedProxies").Get() ?? [];
var skipTunnelProcessCheck = section.GetValue("SkipTunnelProcessCheck") ?? false;
@@ -81,11 +90,31 @@ public static DaemonConfig BindFromConfiguration(IConfigurationSection? section)
Port = port,
ExposureMode = mode,
DisableSelfUpdate = disableSelfUpdate,
+ UpdateChannel = updateChannel,
TrustedProxies = trustedProxies,
SkipTunnelProcessCheck = skipTunnelProcessCheck
};
}
+ ///
+ /// Parses an from a config string value.
+ /// Returns for null/empty input; throws on an
+ /// unknown value rather than silently defaulting (a typo'd channel should fail loudly).
+ ///
+ public static UpdateChannel ParseUpdateChannel(string? value)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ return UpdateChannel.Stable;
+
+ return value.Trim().ToLowerInvariant() switch
+ {
+ "stable" => UpdateChannel.Stable,
+ "beta" => UpdateChannel.Beta,
+ _ => throw new InvalidOperationException(
+ $"Unknown UpdateChannel value: '{value.Trim()}'. Valid values: stable, beta.")
+ };
+ }
+
///
/// Parses an from a config string value.
/// Accepts kebab-case (tailscale-serve) and PascalCase (TailscaleServe).
@@ -284,3 +313,18 @@ public static bool TryGetInvalidTrustedProxy(IEnumerable trustedProxies,
public sealed record DaemonExposureValidationIssue(string Message, string Remediation, bool IsTrustedProxyIssue = false);
public sealed record ParsedTrustedProxy(string RawValue, IPAddress Address, int? PrefixLength);
+
+///
+/// Release channel the update check follows. Bound from Daemon.UpdateChannel.
+///
+public enum UpdateChannel
+{
+ /// Stable releases only (default). Never offered a prerelease.
+ Stable,
+
+ ///
+ /// Opt into prereleases. Resolves to the newest of {stable, prerelease}, so a
+ /// stable release that supersedes a beta is still offered.
+ ///
+ Beta,
+}
diff --git a/src/Netclaw.Configuration/Feeds/BinaryFeedManifest.cs b/src/Netclaw.Configuration/Feeds/BinaryFeedManifest.cs
index bbdf0459f..619a6d8a2 100644
--- a/src/Netclaw.Configuration/Feeds/BinaryFeedManifest.cs
+++ b/src/Netclaw.Configuration/Feeds/BinaryFeedManifest.cs
@@ -25,6 +25,14 @@ public sealed class BinaryFeedManifest
[JsonPropertyName("latest")]
public string Latest { get; init; } = "";
+ ///
+ /// Newest version of any kind — stable or prerelease (always >= ).
+ /// What the beta channel resolves to. Empty on manifests published before the
+ /// prerelease channel existed; stable clients never read this field.
+ ///
+ [JsonPropertyName("latestPrerelease")]
+ public string LatestPrerelease { get; init; } = "";
+
[JsonPropertyName("releases")]
public List Releases { get; init; } = [];
}
diff --git a/src/Netclaw.Configuration/Feeds/SemVer.cs b/src/Netclaw.Configuration/Feeds/SemVer.cs
new file mode 100644
index 000000000..904f76cae
--- /dev/null
+++ b/src/Netclaw.Configuration/Feeds/SemVer.cs
@@ -0,0 +1,152 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (C) 2026 - 2026 Petabridge, LLC
+//
+// -----------------------------------------------------------------------
+namespace Netclaw.Configuration.Feeds;
+
+///
+/// Minimal SemVer 2.0.0 precedence comparison for release version strings
+/// (major.minor.patch[-prerelease][+build]). Build metadata is ignored.
+///
+/// We deliberately roll our own narrow comparator rather than take a
+/// NuGet.Versioning dependency: the only inputs are our own well-formed
+/// release tags, and the bash release-manifest generator
+/// (feeds/scripts/generate-release-manifest.sh) computes
+/// latest/latestPrerelease with the exact same precedence rules — the
+/// two must agree, so keeping the logic small and co-located avoids drift.
+///
+public static class SemVer
+{
+ ///
+ /// Returns true if has strictly higher SemVer
+ /// precedence than . Returns false if either string
+ /// cannot be parsed — fail safe, so the update check never offers a version it
+ /// can't reason about.
+ ///
+ public static bool IsNewer(string current, string candidate)
+ => TryCompare(current, candidate, out var cmp) && cmp < 0;
+
+ ///
+ /// Compares and by SemVer precedence.
+ /// On success sets to a value <0, 0, or >0
+ /// (the sign of "a relative to b") and returns true. Returns false if either
+ /// string is not a parseable version.
+ ///
+ public static bool TryCompare(string a, string b, out int comparison)
+ {
+ comparison = 0;
+ if (!TryParse(a, out var pa) || !TryParse(b, out var pb))
+ return false;
+ comparison = Compare(pa, pb);
+ return true;
+ }
+
+ private readonly record struct Parsed(int Major, int Minor, int Patch, string[] Pre);
+
+ private static bool TryParse(string version, out Parsed parsed)
+ {
+ parsed = default;
+ if (string.IsNullOrWhiteSpace(version))
+ return false;
+
+ var v = version.Trim();
+
+ // Strip build metadata (everything from the first '+').
+ var plus = v.IndexOf('+', StringComparison.Ordinal);
+ if (plus >= 0)
+ v = v[..plus];
+
+ // Split core from the prerelease label at the first '-'.
+ string core;
+ string pre;
+ var dash = v.IndexOf('-', StringComparison.Ordinal);
+ if (dash >= 0)
+ {
+ core = v[..dash];
+ pre = v[(dash + 1)..];
+ // A '-' must introduce a prerelease label; a trailing '-' is malformed.
+ if (pre.Length == 0)
+ return false;
+ }
+ else
+ {
+ core = v;
+ pre = "";
+ }
+
+ var coreParts = core.Split('.');
+ if (coreParts.Length is < 1 or > 3)
+ return false;
+
+ // Accept "0", "0.1", or "0.1.2" — pad omitted components with zero.
+ var nums = new int[3];
+ for (var i = 0; i < coreParts.Length; i++)
+ {
+ if (!int.TryParse(coreParts[i], out nums[i]) || nums[i] < 0)
+ return false;
+ }
+
+ string[] preIds;
+ if (pre.Length == 0)
+ {
+ preIds = [];
+ }
+ else
+ {
+ preIds = pre.Split('.');
+ // Reject empty identifiers, e.g. "1.0.0-" or "1.0.0-a..b".
+ foreach (var id in preIds)
+ if (id.Length == 0)
+ return false;
+ }
+
+ parsed = new Parsed(nums[0], nums[1], nums[2], preIds);
+ return true;
+ }
+
+ private static int Compare(Parsed a, Parsed b)
+ {
+ var c = a.Major.CompareTo(b.Major);
+ if (c != 0) return c;
+ c = a.Minor.CompareTo(b.Minor);
+ if (c != 0) return c;
+ c = a.Patch.CompareTo(b.Patch);
+ if (c != 0) return c;
+
+ // A version with no prerelease outranks one that has a prerelease with the
+ // same core (1.0.0 > 1.0.0-beta.1).
+ var aPre = a.Pre.Length > 0;
+ var bPre = b.Pre.Length > 0;
+ if (!aPre && !bPre) return 0;
+ if (!aPre) return 1;
+ if (!bPre) return -1;
+
+ // Both prerelease: compare dot-separated identifiers left to right.
+ var shared = Math.Min(a.Pre.Length, b.Pre.Length);
+ for (var i = 0; i < shared; i++)
+ {
+ c = ComparePreIdentifier(a.Pre[i], b.Pre[i]);
+ if (c != 0) return c;
+ }
+
+ // All shared identifiers equal → the longer identifier set has higher
+ // precedence (1.0.0-beta.1 < 1.0.0-beta.1.1).
+ return a.Pre.Length.CompareTo(b.Pre.Length);
+ }
+
+ private static int ComparePreIdentifier(string a, string b)
+ {
+ // Parse as long (not int): numeric identifiers can be large (timestamps, build
+ // counters), and the bash generator compares them with Python's unbounded int —
+ // long covers any realistic value and keeps the two implementations in agreement.
+ var aNumeric = long.TryParse(a, out var ai);
+ var bNumeric = long.TryParse(b, out var bi);
+
+ if (aNumeric && bNumeric) return ai.CompareTo(bi);
+ // Numeric identifiers always have lower precedence than alphanumeric ones.
+ if (aNumeric) return -1;
+ if (bNumeric) return 1;
+ return string.CompareOrdinal(a, b);
+ }
+}
diff --git a/src/Netclaw.Configuration/Feeds/UpdateCheckService.cs b/src/Netclaw.Configuration/Feeds/UpdateCheckService.cs
index f0558c3cd..7408f53a0 100644
--- a/src/Netclaw.Configuration/Feeds/UpdateCheckService.cs
+++ b/src/Netclaw.Configuration/Feeds/UpdateCheckService.cs
@@ -69,30 +69,29 @@ public enum ManifestFetchStatus
///
public static class UpdateCheckService
{
- private static UpdateCheckResult? s_cachedResult;
- private static DateTimeOffset s_cachedAt;
- private static readonly TimeSpan CacheDuration = TimeSpan.FromHours(1);
+ // Last computed result, kept so the daemon /status API can report update availability
+ // via GetLastResult() without its own network fetch. This is a last-result store, NOT
+ // a TTL cache — every CheckForUpdateAsync call recomputes (a cache keyed only on
+ // freshness would silently return another channel's result; the daemon already
+ // throttles network calls via its 24h recheck timer).
+ private static UpdateCheckResult? s_lastResult;
///
- /// Returns the most recent cached result, or null if no check has been performed yet.
+ /// Returns the most recent result, or null if no check has been performed yet.
///
- public static UpdateCheckResult? GetLastResult() => s_cachedResult;
+ public static UpdateCheckResult? GetLastResult() => s_lastResult;
///
- /// Fetches the binary manifest and compares the latest version against the current version.
- /// Returns a cached result if one exists and is less than 1 hour old.
+ /// Fetches the binary manifest and compares the latest version for
+ /// against . Records the result for .
/// Never throws — returns a "no update" result on any failure.
///
public static async Task CheckForUpdateAsync(
HttpClient httpClient,
string currentVersion,
- CancellationToken cancellationToken = default)
+ CancellationToken cancellationToken,
+ UpdateChannel channel)
{
- // Return cached result if fresh
- var cached = s_cachedResult;
- if (cached is not null && DateTimeOffset.UtcNow - s_cachedAt < CacheDuration)
- return cached;
-
try
{
var fetchResult = await FetchVerifiedManifestAsync(httpClient, cancellationToken);
@@ -106,12 +105,12 @@ public static async Task CheckForUpdateAsync(
CurrentVersion = currentVersion,
LatestVersion = currentVersion,
};
- CacheResult(failed);
+ RecordResult(failed);
return failed;
}
- var result = EvaluateManifest(fetchResult.Manifest!, currentVersion);
- CacheResult(result);
+ var result = EvaluateManifest(fetchResult.Manifest!, currentVersion, channel);
+ RecordResult(result);
return result;
}
catch (Exception ex) when (ex is not OperationCanceledException)
@@ -124,7 +123,7 @@ public static async Task CheckForUpdateAsync(
CurrentVersion = currentVersion,
LatestVersion = currentVersion,
};
- CacheResult(failed);
+ RecordResult(failed);
return failed;
}
}
@@ -229,32 +228,38 @@ public static async Task FetchVerifiedManifestAsync(
};
}
- private static void CacheResult(UpdateCheckResult result)
- {
- s_cachedResult = result;
- s_cachedAt = DateTimeOffset.UtcNow;
- }
+ private static void RecordResult(UpdateCheckResult result) => s_lastResult = result;
///
- /// Clears the cached result. Used by tests to avoid cross-test interference.
+ /// Clears the recorded result. Used by tests to avoid cross-test interference.
///
- public static void ResetCache()
- {
- s_cachedResult = null;
- s_cachedAt = default;
- }
+ public static void ResetCache() => s_lastResult = null;
///
- /// Evaluates a manifest against the current version and RID.
+ /// Evaluates a manifest against the current version, RID, and release channel.
/// Pure function — no I/O.
///
+ ///
+ /// The decides which version pointer is compared:
+ /// only ever reads ,
+ /// so a stable client can never be offered a prerelease.
+ /// reads (the newest of {stable, prerelease}),
+ /// falling back to Latest only on manifests published before the prerelease channel existed.
+ ///
public static UpdateCheckResult EvaluateManifest(
BinaryFeedManifest manifest,
- string currentVersion)
+ string currentVersion,
+ UpdateChannel channel)
{
var rid = GetCurrentRid();
- if (string.IsNullOrEmpty(manifest.Latest))
+ // Resolve the target pointer for the channel. Beta uses latestPrerelease when
+ // present (always >= latest); an older manifest without it falls back to latest.
+ var targetVersion = channel == UpdateChannel.Beta && !string.IsNullOrEmpty(manifest.LatestPrerelease)
+ ? manifest.LatestPrerelease
+ : manifest.Latest;
+
+ if (string.IsNullOrEmpty(targetVersion))
{
return new UpdateCheckResult
{
@@ -264,13 +269,13 @@ public static UpdateCheckResult EvaluateManifest(
};
}
- var isNewer = IsNewerVersion(currentVersion, manifest.Latest);
+ var isNewer = IsNewerVersion(currentVersion, targetVersion);
- // Find the latest release entry
- var latestRelease = manifest.Releases
- .FirstOrDefault(r => r.Version == manifest.Latest);
+ // Find the release entry for the resolved target version.
+ var targetRelease = manifest.Releases
+ .FirstOrDefault(r => r.Version == targetVersion);
- var matchingAssets = latestRelease?.Assets
+ var matchingAssets = targetRelease?.Assets
.Where(a => string.Equals(a.Rid, rid, StringComparison.OrdinalIgnoreCase))
.ToList() ?? [];
@@ -278,8 +283,8 @@ public static UpdateCheckResult EvaluateManifest(
{
IsUpdateAvailable = isNewer && matchingAssets.Count > 0,
CurrentVersion = currentVersion,
- LatestVersion = manifest.Latest,
- ReleaseNotesUrl = latestRelease?.ReleaseNotesUrl,
+ LatestVersion = targetVersion,
+ ReleaseNotesUrl = targetRelease?.ReleaseNotesUrl,
MatchingAssets = matchingAssets,
};
}
@@ -296,16 +301,11 @@ public static string GetCurrentRid()
///
/// Returns true if is newer than .
+ /// Uses SemVer 2.0.0 precedence (see ), so prerelease versions
+ /// like "0.19.0-beta.1" compare correctly — unlike , which
+ /// cannot parse a prerelease suffix at all. On a parse failure this returns false
+ /// (fail safe: never offer an update we can't reason about).
///
public static bool IsNewerVersion(string current, string latest)
- {
- if (Version.TryParse(current, out var currentVersion)
- && Version.TryParse(latest, out var latestVersion))
- {
- return latestVersion > currentVersion;
- }
-
- // If parsing fails, treat as no update to avoid false positives
- return false;
- }
+ => SemVer.IsNewer(current, latest);
}
diff --git a/src/Netclaw.Configuration/Schemas/netclaw-config.v1.schema.json b/src/Netclaw.Configuration/Schemas/netclaw-config.v1.schema.json
index f8296cca9..775f1bcfa 100644
--- a/src/Netclaw.Configuration/Schemas/netclaw-config.v1.schema.json
+++ b/src/Netclaw.Configuration/Schemas/netclaw-config.v1.schema.json
@@ -652,6 +652,15 @@
"type": "boolean",
"default": false,
"description": "When true, in-place binary self-update via 'netclaw update' is blocked. Update availability checks still run. Intended for container deployments where the image tag is the version."
+ },
+ "UpdateChannel": {
+ "type": "string",
+ "enum": [
+ "stable",
+ "beta"
+ ],
+ "default": "stable",
+ "description": "Release channel the update check follows. 'stable' (default) only sees stable releases; 'beta' opts into prereleases and rolls onto a stable release once it supersedes a beta. Stable clients are never offered a prerelease."
}
},
"additionalProperties": false
diff --git a/src/Netclaw.Daemon.Tests/Services/BinaryUpdateCheckServiceTests.cs b/src/Netclaw.Daemon.Tests/Services/BinaryUpdateCheckServiceTests.cs
index 16d41ebc1..60963d41e 100644
--- a/src/Netclaw.Daemon.Tests/Services/BinaryUpdateCheckServiceTests.cs
+++ b/src/Netclaw.Daemon.Tests/Services/BinaryUpdateCheckServiceTests.cs
@@ -132,7 +132,7 @@ public async Task CheckForUpdateAsync_ReturnsUpdateWhenNewerVersionAvailable()
using var httpClient = new HttpClient(handler);
var result = await UpdateCheckService.CheckForUpdateAsync(
- httpClient, "0.1.0", TestContext.Current.CancellationToken);
+ httpClient, "0.1.0", TestContext.Current.CancellationToken, UpdateChannel.Stable);
Assert.True(result.IsUpdateAvailable);
Assert.Equal("0.1.0", result.CurrentVersion);
@@ -148,7 +148,7 @@ public async Task CheckForUpdateAsync_ReturnsNoUpdateWhenAlreadyLatest()
using var httpClient = new HttpClient(handler);
var result = await UpdateCheckService.CheckForUpdateAsync(
- httpClient, "0.1.0", TestContext.Current.CancellationToken);
+ httpClient, "0.1.0", TestContext.Current.CancellationToken, UpdateChannel.Stable);
Assert.False(result.IsUpdateAvailable);
}
@@ -161,7 +161,7 @@ public async Task CheckForUpdateAsync_ReturnsNoUpdateWhenNewerThanManifest()
using var httpClient = new HttpClient(handler);
var result = await UpdateCheckService.CheckForUpdateAsync(
- httpClient, "0.2.0", TestContext.Current.CancellationToken);
+ httpClient, "0.2.0", TestContext.Current.CancellationToken, UpdateChannel.Stable);
Assert.False(result.IsUpdateAvailable);
}
@@ -174,7 +174,7 @@ public async Task CheckForUpdateAsync_ReturnsNoUpdateOnNetworkFailure()
using var httpClient = new HttpClient(handler);
var result = await UpdateCheckService.CheckForUpdateAsync(
- httpClient, "0.1.0", TestContext.Current.CancellationToken);
+ httpClient, "0.1.0", TestContext.Current.CancellationToken, UpdateChannel.Stable);
Assert.False(result.IsUpdateAvailable);
Assert.Equal("0.1.0", result.CurrentVersion);
@@ -188,7 +188,7 @@ public async Task CheckForUpdateAsync_UsesDedicatedReleasesManifestEndpoint()
using var httpClient = new HttpClient(handler);
var result = await UpdateCheckService.CheckForUpdateAsync(
- httpClient, "0.1.0", TestContext.Current.CancellationToken);
+ httpClient, "0.1.0", TestContext.Current.CancellationToken, UpdateChannel.Stable);
Assert.True(result.IsUpdateAvailable);
Assert.Equal("0.2.0", result.LatestVersion);
@@ -204,7 +204,7 @@ public async Task CheckForUpdateAsync_ReturnsNoUpdateOnMissingSignature()
using var httpClient = new HttpClient(handler);
var result = await UpdateCheckService.CheckForUpdateAsync(
- httpClient, "0.1.0", TestContext.Current.CancellationToken);
+ httpClient, "0.1.0", TestContext.Current.CancellationToken, UpdateChannel.Stable);
// Signature failure treated as network failure → no update
Assert.False(result.IsUpdateAvailable);
@@ -309,7 +309,7 @@ public void EvaluateManifest_MatchesAssetsByRid()
]
};
- var result = UpdateCheckService.EvaluateManifest(manifest, "0.1.0");
+ var result = UpdateCheckService.EvaluateManifest(manifest, "0.1.0", UpdateChannel.Stable);
Assert.True(result.IsUpdateAvailable);
// The matching count depends on the current RID at test runtime; the
@@ -344,7 +344,7 @@ public void EvaluateManifest_ReturnsNoUpdateWhenNoMatchingRid()
]
};
- var result = UpdateCheckService.EvaluateManifest(manifest, "0.1.0");
+ var result = UpdateCheckService.EvaluateManifest(manifest, "0.1.0", UpdateChannel.Stable);
// Update exists in manifest but no assets match current RID
Assert.False(result.IsUpdateAvailable);
@@ -378,7 +378,7 @@ public void EvaluateManifest_IncludesReleaseNotesUrl()
]
};
- var result = UpdateCheckService.EvaluateManifest(manifest, "0.1.0");
+ var result = UpdateCheckService.EvaluateManifest(manifest, "0.1.0", UpdateChannel.Stable);
Assert.Equal("https://github.com/netclaw-dev/netclaw/releases/tag/0.2.0",
result.ReleaseNotesUrl);
diff --git a/src/Netclaw.Daemon.Tests/Services/UpdateChannelEvaluationTests.cs b/src/Netclaw.Daemon.Tests/Services/UpdateChannelEvaluationTests.cs
new file mode 100644
index 000000000..ff3c1d02d
--- /dev/null
+++ b/src/Netclaw.Daemon.Tests/Services/UpdateChannelEvaluationTests.cs
@@ -0,0 +1,124 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (C) 2026 - 2026 Petabridge, LLC
+//
+// -----------------------------------------------------------------------
+using Netclaw.Configuration;
+using Netclaw.Configuration.Feeds;
+using Xunit;
+
+namespace Netclaw.Daemon.Tests.Services;
+
+///
+/// Channel-aware behavior of :
+/// a stable client is never offered a prerelease, while a beta client tracks
+/// latestPrerelease and rolls onto a stable release once it supersedes the beta.
+///
+public sealed class UpdateChannelEvaluationTests
+{
+ // Build a manifest with stable + prerelease entries, each carrying an asset for the
+ // current RID so matching assets are found regardless of the test host platform.
+ private static BinaryFeedManifest Manifest(string latest, string latestPrerelease)
+ {
+ var rid = UpdateCheckService.GetCurrentRid();
+
+ BinaryRelease Release(string version) => new()
+ {
+ Version = version,
+ ReleaseNotesUrl = $"https://github.com/netclaw-dev/netclaw/releases/tag/{version}",
+ Assets =
+ [
+ new BinaryAsset
+ {
+ Component = "netclaw",
+ Rid = rid,
+ Url = $"https://releases.netclaw.dev/{version}/netclaw-{version}-{rid}.tar.gz",
+ Sha256 = "abc",
+ SizeBytes = 1,
+ },
+ ],
+ };
+
+ var releases = new List();
+ if (!string.IsNullOrEmpty(latestPrerelease) && latestPrerelease != latest)
+ releases.Add(Release(latestPrerelease));
+ if (!string.IsNullOrEmpty(latest))
+ releases.Add(Release(latest));
+
+ return new BinaryFeedManifest
+ {
+ Latest = latest,
+ LatestPrerelease = latestPrerelease,
+ Releases = releases,
+ };
+ }
+
+ [Fact]
+ public void Stable_IsNeverOfferedAPrerelease()
+ {
+ // A newer prerelease exists, but a stable client must never see it.
+ var manifest = Manifest("0.18.1", "0.19.0-beta1");
+
+ var result = UpdateCheckService.EvaluateManifest(manifest, "0.18.1", UpdateChannel.Stable);
+
+ Assert.False(result.IsUpdateAvailable);
+ Assert.Equal("0.18.1", result.LatestVersion);
+ }
+
+ [Fact]
+ public void Stable_OffersNewerStable()
+ {
+ var manifest = Manifest("0.19.0", "0.19.0");
+
+ var result = UpdateCheckService.EvaluateManifest(manifest, "0.18.1", UpdateChannel.Stable);
+
+ Assert.True(result.IsUpdateAvailable);
+ Assert.Equal("0.19.0", result.LatestVersion);
+ }
+
+ [Fact]
+ public void Beta_OffersNewerPrerelease()
+ {
+ var manifest = Manifest("0.18.1", "0.19.0-beta2");
+
+ var result = UpdateCheckService.EvaluateManifest(manifest, "0.19.0-beta1", UpdateChannel.Beta);
+
+ Assert.True(result.IsUpdateAvailable);
+ Assert.Equal("0.19.0-beta2", result.LatestVersion);
+ }
+
+ [Fact]
+ public void Beta_OnNewestPrerelease_ReportsNoUpdate()
+ {
+ var manifest = Manifest("0.18.1", "0.19.0-beta1");
+
+ var result = UpdateCheckService.EvaluateManifest(manifest, "0.19.0-beta1", UpdateChannel.Beta);
+
+ Assert.False(result.IsUpdateAvailable);
+ }
+
+ [Fact]
+ public void Beta_RollsOntoSupersedingStable()
+ {
+ // 0.19.0 stable shipped; the generator sets latestPrerelease to the max of all,
+ // so a beta client is moved onto the stable that supersedes its beta.
+ var manifest = Manifest("0.19.0", "0.19.0");
+
+ var result = UpdateCheckService.EvaluateManifest(manifest, "0.19.0-beta1", UpdateChannel.Beta);
+
+ Assert.True(result.IsUpdateAvailable);
+ Assert.Equal("0.19.0", result.LatestVersion);
+ }
+
+ [Fact]
+ public void Beta_FallsBackToLatest_WhenManifestHasNoPrerelease()
+ {
+ // Manifest published before the prerelease channel existed.
+ var manifest = Manifest("0.19.0", "");
+
+ var result = UpdateCheckService.EvaluateManifest(manifest, "0.18.1", UpdateChannel.Beta);
+
+ Assert.True(result.IsUpdateAvailable);
+ Assert.Equal("0.19.0", result.LatestVersion);
+ }
+}
diff --git a/src/Netclaw.Daemon/BuildInfo.cs b/src/Netclaw.Daemon/BuildInfo.cs
index 2bcb4b972..3c2a6b133 100644
--- a/src/Netclaw.Daemon/BuildInfo.cs
+++ b/src/Netclaw.Daemon/BuildInfo.cs
@@ -21,6 +21,13 @@ internal static class BuildInfo
public static string Version { get; } =
Netclaw.Configuration.BuildInfo.GetVersion(Assembly);
+ ///
+ /// Full semver including any prerelease suffix (e.g. "0.19.0-beta.1"), read from
+ /// the daemon assembly's informational version. What the update check compares.
+ ///
+ public static string FullVersion { get; } =
+ Netclaw.Configuration.BuildInfo.GetFullVersion(Assembly);
+
///
/// Short git commit hash (first 7 chars of the SHA embedded by SourceLink),
/// or "unknown" if the assembly was built outside a git repository.
diff --git a/src/Netclaw.Daemon/Gateway/DaemonRuntimeStatusService.cs b/src/Netclaw.Daemon/Gateway/DaemonRuntimeStatusService.cs
index 636ced734..8756a73cf 100644
--- a/src/Netclaw.Daemon/Gateway/DaemonRuntimeStatusService.cs
+++ b/src/Netclaw.Daemon/Gateway/DaemonRuntimeStatusService.cs
@@ -264,7 +264,9 @@ private DaemonRuntimeStatus.Update BuildUpdateStatus()
{
Available = false,
State = "unknown",
- CurrentVersion = BuildInfo.Version,
+ // FullVersion to match the post-check branch (result.CurrentVersion is
+ // the full version), so a beta build doesn't show a stripped core here.
+ CurrentVersion = BuildInfo.FullVersion,
SelfUpdateDisabled = daemonConfig.DisableSelfUpdate,
};
}
diff --git a/src/Netclaw.Daemon/Services/BinaryUpdateCheckService.cs b/src/Netclaw.Daemon/Services/BinaryUpdateCheckService.cs
index 1e6502c48..52cc7a58b 100644
--- a/src/Netclaw.Daemon/Services/BinaryUpdateCheckService.cs
+++ b/src/Netclaw.Daemon/Services/BinaryUpdateCheckService.cs
@@ -31,6 +31,7 @@ internal sealed class BinaryUpdateCheckService : BackgroundService
private readonly TimeProvider _timeProvider;
private readonly string _currentVersion;
private readonly string _upgradeHint;
+ private readonly UpdateChannel _channel;
public BinaryUpdateCheckService(
HttpClient httpClient,
@@ -38,7 +39,10 @@ public BinaryUpdateCheckService(
DaemonConfig daemonConfig,
IOperationalNotificationSink? notificationSink = null,
TimeProvider? timeProvider = null)
- : this(httpClient, logger, BuildInfo.Version, daemonConfig.DisableSelfUpdate, notificationSink, timeProvider)
+ // FullVersion (not Version) so a beta build reports its prerelease suffix and
+ // can be compared against the beta channel rather than stranding on its core.
+ : this(httpClient, logger, BuildInfo.FullVersion, daemonConfig.DisableSelfUpdate,
+ notificationSink, timeProvider, daemonConfig.UpdateChannel)
{
}
@@ -49,7 +53,8 @@ internal BinaryUpdateCheckService(
string currentVersion,
bool selfUpdateDisabled = false,
IOperationalNotificationSink? notificationSink = null,
- TimeProvider? timeProvider = null)
+ TimeProvider? timeProvider = null,
+ UpdateChannel channel = UpdateChannel.Stable)
{
_httpClient = httpClient;
_logger = logger;
@@ -59,6 +64,7 @@ internal BinaryUpdateCheckService(
: "Run 'netclaw update' to upgrade.";
_notificationSink = notificationSink;
_timeProvider = timeProvider ?? TimeProvider.System;
+ _channel = channel;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
@@ -79,7 +85,7 @@ internal async Task CheckAndNotifyAsync(CancellationToken cancellationToken)
try
{
var result = await UpdateCheckService.CheckForUpdateAsync(
- _httpClient, _currentVersion, cancellationToken);
+ _httpClient, _currentVersion, cancellationToken, _channel);
if (result.IsUpdateAvailable)
{