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) {