mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
fix: support legacy and beta prerelease version formats
This commit is contained in:
@@ -86,6 +86,7 @@
|
|||||||
|
|
||||||
- stable: tagged releases only (e.g. `vYYYY.M.D`), npm dist-tag `latest`.
|
- 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: 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-<patch>` and `vYYYY.M.D.beta.N` remain recognized.
|
||||||
- dev: moving head on `main` (no tag; git checkout main).
|
- dev: moving head on `main` (no tag; git checkout main).
|
||||||
|
|
||||||
## Testing Guidelines
|
## Testing Guidelines
|
||||||
|
|||||||
@@ -60,7 +60,9 @@ When you switch channels with `openclaw update`, OpenClaw also syncs plugin sour
|
|||||||
|
|
||||||
## Tagging best practices
|
## Tagging best practices
|
||||||
|
|
||||||
- Tag releases you want git checkouts to land on (`vYYYY.M.D` or `vYYYY.M.D-<patch>`).
|
- 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-<patch>` tags are still recognized as stable (non-beta).
|
||||||
- Keep tags immutable: never move or reuse a tag.
|
- Keep tags immutable: never move or reuse a tag.
|
||||||
- npm dist-tags remain the source of truth for npm installs:
|
- npm dist-tags remain the source of truth for npm installs:
|
||||||
- `latest` → stable
|
- `latest` → stable
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ ZIP_NAME=$(basename "$ZIP")
|
|||||||
ZIP_BASE="${ZIP_NAME%.zip}"
|
ZIP_BASE="${ZIP_NAME%.zip}"
|
||||||
VERSION=${SPARKLE_RELEASE_VERSION:-}
|
VERSION=${SPARKLE_RELEASE_VERSION:-}
|
||||||
if [[ -z "$VERSION" ]]; then
|
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]}"
|
VERSION="${BASH_REMATCH[1]}"
|
||||||
else
|
else
|
||||||
echo "Could not infer version from $ZIP_NAME; set SPARKLE_RELEASE_VERSION." >&2
|
echo "Could not infer version from $ZIP_NAME; set SPARKLE_RELEASE_VERSION." >&2
|
||||||
|
|||||||
@@ -22,7 +22,12 @@ type PackageJson = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function normalizePluginSyncVersion(version: string): string {
|
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[] {
|
function runPackDry(): PackResult[] {
|
||||||
|
|||||||
19
src/infra/update-channels.test.ts
Normal file
19
src/infra/update-channels.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -27,7 +27,7 @@ export function channelToNpmTag(channel: UpdateChannel): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isBetaTag(tag: string): boolean {
|
export function isBetaTag(tag: string): boolean {
|
||||||
return tag.toLowerCase().includes("-beta");
|
return /(?:^|[.-])beta(?:[.-]|$)/i.test(tag);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isStableTag(tag: string): boolean {
|
export function isStableTag(tag: string): boolean {
|
||||||
|
|||||||
@@ -1,5 +1,25 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
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", () => {
|
describe("resolveNpmChannelTag", () => {
|
||||||
let versionByTag: Record<string, string | null>;
|
let versionByTag: Record<string, string | null>;
|
||||||
@@ -43,4 +63,13 @@ describe("resolveNpmChannelTag", () => {
|
|||||||
|
|
||||||
expect(resolved).toEqual({ tag: "beta", version: "1.0.2-beta.1" });
|
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" });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import path from "node:path";
|
|||||||
import { runCommandWithTimeout } from "../process/exec.js";
|
import { runCommandWithTimeout } from "../process/exec.js";
|
||||||
import { fetchWithTimeout } from "../utils/fetch-timeout.js";
|
import { fetchWithTimeout } from "../utils/fetch-timeout.js";
|
||||||
import { detectPackageManager as detectPackageManagerImpl } from "./detect-package-manager.js";
|
import { detectPackageManager as detectPackageManagerImpl } from "./detect-package-manager.js";
|
||||||
import { parseSemver } from "./runtime-guard.js";
|
|
||||||
import { channelToNpmTag, type UpdateChannel } from "./update-channels.js";
|
import { channelToNpmTag, type UpdateChannel } from "./update-channels.js";
|
||||||
|
|
||||||
export type PackageManager = "pnpm" | "bun" | "npm" | "unknown";
|
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 {
|
export function compareSemverStrings(a: string | null, b: string | null): number | null {
|
||||||
const pa = parseSemver(a);
|
const pa = parseComparableSemver(a);
|
||||||
const pb = parseSemver(b);
|
const pb = parseComparableSemver(b);
|
||||||
if (!pa || !pb) {
|
if (!pa || !pb) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -356,6 +355,94 @@ export function compareSemverStrings(a: string | null, b: string | null): number
|
|||||||
if (pa.patch !== pb.patch) {
|
if (pa.patch !== pb.patch) {
|
||||||
return pa.patch < pb.patch ? -1 : 1;
|
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;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user