diff --git a/AGENTS.md b/AGENTS.md index 7724e72778f..00ae79a0551 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -86,6 +86,7 @@ - stable: tagged releases only (e.g. `vYYYY.M.D`), npm dist-tag `latest`. - beta: prerelease tags `vYYYY.M.D-beta.N`, npm dist-tag `beta` (may ship without macOS app). +- beta naming: prefer `-beta.N`; do not mint new `-1/-2` betas. Legacy `vYYYY.M.D-` and `vYYYY.M.D.beta.N` remain recognized. - dev: moving head on `main` (no tag; git checkout main). ## Testing Guidelines diff --git a/docs/install/development-channels.md b/docs/install/development-channels.md index c31ec7c0618..a585ce9f2a9 100644 --- a/docs/install/development-channels.md +++ b/docs/install/development-channels.md @@ -60,7 +60,9 @@ When you switch channels with `openclaw update`, OpenClaw also syncs plugin sour ## Tagging best practices -- Tag releases you want git checkouts to land on (`vYYYY.M.D` or `vYYYY.M.D-`). +- Tag releases you want git checkouts to land on (`vYYYY.M.D` for stable, `vYYYY.M.D-beta.N` for beta). +- `vYYYY.M.D.beta.N` is also recognized for compatibility, but prefer `-beta.N`. +- Legacy `vYYYY.M.D-` tags are still recognized as stable (non-beta). - Keep tags immutable: never move or reuse a tag. - npm dist-tags remain the source of truth for npm installs: - `latest` → stable diff --git a/scripts/make_appcast.sh b/scripts/make_appcast.sh index 437c68e8beb..df5c249caf3 100755 --- a/scripts/make_appcast.sh +++ b/scripts/make_appcast.sh @@ -19,7 +19,8 @@ ZIP_NAME=$(basename "$ZIP") ZIP_BASE="${ZIP_NAME%.zip}" VERSION=${SPARKLE_RELEASE_VERSION:-} if [[ -z "$VERSION" ]]; then - if [[ "$ZIP_NAME" =~ ^OpenClaw-([0-9]+(\.[0-9]+){1,2}([-.][^.]*)?)\.zip$ ]]; then + # Accept legacy calver suffixes like -1 and prerelease forms like -beta.1 / .beta.1. + if [[ "$ZIP_NAME" =~ ^OpenClaw-([0-9]+(\.[0-9]+){1,2}([-.][0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?)\.zip$ ]]; then VERSION="${BASH_REMATCH[1]}" else echo "Could not infer version from $ZIP_NAME; set SPARKLE_RELEASE_VERSION." >&2 diff --git a/scripts/release-check.ts b/scripts/release-check.ts index 7e2bd449044..0ccc3efc1de 100755 --- a/scripts/release-check.ts +++ b/scripts/release-check.ts @@ -22,7 +22,12 @@ type PackageJson = { }; function normalizePluginSyncVersion(version: string): string { - return version.replace(/[-+].*$/, ""); + const normalized = version.trim().replace(/^v/, ""); + const base = /^([0-9]+\.[0-9]+\.[0-9]+)/.exec(normalized)?.[1]; + if (base) { + return base; + } + return normalized.replace(/[-+].*$/, ""); } function runPackDry(): PackResult[] { diff --git a/src/infra/update-channels.test.ts b/src/infra/update-channels.test.ts new file mode 100644 index 00000000000..b17133bb7fa --- /dev/null +++ b/src/infra/update-channels.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { isBetaTag, isStableTag } from "./update-channels.js"; + +describe("update-channels tag detection", () => { + it("recognizes both -beta and .beta formats", () => { + expect(isBetaTag("v2026.2.24-beta.1")).toBe(true); + expect(isBetaTag("v2026.2.24.beta.1")).toBe(true); + }); + + it("keeps legacy -x tags stable", () => { + expect(isBetaTag("v2026.2.24-1")).toBe(false); + expect(isStableTag("v2026.2.24-1")).toBe(true); + }); + + it("does not false-positive on non-beta words", () => { + expect(isBetaTag("v2026.2.24-alphabeta.1")).toBe(false); + expect(isStableTag("v2026.2.24")).toBe(true); + }); +}); diff --git a/src/infra/update-channels.ts b/src/infra/update-channels.ts index bfa7f868275..7e81b2ac648 100644 --- a/src/infra/update-channels.ts +++ b/src/infra/update-channels.ts @@ -27,7 +27,7 @@ export function channelToNpmTag(channel: UpdateChannel): string { } export function isBetaTag(tag: string): boolean { - return tag.toLowerCase().includes("-beta"); + return /(?:^|[.-])beta(?:[.-]|$)/i.test(tag); } export function isStableTag(tag: string): boolean { diff --git a/src/infra/update-check.test.ts b/src/infra/update-check.test.ts index faa3482efcd..560902aee83 100644 --- a/src/infra/update-check.test.ts +++ b/src/infra/update-check.test.ts @@ -1,5 +1,25 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { resolveNpmChannelTag } from "./update-check.js"; +import { compareSemverStrings, resolveNpmChannelTag } from "./update-check.js"; + +describe("compareSemverStrings", () => { + it("handles stable and prerelease precedence for both legacy and beta formats", () => { + expect(compareSemverStrings("1.0.0", "1.0.0")).toBe(0); + expect(compareSemverStrings("v1.0.0", "1.0.0")).toBe(0); + + expect(compareSemverStrings("1.0.0", "1.0.0-beta.1")).toBe(1); + expect(compareSemverStrings("1.0.0-beta.2", "1.0.0-beta.1")).toBe(1); + + expect(compareSemverStrings("1.0.0-2", "1.0.0-1")).toBe(1); + expect(compareSemverStrings("1.0.0-1", "1.0.0-beta.1")).toBe(-1); + expect(compareSemverStrings("1.0.0.beta.2", "1.0.0-beta.1")).toBe(1); + expect(compareSemverStrings("1.0.0", "1.0.0.beta.1")).toBe(1); + }); + + it("returns null for invalid inputs", () => { + expect(compareSemverStrings("1.0", "1.0.0")).toBeNull(); + expect(compareSemverStrings("latest", "1.0.0")).toBeNull(); + }); +}); describe("resolveNpmChannelTag", () => { let versionByTag: Record; @@ -43,4 +63,13 @@ describe("resolveNpmChannelTag", () => { expect(resolved).toEqual({ tag: "beta", version: "1.0.2-beta.1" }); }); + + it("falls back to latest when beta has same base as stable", async () => { + versionByTag.beta = "1.0.1-beta.2"; + versionByTag.latest = "1.0.1"; + + const resolved = await resolveNpmChannelTag({ channel: "beta", timeoutMs: 1000 }); + + expect(resolved).toEqual({ tag: "latest", version: "1.0.1" }); + }); }); diff --git a/src/infra/update-check.ts b/src/infra/update-check.ts index bdb11835c86..3890ceb8c46 100644 --- a/src/infra/update-check.ts +++ b/src/infra/update-check.ts @@ -3,7 +3,6 @@ import path from "node:path"; import { runCommandWithTimeout } from "../process/exec.js"; import { fetchWithTimeout } from "../utils/fetch-timeout.js"; import { detectPackageManager as detectPackageManagerImpl } from "./detect-package-manager.js"; -import { parseSemver } from "./runtime-guard.js"; import { channelToNpmTag, type UpdateChannel } from "./update-channels.js"; export type PackageManager = "pnpm" | "bun" | "npm" | "unknown"; @@ -342,8 +341,8 @@ export async function resolveNpmChannelTag(params: { } export function compareSemverStrings(a: string | null, b: string | null): number | null { - const pa = parseSemver(a); - const pb = parseSemver(b); + const pa = parseComparableSemver(a); + const pb = parseComparableSemver(b); if (!pa || !pb) { return null; } @@ -356,6 +355,94 @@ export function compareSemverStrings(a: string | null, b: string | null): number if (pa.patch !== pb.patch) { return pa.patch < pb.patch ? -1 : 1; } + return comparePrerelease(pa.prerelease, pb.prerelease); +} + +type ComparableSemver = { + major: number; + minor: number; + patch: number; + prerelease: string[] | null; +}; + +function parseComparableSemver(version: string | null): ComparableSemver | null { + if (!version) { + return null; + } + const normalized = normalizeLegacyDotBetaVersion(version.trim()); + const match = /^v?([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec( + normalized, + ); + if (!match) { + return null; + } + const [, major, minor, patch, prereleaseRaw] = match; + if (!major || !minor || !patch) { + return null; + } + return { + major: Number.parseInt(major, 10), + minor: Number.parseInt(minor, 10), + patch: Number.parseInt(patch, 10), + prerelease: prereleaseRaw ? prereleaseRaw.split(".").filter(Boolean) : null, + }; +} + +function normalizeLegacyDotBetaVersion(version: string): string { + const trimmed = version.trim(); + const dotBetaMatch = /^([vV]?[0-9]+\.[0-9]+\.[0-9]+)\.beta(?:\.([0-9A-Za-z.-]+))?$/.exec(trimmed); + if (!dotBetaMatch) { + return trimmed; + } + const base = dotBetaMatch[1]; + const suffix = dotBetaMatch[2]; + return suffix ? `${base}-beta.${suffix}` : `${base}-beta`; +} + +function comparePrerelease(a: string[] | null, b: string[] | null): number { + if (!a?.length && !b?.length) { + return 0; + } + if (!a?.length) { + return 1; + } + if (!b?.length) { + return -1; + } + + const max = Math.max(a.length, b.length); + for (let i = 0; i < max; i += 1) { + const ai = a[i]; + const bi = b[i]; + if (ai == null && bi == null) { + return 0; + } + if (ai == null) { + return -1; + } + if (bi == null) { + return 1; + } + if (ai === bi) { + continue; + } + + const aiNumeric = /^[0-9]+$/.test(ai); + const biNumeric = /^[0-9]+$/.test(bi); + if (aiNumeric && biNumeric) { + const aiNum = Number.parseInt(ai, 10); + const biNum = Number.parseInt(bi, 10); + return aiNum < biNum ? -1 : 1; + } + if (aiNumeric && !biNumeric) { + return -1; + } + if (!aiNumeric && biNumeric) { + return 1; + } + return ai < bi ? -1 : 1; + } + return 0; }