Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .github/workflows/pr_validation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
16 changes: 16 additions & 0 deletions .github/workflows/publish_release_binaries.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 '')")

Expand Down
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
<PackageVersion Include="xunit.v3" Version="3.2.2" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.6.0" />
<PackageVersion Include="CsCheck" Version="4.7.0" />
<PackageVersion Include="Verify.XunitV3" Version="31.19.0" />
<PackageVersion Include="Testcontainers" Version="4.12.0" />
</ItemGroup>
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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/).
Expand Down
31 changes: 9 additions & 22 deletions feeds/scripts/generate-release-manifest.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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')
Expand Down
24 changes: 24 additions & 0 deletions feeds/scripts/semver-order.txt
Original file line number Diff line number Diff line change
@@ -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
63 changes: 63 additions & 0 deletions feeds/scripts/semver_key.py
Original file line number Diff line number Diff line change
@@ -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 <fixture>`) 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 <fixture>: 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))
2 changes: 2 additions & 0 deletions openspec/changes/release-channels/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-06-03
92 changes: 92 additions & 0 deletions openspec/changes/release-channels/design.md
Original file line number Diff line number Diff line change
@@ -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.
66 changes: 66 additions & 0 deletions openspec/changes/release-channels/proposal.md
Original file line number Diff line number Diff line change
@@ -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
<!-- None. The manifest gains an additive field but manifest-signature-verification
behavior (minisign parsing/verification/fail-closed) is unchanged. -->

## 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).
Loading
Loading