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
97 changes: 88 additions & 9 deletions .github/workflows/publish_release_binaries.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <VersionSuffix>, not <VersionPrefix>, 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:-<none>})" >&2
echo " For this tag set <VersionPrefix>$TAG_PREFIX</VersionPrefix> and <VersionSuffix>$TAG_SUFFIX</VersionSuffix>." >&2
exit 1
fi

echo "Version check passed: $TAG_VERSION"
echo "Version check passed: $TAG_VERSION (prefix=$TAG_PREFIX suffix=${TAG_SUFFIX:-<none>})"

- name: Extract latest release notes
shell: pwsh
Expand All @@ -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
Expand Down Expand Up @@ -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"

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
39 changes: 38 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand All @@ -115,6 +118,9 @@ 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).

**Docker** (multi-arch: amd64/arm64):

```bash
Expand All @@ -127,9 +133,40 @@ 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.

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

Expand Down
57 changes: 56 additions & 1 deletion feeds/scripts/generate-release-manifest.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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}"

Expand All @@ -113,7 +167,8 @@ cat > "$MANIFEST_PATH" << EOF
"schemaVersion": 1,
"feedType": "releases",
"updatedAt": "${NOW}",
"latest": "${VERSION}",
"latest": "${LATEST}",
"latestPrerelease": "${LATEST_PRERELEASE}",
"releases": [
{
"version": "${VERSION}",
Expand Down
20 changes: 19 additions & 1 deletion scripts/install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand All @@ -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
)
Expand Down Expand Up @@ -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)"
}
Expand All @@ -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
}
Expand Down
Loading
Loading