Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
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
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
89 changes: 89 additions & 0 deletions openspec/changes/release-channels/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
## 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 `-beta1` suffix from —
a beta build would report `0.19.0` and never see `0.19.0-beta2`. `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.
- **`beta1`/`beta2` vs `beta.1`/`beta.2`** → single alphanumeric identifiers compare
lexically, so `beta10 < beta2`. Mitigation: the comparator is spec-correct and matches
the generator; use the dotted form (`beta.10`) for ≥10 betas of one line. Documented,
not special-cased.
- **`latestPrerelease` drift between bash and C#** → mitigated by keeping both precedence
implementations minimal and co-located conceptually, with SemVer unit tests asserting
the rules.
- **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-beta1` — became the de-facto "latest" everywhere (install
scripts, Docker `:latest`, the GitHub "Latest" release, and the update check). A real
`0.19.0-beta1` 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 `-beta1`) 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).
202 changes: 202 additions & 0 deletions openspec/changes/release-channels/specs/release-channels/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
# 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-beta1`) is published while the newest
stable is `0.18.1`
- **THEN** `latest` remains `0.18.1`
- **AND** `latestPrerelease` becomes `0.19.0-beta1`
- **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-beta1`
- **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 <stable|beta>`,
`install.ps1 -Channel <stable|beta>`) 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 `<VersionPrefix>` + `<VersionSuffix>` rather than the
verbatim tag string.

#### Scenario: Prerelease tag is marked and does not move `:latest`

- **WHEN** a tag like `0.19.0-beta1` 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-beta1` 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-beta1`
- **THEN** `ghcr.io/netclaw-dev/netclaw:beta` resolves to that image

#### Scenario: Version gate validates prefix and suffix

- **WHEN** tag `0.19.0-beta1` is published with `<VersionPrefix>0.19.0</VersionPrefix>`
and `<VersionSuffix>beta1</VersionSuffix>`
- **THEN** the version gate passes
- **AND** a tag whose prefix/suffix do not match the props fails the gate

### 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-beta1`
- **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-beta1` checks and `latestPrerelease=0.19.0-beta2`
- **THEN** an update to `0.19.0-beta2` is reported

#### Scenario: Beta client rolls onto a superseding stable

- **WHEN** a beta client on `0.19.0-beta1` 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-beta1` checks and `latestPrerelease=0.19.0-beta1`
- **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-beta1` 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-beta1`), 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-beta1` reports its version for the update check
- **THEN** the reported version is `0.19.0-beta1`, 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
Loading
Loading