From e8897d30564b2221dceadd67b65ce3376e2c6b12 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Wed, 3 Jun 2026 08:53:04 +0000 Subject: [PATCH 1/3] feat(release): add public beta (prerelease) channel (#1027) Publish and install prereleases without affecting any default install. - generate-release-manifest.sh: compute `latest` (newest stable) plus a new additive `latestPrerelease` (newest of all) with semver-correct precedence; schemaVersion stays 1, so existing clients are unaffected. - install.sh `--channel` / install.ps1 `-Channel`: beta resolves to latestPrerelease (falling back loudly to latest stable if absent); NETCLAW_VERSION / -Version pin still wins; unknown channel fails loudly. - publish_release_binaries.yml: prerelease tags get only their exact Docker tag (no :latest/:major.minor); new update-docker-beta-tag job points the rolling :beta tag at latestPrerelease; GitHub release marked prerelease for tags containing '-'; version gate now validates VersionPrefix + VersionSuffix. - install-smoke.sh / install-smoke.ps1: assert default -> stable, --channel beta -> prerelease, explicit pin overrides channel, and an unknown channel is rejected. - README: document the beta channel and the :beta image tag. Stable clients only ever read `latest`, so a prerelease can never be offered to them. Manifest unbounded-growth is tracked separately in #1310. --- .../workflows/publish_release_binaries.yml | 97 +++++++++++++++++-- README.md | 16 ++- feeds/scripts/generate-release-manifest.sh | 57 ++++++++++- scripts/install.ps1 | 20 +++- scripts/install.sh | 50 ++++++++-- scripts/smoke/install-smoke.ps1 | 96 ++++++++++++------ scripts/smoke/install-smoke.sh | 80 ++++++++++++--- 7 files changed, 351 insertions(+), 65 deletions(-) diff --git a/.github/workflows/publish_release_binaries.yml b/.github/workflows/publish_release_binaries.yml index 4f0514d59..46c934179 100644 --- a/.github/workflows/publish_release_binaries.yml +++ b/.github/workflows/publish_release_binaries.yml @@ -23,23 +23,35 @@ jobs: lfs: true fetch-depth: 0 - - name: Validate tag matches VersionPrefix + - name: Validate tag matches VersionPrefix/VersionSuffix shell: bash run: | TAG_VERSION="${{ github.ref_name }}" - PROPS_VERSION=$(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 '')") + # Split a prerelease tag (e.g. 0.19.0-beta1) into core + suffix. The .NET SDK + # keeps the semver suffix in , not , so validate + # each half. A stable tag (e.g. 0.18.1) has no suffix. + TAG_PREFIX="${TAG_VERSION%%-*}" + if [ "$TAG_VERSION" = "$TAG_PREFIX" ]; then + TAG_SUFFIX="" + else + TAG_SUFFIX="${TAG_VERSION#*-}" + fi - if [ -z "$PROPS_VERSION" ]; then + 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 '')") + + if [ -z "$PROPS_PREFIX" ]; then echo "Directory.Build.props is missing VersionPrefix" >&2 exit 1 fi - if [ "$TAG_VERSION" != "$PROPS_VERSION" ]; then - echo "Tag version ($TAG_VERSION) does not match Directory.Build.props VersionPrefix ($PROPS_VERSION)" >&2 + if [ "$TAG_PREFIX" != "$PROPS_PREFIX" ] || [ "$TAG_SUFFIX" != "$PROPS_SUFFIX" ]; then + echo "Tag ($TAG_VERSION) does not match Directory.Build.props (VersionPrefix=$PROPS_PREFIX, VersionSuffix=${PROPS_SUFFIX:-})" >&2 + echo " For this tag set $TAG_PREFIX and $TAG_SUFFIX." >&2 exit 1 fi - echo "Version check passed: $TAG_VERSION" + echo "Version check passed: $TAG_VERSION (prefix=$TAG_PREFIX suffix=${TAG_SUFFIX:-})" - name: Extract latest release notes shell: pwsh @@ -57,7 +69,9 @@ jobs: id: create_release with: draft: false - prerelease: false + # A tag with a '-' is a semver prerelease (e.g. 0.19.0-beta1) → mark it a + # GitHub prerelease so it never becomes the repo's "Latest" release. + prerelease: ${{ contains(github.ref_name, '-') }} release_name: 'Netclaw ${{ github.ref_name }}' tag_name: ${{ github.ref }} body_path: RELEASE_NOTES_LATEST.md @@ -307,7 +321,14 @@ jobs: MAJOR_MINOR=$(echo "$STRIPPED" | awk -F. '{print $1"."$2}') REPO="ghcr.io/netclaw-dev/netclaw" - TAGS="${REPO}:${VERSION},${REPO}:latest,${REPO}:${MAJOR_MINOR}" + # A prerelease tag (e.g. 0.19.0-beta1) must NOT move the floating stable tags + # (:latest, :major.minor). It only gets its exact-version tag. The rolling + # :beta tag is handled separately (see the update-docker-beta-tag job), which + # tracks latestPrerelease so :beta survives a stable that supersedes a beta. + case "$VERSION" in + *-*) TAGS="${REPO}:${VERSION}" ;; + *) TAGS="${REPO}:${VERSION},${REPO}:latest,${REPO}:${MAJOR_MINOR}" ;; + esac echo "tags=$TAGS" >> "$GITHUB_OUTPUT" echo "Tags: $TAGS" @@ -330,6 +351,10 @@ jobs: needs: publish-binaries runs-on: ubuntu-latest timeout-minutes: 10 + outputs: + # The version the beta channel / Docker :beta tag should resolve to + # (newest of {stable, prerelease}). Consumed by update-docker-beta-tag. + latest_prerelease: ${{ steps.pointers.outputs.latest_prerelease }} steps: - name: Checkout uses: actions/checkout@v6 @@ -367,6 +392,17 @@ jobs: ./checksums \ "https://releases.netclaw.dev" + - name: Read channel pointers + id: pointers + run: | + LP=$(python3 -c "import json; print(json.load(open('./feeds/releases/manifest.json')).get('latestPrerelease',''))") + if [ -z "$LP" ]; then + echo "Generated manifest is missing latestPrerelease" >&2 + exit 1 + fi + echo "latest_prerelease=$LP" >> "$GITHUB_OUTPUT" + echo "latestPrerelease=$LP" + - name: Install minisign run: | sudo apt-get update @@ -467,7 +503,50 @@ jobs: run: rm -f /tmp/netclaw-manifest.key # ──────────────────────────────────────────────────────────────────── - # Job 5: Publish system skills feed to R2 + # Job 5: Move the rolling Docker :beta tag to the newest prerelease + # ──────────────────────────────────────────────────────────────────── + # :beta tracks latestPrerelease (newest of {stable, prerelease}), mirroring the + # install-script beta channel. Needs publish-docker (so the freshly built image + # exists) and publish-binary-manifest (for the latestPrerelease value). When a + # stable supersedes a beta, latestPrerelease == that stable and :beta follows it; + # when an old stable line is patched while a newer beta is live, latestPrerelease + # stays on the beta and :beta does not regress. + update-docker-beta-tag: + name: Update Docker :beta tag + needs: [publish-docker, publish-binary-manifest] + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + packages: write + steps: + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Log in to GHCR + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Point :beta at latestPrerelease + shell: bash + run: | + set -euo pipefail + REPO="ghcr.io/netclaw-dev/netclaw" + LP="${{ needs.publish-binary-manifest.outputs.latest_prerelease }}" + if [ -z "$LP" ]; then + echo "latest_prerelease output is empty — cannot move :beta" >&2 + exit 1 + fi + echo "Pointing ${REPO}:beta at ${REPO}:${LP}" + # imagetools create copies the (multi-arch) manifest list from the source tag + # to :beta without rebuilding. The source image already exists in GHCR. + docker buildx imagetools create --tag "${REPO}:beta" "${REPO}:${LP}" + + # ──────────────────────────────────────────────────────────────────── + # Job 6: Publish system skills feed to R2 # ──────────────────────────────────────────────────────────────────── publish-skills: name: Publish Skills Feed diff --git a/README.md b/README.md index c58222cdc..a7c1f4fee 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,10 @@ curl -sSL https://releases.netclaw.dev/install.sh | bash curl -sSL https://releases.netclaw.dev/install.sh | bash -s -- cli curl -sSL https://releases.netclaw.dev/install.sh | bash -s -- daemon -# Pin a specific version +# Opt into the beta channel (newest prerelease, or latest stable if none) +curl -sSL https://releases.netclaw.dev/install.sh | bash -s -- --channel beta + +# Pin a specific version (e.g. a prerelease) NETCLAW_VERSION=0.17.1 curl -sSL https://releases.netclaw.dev/install.sh | bash ``` @@ -115,6 +118,14 @@ available on macOS ([#1015](https://github.com/netclaw-dev/netclaw/issues/1015)) iwr -useb https://releases.netclaw.dev/install.ps1 | iex ``` +The `-Component cli|daemon`, `-Channel beta`, and `-Version` options work the same +way as their Linux counterparts (download the script and run it with the flag). + +**Beta channel.** Stable installs are never affected by prereleases. To test an +upcoming release, opt in with `--channel beta` (`-Channel beta` on Windows) — it +installs the newest prerelease, automatically rolling onto the next stable once it +ships. Pin an exact build with `NETCLAW_VERSION=x.y.z-beta.n`. + **Docker** (multi-arch: amd64/arm64): ```bash @@ -127,6 +138,9 @@ docker run -d --name netclawd \ ghcr.io/netclaw-dev/netclaw:latest ``` +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. + See the [Docker deployment guide](https://netclaw.dev/deployment/docker/) for volume setup, environment variables, and Docker Compose examples. diff --git a/feeds/scripts/generate-release-manifest.sh b/feeds/scripts/generate-release-manifest.sh index c0579bbdd..338fa48e2 100755 --- a/feeds/scripts/generate-release-manifest.sh +++ b/feeds/scripts/generate-release-manifest.sh @@ -104,6 +104,60 @@ except: fi fi +# Compute the channel pointers with semver-correct precedence over the union of +# {this version} ∪ {versions already in the manifest}: +# latest = newest STABLE version (no prerelease suffix); "" if none yet +# latestPrerelease = newest of ALL versions (always >= latest), so the beta channel +# automatically rolls onto a stable release once it supersedes a +# prior beta. This is what install.sh/install.ps1 --channel beta +# and the Docker :beta tag resolve to. +# python3 is required here — channel pointers must be correct, so we fail loudly +# rather than guess if it is missing. +if ! command -v python3 >/dev/null 2>&1; then + echo "Error: python3 is required to compute release channel pointers" >&2 + exit 1 +fi + +POINTERS=$(python3 - "$VERSION" "$MANIFEST_PATH" <<'PY' +import json, sys + +version = sys.argv[1] +manifest_path = sys.argv[2] + +versions = {version} +try: + with open(manifest_path) as f: + existing = json.load(f) + versions.update(r["version"] for r in existing.get("releases", [])) +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)) +PY +) +LATEST=$(printf '%s\n' "$POINTERS" | sed -n '1p') +LATEST_PRERELEASE=$(printf '%s\n' "$POINTERS" | sed -n '2p') + # GitHub release notes URL RELEASE_NOTES_URL="https://github.com/netclaw-dev/netclaw/releases/tag/${VERSION}" @@ -113,7 +167,8 @@ cat > "$MANIFEST_PATH" << EOF "schemaVersion": 1, "feedType": "releases", "updatedAt": "${NOW}", - "latest": "${VERSION}", + "latest": "${LATEST}", + "latestPrerelease": "${LATEST_PRERELEASE}", "releases": [ { "version": "${VERSION}", diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 6c808d8da..3bfcbb1f6 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -5,7 +5,11 @@ # .\install.ps1 -Component cli # .\install.ps1 -Component daemon # .\install.ps1 -InstallDir C:\tools\netclaw +# .\install.ps1 -Channel beta # Opt into prereleases # .\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). param( [ValidateSet("all", "cli", "daemon")] @@ -15,6 +19,10 @@ param( [string]$Version = "", + # Release channel: "stable" (default) or "beta" (opt into prereleases). + [ValidateSet("stable", "beta")] + [string]$Channel = "stable", + # Resolve and report what would be installed, but install nothing. [switch]$DryRun ) @@ -89,6 +97,7 @@ if (-not $InstallDir) { Write-Host "Netclaw installer" Write-Host " Platform: win-x64" Write-Host " Install dir: $InstallDir" +Write-Host " Channel: $Channel" if ($DryRun) { Write-Host " Mode: dry run (no changes will be made)" } @@ -103,9 +112,18 @@ try { exit 1 } -# Determine version +# Determine version. Precedence: explicit pin > channel selection > stable latest. if ($Version) { $targetVersion = $Version +} elseif ($Channel -eq "beta") { + # Beta channel resolves to latestPrerelease (the newest of {stable, prerelease}). + $targetVersion = $manifest.latestPrerelease + if (-not $targetVersion) { + # Manifest predates the prerelease channel — use latest stable and say so + # loudly. This is the newest known version, not a silent default. + Write-Host " Note: manifest has no prerelease channel; using latest stable." + $targetVersion = $manifest.latest + } } else { $targetVersion = $manifest.latest } diff --git a/scripts/install.sh b/scripts/install.sh index 4db9d4d3a..cf443a5e9 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -3,13 +3,20 @@ # # Usage: # curl -sSL https://releases.netclaw.dev/install.sh | bash -# curl -sSL https://releases.netclaw.dev/install.sh | bash -s -- cli # CLI only -# curl -sSL https://releases.netclaw.dev/install.sh | bash -s -- daemon # Daemon only +# curl -sSL https://releases.netclaw.dev/install.sh | bash -s -- cli # CLI only +# curl -sSL https://releases.netclaw.dev/install.sh | bash -s -- daemon # Daemon only +# curl -sSL https://releases.netclaw.dev/install.sh | bash -s -- --channel beta # Opt into prereleases # INSTALL_DIR=/opt/netclaw curl -sSL https://releases.netclaw.dev/install.sh | bash # +# Arguments: +# all|cli|daemon — Which component(s) to install (default: all) +# --channel stable|beta — Release channel (default: stable). 'beta' installs the +# newest prerelease (or latest stable if no prerelease exists). +# --dry-run — Resolve and report what would happen; install nothing. +# # Environment variables: -# INSTALL_DIR — Install directory (default: ~/.netclaw/bin) -# NETCLAW_VERSION — Specific version to install (default: latest) +# INSTALL_DIR — Install directory (default: ~/.netclaw/bin) +# NETCLAW_VERSION — Specific version to install (overrides --channel; e.g. 0.19.0-beta1) set -euo pipefail @@ -27,14 +34,27 @@ MANIFEST_URL="${MANIFEST_URL:-https://releases.netclaw.dev/manifest.json}" # ── Argument parsing ── COMPONENT="all" # "all", "cli", or "daemon" DRY_RUN=false # --dry-run: resolve and report what would happen, install nothing -for arg in "$@"; do - case "$arg" in - --dry-run) DRY_RUN=true ;; - all|cli|daemon) COMPONENT="$arg" ;; - *) echo "Usage: install.sh [all|cli|daemon] [--dry-run]" >&2; exit 1 ;; +CHANNEL="stable" # release channel: "stable" (default) or "beta" (opt into prereleases) +while [ $# -gt 0 ]; do + case "$1" in + --dry-run) DRY_RUN=true; shift ;; + --channel) + if [ $# -lt 2 ]; then + echo "Error: --channel requires a value (stable|beta)" >&2; exit 1 + fi + CHANNEL="$2"; shift 2 ;; + --channel=*) CHANNEL="${1#*=}"; shift ;; + all|cli|daemon) COMPONENT="$1"; shift ;; + *) echo "Usage: install.sh [all|cli|daemon] [--channel stable|beta] [--dry-run]" >&2; exit 1 ;; esac done +# Validate channel — fail loudly on an unknown value rather than silently defaulting. +case "$CHANNEL" in + stable|beta) ;; + *) echo "Error: unknown channel '$CHANNEL' (expected 'stable' or 'beta')" >&2; exit 1 ;; +esac + # ── Platform detection ── detect_platform() { local os arch rid @@ -121,6 +141,7 @@ INSTALL_DIR="${INSTALL_DIR:-$HOME/.netclaw/bin}" echo "Netclaw installer" echo " Platform: $RID" echo " Install dir: $INSTALL_DIR" +echo " Channel: $CHANNEL" if [ "$DRY_RUN" = true ]; then echo " Mode: dry run (no changes will be made)" fi @@ -133,9 +154,18 @@ MANIFEST=$(curl -sSL --fail "$MANIFEST_URL") || { exit 1 } -# Determine version +# Determine version. Precedence: explicit pin > channel selection > stable latest. if [ -n "${NETCLAW_VERSION:-}" ]; then VERSION="$NETCLAW_VERSION" +elif [ "$CHANNEL" = "beta" ]; then + # Beta channel resolves to latestPrerelease (the newest of {stable, prerelease}). + VERSION=$(json_field "$MANIFEST" ".latestPrerelease") + if [ -z "$VERSION" ] || [ "$VERSION" = "null" ]; then + # Manifest predates the prerelease channel — use latest stable and say so + # loudly. This is the newest known version, not a silent default. + echo " Note: manifest has no prerelease channel; using latest stable." >&2 + VERSION=$(json_field "$MANIFEST" ".latest") + fi else VERSION=$(json_field "$MANIFEST" ".latest") fi diff --git a/scripts/smoke/install-smoke.ps1 b/scripts/smoke/install-smoke.ps1 index 3b76aa13d..6ae2976e5 100644 --- a/scripts/smoke/install-smoke.ps1 +++ b/scripts/smoke/install-smoke.ps1 @@ -13,7 +13,8 @@ $ErrorActionPreference = "Stop" $RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot ".." "..")).Path $InstallPs1 = Join-Path $RepoRoot "scripts" "install.ps1" -$Version = "0.0.0" +$Version = "0.0.0" # stable -> manifest.latest +$BetaVersion = "0.0.1-beta1" # prerelease -> manifest.latestPrerelease $Rid = "win-x64" $script:Pass = 0 @@ -23,51 +24,55 @@ function Fail([string]$m) { Write-Host "FAIL: $m"; $script:Fail++ } $Work = Join-Path ([System.IO.Path]::GetTempPath()) ("netclaw-install-smoke-" + [Guid]::NewGuid().ToString('N')) $Serve = Join-Path $Work "serve" -$VersionDir = Join-Path $Serve $Version $BinDir = Join-Path $Work "bin" -New-Item -ItemType Directory -Path $VersionDir, $BinDir -Force | Out-Null +New-Item -ItemType Directory -Path $Serve, $BinDir -Force | Out-Null $ServerProc = $null try { # 1. Stand-in binaries - install.ps1 only needs a file named .exe foreach ($name in @("netclaw", "netclawd")) { - Set-Content -Path (Join-Path $BinDir "$name.exe") -Value "stand-in $name $Version" -NoNewline + Set-Content -Path (Join-Path $BinDir "$name.exe") -Value "stand-in $name" -NoNewline } - # 2. Package zip archives + collect asset metadata - $assets = @() - foreach ($comp in @("netclaw", "netclawd")) { - $archiveName = "$comp-$Version-$Rid.zip" - $archivePath = Join-Path $VersionDir $archiveName - Compress-Archive -Path (Join-Path $BinDir "$comp.exe") -DestinationPath $archivePath -Force - $hash = (Get-FileHash -Path $archivePath -Algorithm SHA256).Hash.ToLowerInvariant() - $assets += [ordered]@{ - component = $comp - rid = $Rid - url = "PLACEHOLDER/$Version/$archiveName" - sha256 = $hash - sizeBytes = (Get-Item $archivePath).Length - } - } - - # 3. Pick a free port, then write the manifest with localhost URLs + # 2. Pick a free port (asset URLs embed it) $listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0) $listener.Start() $Port = ([System.Net.IPEndPoint]$listener.LocalEndpoint).Port $listener.Stop() $BaseUrl = "http://127.0.0.1:$Port" - foreach ($a in $assets) { $a.url = $a.url.Replace("PLACEHOLDER", $BaseUrl) } - $manifest = [ordered]@{ - schemaVersion = 1 - feedType = "releases" - latest = $Version - releases = @( - [ordered]@{ - version = $Version - assets = $assets + # 3. Package zip archives for a stable AND a prerelease, and write a manifest with + # latest (stable) + latestPrerelease (prerelease). Two versions let us prove + # channel selection: default -> latest, -Channel beta -> latestPrerelease. + function New-ReleaseEntry([string]$ver) { + $verDir = Join-Path $Serve $ver + New-Item -ItemType Directory -Path $verDir -Force | Out-Null + $entryAssets = @() + foreach ($comp in @("netclaw", "netclawd")) { + $archiveName = "$comp-$ver-$Rid.zip" + $archivePath = Join-Path $verDir $archiveName + Compress-Archive -Path (Join-Path $BinDir "$comp.exe") -DestinationPath $archivePath -Force + $hash = (Get-FileHash -Path $archivePath -Algorithm SHA256).Hash.ToLowerInvariant() + $entryAssets += [ordered]@{ + component = $comp + rid = $Rid + url = "$BaseUrl/$ver/$archiveName" + sha256 = $hash + sizeBytes = (Get-Item $archivePath).Length } - ) + } + return [ordered]@{ version = $ver; assets = $entryAssets } + } + + $stableEntry = New-ReleaseEntry $Version + $betaEntry = New-ReleaseEntry $BetaVersion + + $manifest = [ordered]@{ + schemaVersion = 1 + feedType = "releases" + latest = $Version + latestPrerelease = $BetaVersion + releases = @($betaEntry, $stableEntry) } $manifest | ConvertTo-Json -Depth 8 | Set-Content -Path (Join-Path $Serve "manifest.json") -Encoding utf8 @@ -148,6 +153,35 @@ try { } else { Fail "PATH instruction: should read from User scope with GetEnvironmentVariable('PATH', 'User')" } + + # 8. Release channel resolution (dry-run) + Write-Host "" + Write-Host "=== release channel resolution ===" + $shouldNotExist = Join-Path $Work "should-not-exist" + + function Assert-Resolves([string]$desc, [string]$want, [string[]]$extraArgs) { + $out = & pwsh -NoProfile -File $InstallPs1 -InstallDir $shouldNotExist -DryRun @extraArgs 2>&1 | Out-String + $pattern = "(?m)^\s+Version:\s+$([regex]::Escape($want))\s*$" + if ($LASTEXITCODE -eq 0 -and $out -match $pattern) { + Pass "channel: $desc -> $want" + } else { + Fail "channel: $desc (exit=$LASTEXITCODE, expected Version: $want)" + Write-Host ($out.TrimEnd()) + } + } + + Assert-Resolves "default install -> latest stable" $Version @() + Assert-Resolves "-Channel stable -> latest stable" $Version @("-Channel", "stable") + Assert-Resolves "-Channel beta -> latest prerelease" $BetaVersion @("-Channel", "beta") + Assert-Resolves "-Version pin overrides -Channel" $BetaVersion @("-Channel", "stable", "-Version", $BetaVersion) + + # An unknown channel must be rejected by the ValidateSet, not silently default. + & pwsh -NoProfile -File $InstallPs1 -InstallDir $shouldNotExist -DryRun -Channel bogus 2>&1 | Out-Null + if ($LASTEXITCODE -ne 0) { + Pass "channel: unknown value rejected" + } else { + Fail "channel: unknown value should fail (exit=$LASTEXITCODE)" + } } finally { if ($ServerProc -and -not $ServerProc.HasExited) { $ServerProc.Kill() } diff --git a/scripts/smoke/install-smoke.sh b/scripts/smoke/install-smoke.sh index 2301103b4..42bc7414a 100755 --- a/scripts/smoke/install-smoke.sh +++ b/scripts/smoke/install-smoke.sh @@ -23,7 +23,8 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" INSTALL_SH="$ROOT_DIR/scripts/install.sh" MANIFEST_GEN="$ROOT_DIR/feeds/scripts/generate-release-manifest.sh" -VERSION="0.0.0" +VERSION="0.0.0" # stable → manifest.latest +BETA_VERSION="0.0.1-beta1" # prerelease → manifest.latestPrerelease RIDS="linux-x64 linux-arm64 osx-arm64" PASS=0 @@ -82,22 +83,33 @@ EOF chmod +x "$WORK/bin/$name" done -# ── 2. Package archives + checksums for every RID ──────────────────────────── -for rid in $RIDS; do - cli="netclaw-$VERSION-$rid.tar.gz" - daemon="netclawd-$VERSION-$rid.tar.gz" - tar czf "$SERVE/$VERSION/$cli" -C "$WORK/bin" netclaw - tar czf "$SERVE/$VERSION/$daemon" -C "$WORK/bin" netclawd - { - echo "$(sha256_of "$SERVE/$VERSION/$cli") $cli $(size_of "$SERVE/$VERSION/$cli")" - echo "$(sha256_of "$SERVE/$VERSION/$daemon") $daemon $(size_of "$SERVE/$VERSION/$daemon")" - } > "$WORK/checksums/checksums-$rid.txt" +# ── 2. Package archives + checksums for every RID, for a stable AND a prerelease ─ +# Two versions let us prove channel selection: the default install must resolve to +# the stable (manifest.latest), --channel beta to the prerelease (latestPrerelease). +for ver in "$VERSION" "$BETA_VERSION"; do + mkdir -p "$SERVE/$ver" "$WORK/checksums-$ver" + for rid in $RIDS; do + cli="netclaw-$ver-$rid.tar.gz" + daemon="netclawd-$ver-$rid.tar.gz" + tar czf "$SERVE/$ver/$cli" -C "$WORK/bin" netclaw + tar czf "$SERVE/$ver/$daemon" -C "$WORK/bin" netclawd + { + echo "$(sha256_of "$SERVE/$ver/$cli") $cli $(size_of "$SERVE/$ver/$cli")" + echo "$(sha256_of "$SERVE/$ver/$daemon") $daemon $(size_of "$SERVE/$ver/$daemon")" + } > "$WORK/checksums-$ver/checksums-$rid.txt" + done done # ── 3. Pick a free port and generate the manifest ──────────────────────────── PORT="$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1",0)); p=s.getsockname()[1]; s.close(); print(p)')" BASE_URL="http://127.0.0.1:$PORT" -bash "$MANIFEST_GEN" "$VERSION" "$WORK/checksums" "$BASE_URL" >/dev/null +# Start from a clean slate so the generator's latest/latestPrerelease are computed +# from only our two test versions (cleanup restores any pre-existing manifest). +rm -f "$MANIFEST_DEST" +# Run the REAL generator once per version; it accumulates releases[] and recomputes +# latest (newest stable) + latestPrerelease (newest of all) across both. +bash "$MANIFEST_GEN" "$VERSION" "$WORK/checksums-$VERSION" "$BASE_URL" >/dev/null +bash "$MANIFEST_GEN" "$BETA_VERSION" "$WORK/checksums-$BETA_VERSION" "$BASE_URL" >/dev/null cp "$MANIFEST_DEST" "$SERVE/manifest.json" # ── 4. Serve the manifest + archives from localhost ────────────────────────── @@ -189,6 +201,50 @@ for name in netclaw netclawd; do fi done +# ── 7. Release channel resolution (dry-run) ────────────────────────────────── +echo "" +echo "=== release channel resolution ===" + +# assert_resolves [extra install.sh args...] +# Optional NETCLAW_PIN env exercises the explicit-version-pin path. Asserts on the +# "Version: X" line install.sh prints after resolving the channel. +assert_resolves() { + local desc="$1" want="$2"; shift 2 + local out rc + set +e + out=$(MANIFEST_URL="$BASE_URL/manifest.json" \ + INSTALL_DIR="$WORK/should-not-exist" \ + NETCLAW_VERSION="${NETCLAW_PIN:-}" \ + bash "$INSTALL_SH" --dry-run "$@" 2>&1) + rc=$? + set -e + if [ "$rc" -eq 0 ] && echo "$out" | grep -Eq "^ Version: ${want}$"; then + pass "channel: $desc -> $want" + else + fail "channel: $desc (exit=$rc, expected 'Version: $want')" + echo "$out" | indent + fi +} + +assert_resolves "default install -> latest stable" "$VERSION" +assert_resolves "--channel stable -> latest stable" "$VERSION" --channel stable +assert_resolves "--channel beta -> latest prerelease" "$BETA_VERSION" --channel beta +NETCLAW_PIN="$BETA_VERSION" \ + assert_resolves "NETCLAW_VERSION pin overrides channel" "$BETA_VERSION" --channel stable + +# An unknown channel must fail loudly, not silently fall back to stable. +set +e +bad_out=$(MANIFEST_URL="$BASE_URL/manifest.json" INSTALL_DIR="$WORK/should-not-exist" \ + bash "$INSTALL_SH" --dry-run --channel bogus 2>&1) +bad_rc=$? +set -e +if [ "$bad_rc" -ne 0 ] && echo "$bad_out" | grep -q "unknown channel"; then + pass "channel: unknown value rejected" +else + fail "channel: unknown value should fail loudly (exit=$bad_rc)" + echo "$bad_out" | indent +fi + # ── Summary ────────────────────────────────────────────────────────────────── echo "" echo "Results: $PASS passed, $FAIL failed" From ef1270d7bf381d147b33cc8cd82ab482371d560b Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Wed, 3 Jun 2026 12:26:07 +0000 Subject: [PATCH 2/3] fix(smoke): make install-smoke.ps1 exit 0 on success The new 'unknown channel rejected' assertion runs `pwsh -Channel bogus`, which exits non-zero by design. Without an explicit exit, the script fell off the end and inherited that non-zero $LASTEXITCODE, failing the Windows CI leg despite all 12 assertions passing. --- scripts/smoke/install-smoke.ps1 | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scripts/smoke/install-smoke.ps1 b/scripts/smoke/install-smoke.ps1 index 6ae2976e5..c3cc216d6 100644 --- a/scripts/smoke/install-smoke.ps1 +++ b/scripts/smoke/install-smoke.ps1 @@ -196,3 +196,7 @@ if ($script:Fail -gt 0) { exit 1 } Write-Host "install smoke (ps1): PASSED" +# Exit explicitly on the result, not on $LASTEXITCODE — the channel checks above run +# `pwsh -Channel bogus` (which exits non-zero by design), and without this the script +# would fall off the end and inherit that non-zero code despite all assertions passing. +exit 0 From 6325238a7612002cb3bad3b9926d84c8207136dd Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Wed, 3 Jun 2026 12:40:12 +0000 Subject: [PATCH 3/3] docs(readme): add a dedicated beta / prerelease install section Consolidates the scattered beta-channel mentions into one discoverable 'Beta / prerelease versions' section covering Linux/macOS, Windows, and Docker, plus exact-version pinning. --- README.md | 33 ++++++++++++++++++++++++++++----- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index a7c1f4fee..e3b19c5fe 100644 --- a/README.md +++ b/README.md @@ -121,11 +121,6 @@ iwr -useb https://releases.netclaw.dev/install.ps1 | iex The `-Component cli|daemon`, `-Channel beta`, and `-Version` options work the same way as their Linux counterparts (download the script and run it with the flag). -**Beta channel.** Stable installs are never affected by prereleases. To test an -upcoming release, opt in with `--channel beta` (`-Channel beta` on Windows) — it -installs the newest prerelease, automatically rolling onto the next stable once it -ships. Pin an exact build with `NETCLAW_VERSION=x.y.z-beta.n`. - **Docker** (multi-arch: amd64/arm64): ```bash @@ -144,6 +139,34 @@ tag like `:0.19.0-beta1`. `:latest` only ever points at the latest stable releas See the [Docker deployment guide](https://netclaw.dev/deployment/docker/) for volume setup, environment variables, and Docker Compose examples. +### Beta / prerelease versions + +Netclaw publishes opt-in **beta** builds so you can test an upcoming release early. +Stable installs are never affected — the default `curl | sh`, Docker `:latest`, and +the GitHub "Latest" release always point at the newest *stable*. The beta channel +follows the newest prerelease and automatically rolls onto a stable release once it +supersedes the beta. + +```bash +# Linux / macOS — newest prerelease (falls back to latest stable if none is open) +curl -sSL https://releases.netclaw.dev/install.sh | bash -s -- --channel beta +``` + +```powershell +# Windows — download, then run with -Channel beta +iwr -useb https://releases.netclaw.dev/install.ps1 -OutFile install.ps1 +./install.ps1 -Channel beta +``` + +```bash +# Docker — :beta tracks the newest prerelease (:latest stays on stable) +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). + For the full installation reference (including building from source), see the [installation docs](https://netclaw.dev/getting-started/installation/).