feat: support alpha releases

This commit is contained in:
Peter Steinberger
2026-05-02 18:29:01 +01:00
parent 831958c5d4
commit bb294bcd20
29 changed files with 237 additions and 68 deletions

View File

@@ -38,7 +38,7 @@ jobs:
RELEASE_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(-beta\.[1-9][0-9]*)?$ ]]; then
if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(-(alpha|beta)\.[1-9][0-9]*)?$ ]]; then
echo "Invalid release tag: ${RELEASE_TAG}"
exit 1
fi

View File

@@ -4,7 +4,7 @@ on:
workflow_dispatch:
inputs:
tag:
description: Existing release tag to validate for macOS release handoff (for example v2026.3.22 or v2026.3.22-beta.1)
description: Existing release tag to validate for macOS release handoff (for example v2026.3.22, v2026.3.22-alpha.1, or v2026.3.22-beta.1)
required: true
type: string
preflight_only:
@@ -38,7 +38,7 @@ jobs:
RELEASE_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*((-beta\.[1-9][0-9]*)|(-[1-9][0-9]*))?$ ]]; then
if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*((-(alpha|beta)\.[1-9][0-9]*)|(-[1-9][0-9]*))?$ ]]; then
echo "Invalid release tag format: ${RELEASE_TAG}"
exit 1
fi

View File

@@ -152,8 +152,8 @@ jobs:
set -euo pipefail
if [[ -z "${PACKAGE_ARTIFACT_NAME// }" ]]; then
if [[ ! "${PACKAGE_SPEC}" =~ ^openclaw@(beta|latest|[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(-[1-9][0-9]*|-beta\.[1-9][0-9]*)?)$ ]]; then
echo "package_spec must be openclaw@beta, openclaw@latest, or an exact OpenClaw release version; got: ${PACKAGE_SPEC}" >&2
if [[ ! "${PACKAGE_SPEC}" =~ ^openclaw@(alpha|beta|latest|[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(-[1-9][0-9]*|-(alpha|beta)\.[1-9][0-9]*)?)$ ]]; then
echo "package_spec must be openclaw@alpha, openclaw@beta, openclaw@latest, or an exact OpenClaw release version; got: ${PACKAGE_SPEC}" >&2
exit 1
fi
fi

View File

@@ -17,11 +17,12 @@ on:
required: false
type: string
npm_dist_tag:
description: npm dist-tag to publish to for stable releases
description: npm dist-tag to publish to
required: true
default: beta
type: choice
options:
- alpha
- beta
- latest
@@ -54,7 +55,7 @@ jobs:
RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }}
run: |
set -euo pipefail
if [[ ! "${RELEASE_REF}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*((-beta\.[1-9][0-9]*)|(-[1-9][0-9]*))?$ ]] && [[ ! "${RELEASE_REF}" =~ ^[0-9a-fA-F]{40}$ ]]; then
if [[ ! "${RELEASE_REF}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*((-(alpha|beta)\.[1-9][0-9]*)|(-[1-9][0-9]*))?$ ]] && [[ ! "${RELEASE_REF}" =~ ^[0-9a-fA-F]{40}$ ]]; then
echo "Invalid release ref format: ${RELEASE_REF}"
exit 1
fi
@@ -62,6 +63,10 @@ jobs:
echo "Full commit SHA input is only supported for validation-only preflight runs."
exit 1
fi
if [[ "${RELEASE_REF}" == *"-alpha."* && "${RELEASE_NPM_DIST_TAG}" != "alpha" ]]; then
echo "Alpha prerelease tags must publish to npm dist-tag alpha."
exit 1
fi
if [[ "${RELEASE_REF}" == *"-beta."* && "${RELEASE_NPM_DIST_TAG}" != "beta" ]]; then
echo "Beta prerelease tags must publish to npm dist-tag beta."
exit 1
@@ -294,10 +299,14 @@ jobs:
RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }}
run: |
set -euo pipefail
if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*((-beta\.[1-9][0-9]*)|(-[1-9][0-9]*))?$ ]]; then
if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*((-(alpha|beta)\.[1-9][0-9]*)|(-[1-9][0-9]*))?$ ]]; then
echo "Invalid release tag format: ${RELEASE_TAG}"
exit 1
fi
if [[ "${RELEASE_TAG}" == *"-alpha."* && "${RELEASE_NPM_DIST_TAG}" != "alpha" ]]; then
echo "Alpha prerelease tags must publish to npm dist-tag alpha."
exit 1
fi
if [[ "${RELEASE_TAG}" == *"-beta."* && "${RELEASE_NPM_DIST_TAG}" != "beta" ]]; then
echo "Beta prerelease tags must publish to npm dist-tag beta."
exit 1

View File

@@ -4,7 +4,7 @@ on:
workflow_dispatch:
inputs:
tag:
description: Release tag to publish, for example v2026.5.1-beta.1
description: Release tag to publish, for example v2026.5.1-alpha.1 or v2026.5.1-beta.1
required: true
type: string
preflight_run_id:
@@ -17,6 +17,7 @@ on:
default: beta
type: choice
options:
- alpha
- beta
- latest
plugin_publish_scope:
@@ -69,10 +70,14 @@ jobs:
WORKFLOW_REF: ${{ github.ref }}
run: |
set -euo pipefail
if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*((-beta\.[1-9][0-9]*)|(-[1-9][0-9]*))?$ ]]; then
if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*((-(alpha|beta)\.[1-9][0-9]*)|(-[1-9][0-9]*))?$ ]]; then
echo "Invalid release tag: ${RELEASE_TAG}" >&2
exit 1
fi
if [[ "${RELEASE_TAG}" == *"-alpha."* && "${RELEASE_NPM_DIST_TAG}" != "alpha" ]]; then
echo "Alpha prerelease tags must publish OpenClaw to npm dist-tag alpha." >&2
exit 1
fi
if [[ "${RELEASE_TAG}" == *"-beta."* && "${RELEASE_NPM_DIST_TAG}" != "beta" ]]; then
echo "Beta prerelease tags must publish OpenClaw to npm dist-tag beta." >&2
exit 1

View File

@@ -239,7 +239,7 @@ Use `Package Acceptance` when the question is "does this installable OpenClaw pa
### Candidate sources
- `source=npm` accepts only `openclaw@beta`, `openclaw@latest`, or an exact OpenClaw release version such as `openclaw@2026.4.27-beta.2`. Use this for published beta/stable acceptance.
- `source=npm` accepts only `openclaw@alpha`, `openclaw@beta`, `openclaw@latest`, or an exact OpenClaw release version such as `openclaw@2026.4.27-beta.2`. Use this for published prerelease/stable acceptance.
- `source=ref` packs a trusted `package_ref` branch, tag, or full commit SHA. The resolver fetches OpenClaw branches/tags, verifies the selected commit is reachable from repository branch history or a release tag, installs deps in a detached worktree, and packs it with `scripts/package-openclaw-for-docker.mjs`.
- `source=url` downloads an HTTPS `.tgz`; `package_sha256` is required.
- `source=artifact` downloads one `.tgz` from `artifact_run_id` and `artifact_name`; `package_sha256` is optional but should be supplied for externally shared artifacts.

View File

@@ -7,9 +7,10 @@ read_when:
- Looking for version naming and cadence
---
OpenClaw has three public release lanes:
OpenClaw has four public release lanes:
- stable: tagged releases that publish to npm `beta` by default, or to npm `latest` when explicitly requested
- alpha: prerelease tags that publish to npm `alpha`
- beta: prerelease tags that publish to npm `beta`
- dev: the moving head of `main`
@@ -19,10 +20,13 @@ OpenClaw has three public release lanes:
- Git tag: `vYYYY.M.D`
- Stable correction release version: `YYYY.M.D-N`
- Git tag: `vYYYY.M.D-N`
- Alpha prerelease version: `YYYY.M.D-alpha.N`
- Git tag: `vYYYY.M.D-alpha.N`
- Beta prerelease version: `YYYY.M.D-beta.N`
- Git tag: `vYYYY.M.D-beta.N`
- Do not zero-pad month or day
- `latest` means the current promoted stable npm release
- `alpha` means the current alpha install target
- `beta` means the current beta install target
- Stable and stable correction releases publish to npm `beta` by default; release operators can target `latest` explicitly, or promote a vetted beta build later
- Every stable OpenClaw release ships the npm package and macOS app together;
@@ -75,14 +79,15 @@ the maintainer-only release runbook.
file, lane, workflow job, package profile, provider, or model allowlist that
proves the fix. Rerun the full umbrella only when the changed surface makes
prior evidence stale.
9. For beta, tag `vYYYY.M.D-beta.N`, then run `OpenClaw Release Publish` from
9. For alpha or beta, tag `vYYYY.M.D-alpha.N` or `vYYYY.M.D-beta.N`, then run `OpenClaw Release Publish` from
the matching `release/YYYY.M.D` branch. It verifies `pnpm plugins:sync:check`,
publishes all publishable plugin packages to npm first, publishes the same
set to ClawHub second, and then promotes the prepared OpenClaw npm preflight
artifact with dist-tag `beta`. After publish, run post-publish package
acceptance against the published `openclaw@YYYY.M.D-beta.N` or `openclaw@beta`
package. If a pushed or published beta needs a fix, cut the next `-beta.N`;
do not delete or rewrite the old beta.
artifact with the matching dist-tag. After publish, run post-publish package
acceptance against the published `openclaw@YYYY.M.D-alpha.N`, `openclaw@alpha`,
`openclaw@YYYY.M.D-beta.N`, or `openclaw@beta` package. If a pushed or
published prerelease needs a fix, cut the next matching prerelease number;
do not delete or rewrite the old prerelease.
10. For stable, continue only after the vetted beta or release candidate has the
required validation evidence. Stable npm publish also goes through
`OpenClaw Release Publish`, reusing the successful preflight artifact via
@@ -124,7 +129,7 @@ the maintainer-only release runbook.
`gh workflow run full-release-validation.yml --ref main -f ref=release/YYYY.M.D`
- Run the manual `Package Acceptance` workflow when you want side-channel proof
for a package candidate while release work continues. Use `source=npm` for
`openclaw@beta`, `openclaw@latest`, or an exact release version; `source=ref`
`openclaw@alpha`, `openclaw@beta`, `openclaw@latest`, or an exact release version; `source=ref`
to pack a trusted `package_ref` branch/tag/SHA with the current
`workflow_ref` harness; `source=url` for an HTTPS tarball with a required
SHA-256; or `source=artifact` for a tarball uploaded by another GitHub
@@ -548,6 +553,16 @@ gh workflow run openclaw-release-publish.yml \
-f npm_dist_tag=beta
```
Alpha publish example:
```bash
gh workflow run openclaw-release-publish.yml \
--ref release/YYYY.M.D \
-f tag=vYYYY.M.D-alpha.N \
-f preflight_run_id=<successful-openclaw-npm-preflight-run-id> \
-f npm_dist_tag=alpha
```
Stable publish to the default beta dist-tag:
```bash
@@ -579,7 +594,7 @@ OpenClaw package must not be published.
`OpenClaw NPM Release` accepts these operator-controlled inputs:
- `tag`: required release tag such as `v2026.4.2`, `v2026.4.2-1`, or
`v2026.4.2-beta.1`; when `preflight_only=true`, it may also be the current
`v2026.4.2-alpha.1` or `v2026.4.2-beta.1`; when `preflight_only=true`, it may also be the current
full 40-character workflow-branch commit SHA for validation-only preflight
- `preflight_only`: `true` for validation/build/package only, `false` for the
real publish path
@@ -609,6 +624,7 @@ OpenClaw package must not be published.
Rules:
- Stable and correction tags may publish to either `beta` or `latest`
- Alpha prerelease tags may publish only to `alpha`
- Beta prerelease tags may publish only to `beta`
- For `OpenClaw NPM Release`, full commit SHA input is allowed only when
`preflight_only=true`

View File

@@ -70,10 +70,10 @@ rm -f "$SUMMARY_JSON" "$CONFIG_COVERAGE_JSON"
validate_baseline_package_spec() {
local spec="$1"
if [[ "$spec" =~ ^openclaw@(beta|latest|[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(-[1-9][0-9]*|-beta\.[1-9][0-9]*)?)$ ]]; then
if [[ "$spec" =~ ^openclaw@(alpha|beta|latest|[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(-[1-9][0-9]*|-(alpha|beta)\.[1-9][0-9]*)?)$ ]]; then
return 0
fi
echo "OPENCLAW_UPGRADE_SURVIVOR_BASELINE must be openclaw@latest, openclaw@beta, an exact OpenClaw release version, or a bare release version; got: $spec" >&2
echo "OPENCLAW_UPGRADE_SURVIVOR_BASELINE must be openclaw@latest, openclaw@beta, openclaw@alpha, an exact OpenClaw release version, or a bare release version; got: $spec" >&2
return 1
}
@@ -98,12 +98,12 @@ normalize_baseline() {
;;
esac
case "$baseline_version" in
latest | beta)
latest | beta | alpha)
baseline_version=""
baseline_version_expected="0"
;;
dev | main | "")
echo "OPENCLAW_UPGRADE_SURVIVOR_BASELINE must be openclaw@latest, openclaw@beta, openclaw@<version>, or a bare version" >&2
echo "OPENCLAW_UPGRADE_SURVIVOR_BASELINE must be openclaw@latest, openclaw@beta, openclaw@alpha, openclaw@<version>, or a bare version" >&2
return 1
;;
*)

View File

@@ -41,10 +41,10 @@ resolve_credential_role() {
validate_openclaw_package_spec() {
local spec="$1"
if [[ "$spec" =~ ^openclaw@(beta|latest|[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(-[1-9][0-9]*|-beta\.[1-9][0-9]*)?)$ ]]; then
if [[ "$spec" =~ ^openclaw@(alpha|beta|latest|[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(-[1-9][0-9]*|-(alpha|beta)\.[1-9][0-9]*)?)$ ]]; then
return 0
fi
echo "OPENCLAW_NPM_TELEGRAM_PACKAGE_SPEC must be openclaw@beta, openclaw@latest, or an exact OpenClaw release version; got: $spec" >&2
echo "OPENCLAW_NPM_TELEGRAM_PACKAGE_SPEC must be openclaw@alpha, openclaw@beta, openclaw@latest, or an exact OpenClaw release version; got: $spec" >&2
exit 1
}

View File

@@ -13,10 +13,10 @@ OUTPUT_DIR="${OPENCLAW_NPM_TELEGRAM_OUTPUT_DIR:-.artifacts/qa-e2e/npm-telegram-r
validate_openclaw_package_spec() {
local spec="$1"
if [[ "$spec" =~ ^openclaw@(main|beta|latest|[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(-[1-9][0-9]*|-beta\.[1-9][0-9]*)?)$ ]]; then
if [[ "$spec" =~ ^openclaw@(main|alpha|beta|latest|[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(-[1-9][0-9]*|-(alpha|beta)\.[1-9][0-9]*)?)$ ]]; then
return 0
fi
echo "OPENCLAW_NPM_TELEGRAM_PACKAGE_SPEC must be openclaw@main, openclaw@beta, openclaw@latest, or an exact OpenClaw release version; got: $spec" >&2
echo "OPENCLAW_NPM_TELEGRAM_PACKAGE_SPEC must be openclaw@main, openclaw@alpha, openclaw@beta, openclaw@latest, or an exact OpenClaw release version; got: $spec" >&2
exit 1
}

View File

@@ -91,12 +91,14 @@ export function normalizeUpgradeSurvivorBaselineSpec(raw) {
}
const spec = value.startsWith("openclaw@") ? value : `openclaw@${value}`;
if (
!/^openclaw@(?:beta|latest|[0-9]{4}\.[0-9]+\.[0-9]+(?:-(?:[0-9]+|beta\.[0-9]+))?)$/u.test(spec)
!/^openclaw@(?:alpha|beta|latest|[0-9]{4}\.[0-9]+\.[0-9]+(?:-(?:[0-9]+|alpha\.[0-9]+|beta\.[0-9]+))?)$/u.test(
spec,
)
) {
throw new Error(
`invalid published upgrade survivor baseline: ${JSON.stringify(
value,
)}. Expected openclaw@latest, openclaw@beta, or openclaw@YYYY.M.D.`,
)}. Expected openclaw@latest, openclaw@beta, openclaw@alpha, or openclaw@YYYY.M.D.`,
);
}
return spec;

View File

@@ -7,7 +7,7 @@ const IOS_VERSION_XCCONFIG_FILE = "apps/ios/Config/Version.xcconfig";
const IOS_RELEASE_NOTES_FILE = "apps/ios/fastlane/metadata/en-US/release_notes.txt";
const PINNED_IOS_VERSION_PATTERN = /^(\d{4}\.\d{1,2}\.\d{1,2})$/u;
const GATEWAY_VERSION_PATTERN = /^(\d{4}\.\d{1,2}\.\d{1,2})(?:-(?:beta\.\d+|\d+))?$/u;
const GATEWAY_VERSION_PATTERN = /^(\d{4}\.\d{1,2}\.\d{1,2})(?:-(?:alpha\.\d+|beta\.\d+|\d+))?$/u;
type IosVersionManifest = {
version: string;
@@ -52,7 +52,7 @@ export function normalizeGatewayVersionToPinnedIosVersion(rawVersion: string): s
const match = GATEWAY_VERSION_PATTERN.exec(trimmed);
if (!match) {
throw new Error(
`Invalid gateway version '${rawVersion}'. Expected YYYY.M.D, YYYY.M.D-beta.N, or YYYY.M.D-N.`,
`Invalid gateway version '${rawVersion}'. Expected YYYY.M.D, YYYY.M.D-alpha.N, YYYY.M.D-beta.N, or YYYY.M.D-N.`,
);
}

View File

@@ -1,4 +1,6 @@
const STABLE_VERSION_REGEX = /^(?<year>\d{4})\.(?<month>[1-9]\d?)\.(?<day>[1-9]\d?)$/;
const ALPHA_VERSION_REGEX =
/^(?<year>\d{4})\.(?<month>[1-9]\d?)\.(?<day>[1-9]\d?)-alpha\.(?<alpha>[1-9]\d*)$/;
const BETA_VERSION_REGEX =
/^(?<year>\d{4})\.(?<month>[1-9]\d?)\.(?<day>[1-9]\d?)-beta\.(?<beta>[1-9]\d*)$/;
const CORRECTION_VERSION_REGEX =
@@ -8,10 +10,11 @@ const CORRECTION_VERSION_REGEX =
* @typedef {object} ParsedReleaseVersion
* @property {string} version
* @property {string} baseVersion
* @property {"stable" | "beta"} channel
* @property {"stable" | "alpha" | "beta"} channel
* @property {number} year
* @property {number} month
* @property {number} day
* @property {number | undefined} [alphaNumber]
* @property {number | undefined} [betaNumber]
* @property {number | undefined} [correctionNumber]
* @property {Date} date
@@ -19,9 +22,9 @@ const CORRECTION_VERSION_REGEX =
/**
* @typedef {object} NpmPublishPlan
* @property {"stable" | "beta"} channel
* @property {"latest" | "beta"} publishTag
* @property {("latest" | "beta")[]} mirrorDistTags
* @property {"stable" | "alpha" | "beta"} channel
* @property {"latest" | "alpha" | "beta"} publishTag
* @property {("latest" | "alpha" | "beta")[]} mirrorDistTags
*/
/**
@@ -37,13 +40,14 @@ const CORRECTION_VERSION_REGEX =
/**
* @param {string} version
* @param {Record<string, string | undefined>} groups
* @param {"stable" | "beta"} channel
* @param {"stable" | "alpha" | "beta"} channel
* @returns {ParsedReleaseVersion | null}
*/
function parseDateParts(version, groups, channel) {
const year = Number.parseInt(groups.year ?? "", 10);
const month = Number.parseInt(groups.month ?? "", 10);
const day = Number.parseInt(groups.day ?? "", 10);
const alphaNumber = channel === "alpha" ? Number.parseInt(groups.alpha ?? "", 10) : undefined;
const betaNumber = channel === "beta" ? Number.parseInt(groups.beta ?? "", 10) : undefined;
if (
@@ -60,6 +64,9 @@ function parseDateParts(version, groups, channel) {
if (channel === "beta" && (!Number.isInteger(betaNumber) || (betaNumber ?? 0) < 1)) {
return null;
}
if (channel === "alpha" && (!Number.isInteger(alphaNumber) || (alphaNumber ?? 0) < 1)) {
return null;
}
const date = new Date(Date.UTC(year, month - 1, day));
if (
@@ -77,6 +84,7 @@ function parseDateParts(version, groups, channel) {
year,
month,
day,
alphaNumber,
betaNumber,
date,
};
@@ -97,6 +105,11 @@ export function parseReleaseVersion(version) {
return parseDateParts(trimmed, stableMatch.groups, "stable");
}
const alphaMatch = ALPHA_VERSION_REGEX.exec(trimmed);
if (alphaMatch?.groups) {
return parseDateParts(trimmed, alphaMatch.groups, "alpha");
}
const betaMatch = BETA_VERSION_REGEX.exec(trimmed);
if (betaMatch?.groups) {
return parseDateParts(trimmed, betaMatch.groups, "beta");
@@ -137,7 +150,12 @@ export function compareReleaseVersions(left, right) {
}
if (parsedLeft.channel !== parsedRight.channel) {
return parsedLeft.channel === "stable" ? 1 : -1;
const rank = { alpha: 0, beta: 1, stable: 2 };
return Math.sign(rank[parsedLeft.channel] - rank[parsedRight.channel]);
}
if (parsedLeft.channel === "alpha" && parsedRight.channel === "alpha") {
return Math.sign((parsedLeft.alphaNumber ?? 0) - (parsedRight.alphaNumber ?? 0));
}
if (parsedLeft.channel === "beta" && parsedRight.channel === "beta") {
@@ -165,6 +183,13 @@ export function resolveNpmPublishPlan(version, currentBetaVersion) {
mirrorDistTags: [],
};
}
if (parsedVersion.channel === "alpha") {
return {
channel: "alpha",
publishTag: "alpha",
mirrorDistTags: [],
};
}
const normalizedCurrentBeta = currentBetaVersion?.trim();
if (normalizedCurrentBeta) {

View File

@@ -46,8 +46,8 @@ export type PublishablePluginPackage = {
packageDir: string;
packageName: string;
version: string;
channel: "stable" | "beta";
publishTag: "latest" | "beta";
channel: "stable" | "alpha" | "beta";
publishTag: "latest" | "alpha" | "beta";
};
type PluginReleasePlanItem = PublishablePluginPackage & {
@@ -154,7 +154,12 @@ export function collectClawHubPublishablePluginPackages(
packageName,
version,
channel: parsedVersion.channel,
publishTag: parsedVersion.channel === "beta" ? "beta" : "latest",
publishTag:
parsedVersion.channel === "alpha"
? "alpha"
: parsedVersion.channel === "beta"
? "beta"
: "latest",
});
}

View File

@@ -34,8 +34,8 @@ export type PublishablePluginPackage = {
packageDir: string;
packageName: string;
version: string;
channel: "stable" | "beta";
publishTag: "latest" | "beta";
channel: "stable" | "alpha" | "beta";
publishTag: "latest" | "alpha" | "beta";
installNpmSpec?: string;
};
@@ -117,7 +117,7 @@ export function resolvePublishablePluginVersion(params: {
const parsedVersion = parseReleaseVersion(version);
if (parsedVersion === null) {
params.validationErrors.push(
`${params.extensionId}: package.json version must match YYYY.M.D, YYYY.M.D-N, or YYYY.M.D-beta.N; found "${version}".`,
`${params.extensionId}: package.json version must match YYYY.M.D, YYYY.M.D-N, YYYY.M.D-alpha.N, or YYYY.M.D-beta.N; found "${version}".`,
);
return null;
}
@@ -244,7 +244,7 @@ export function collectPublishablePluginPackageErrors(
errors.push("package.json version must be non-empty.");
} else if (parseReleaseVersion(packageVersion) === null) {
errors.push(
`package.json version must match YYYY.M.D, YYYY.M.D-N, or YYYY.M.D-beta.N; found "${packageVersion}".`,
`package.json version must match YYYY.M.D, YYYY.M.D-N, YYYY.M.D-alpha.N, or YYYY.M.D-beta.N; found "${packageVersion}".`,
);
}
if (!Array.isArray(extensions) || extensions.length === 0) {

View File

@@ -64,7 +64,7 @@ type TelegramQaSummary = {
};
const OPENCLAW_PACKAGE_SPEC_RE =
/^openclaw@(main|beta|latest|[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(-[1-9][0-9]*|-beta\.[1-9][0-9]*)?)$/u;
/^openclaw@(main|alpha|beta|latest|[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(-[1-9][0-9]*|-(alpha|beta)\.[1-9][0-9]*)?)$/u;
const REQUIRED_TELEGRAM_ENV = [
"OPENCLAW_QA_TELEGRAM_GROUP_ID",
@@ -75,7 +75,7 @@ const REQUIRED_TELEGRAM_ENV = [
export function validateOpenClawPackageSpec(spec: string) {
if (!OPENCLAW_PACKAGE_SPEC_RE.test(spec)) {
throw new Error(
`Package spec must be openclaw@main, openclaw@beta, openclaw@latest, or an exact OpenClaw release version; got: ${spec}`,
`Package spec must be openclaw@main, openclaw@alpha, openclaw@beta, openclaw@latest, or an exact OpenClaw release version; got: ${spec}`,
);
}
return spec;

View File

@@ -29,7 +29,7 @@ ZIP_NAME=$(basename "$ZIP")
ZIP_BASE="${ZIP_NAME%.zip}"
VERSION=${SPARKLE_RELEASE_VERSION:-}
if [[ -z "$VERSION" ]]; then
# Accept legacy calver suffixes like -1 and prerelease forms like -beta.1 / .beta.1.
# Accept legacy calver suffixes like -1 and prerelease forms like -alpha.1 / -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

View File

@@ -184,7 +184,7 @@ function parseBooleanEnv(name, fallback) {
export function looksLikeReleaseVersionRef(ref) {
const trimmed = normalizeRequestedRef(ref);
return /^v?[0-9]{4}\.[0-9]+\.[0-9]+(?:-(?:[1-9][0-9]*)|[-.](?:beta|rc)[-.]?[0-9]+)?$/iu.test(
return /^v?[0-9]{4}\.[0-9]+\.[0-9]+(?:-(?:[1-9][0-9]*)|[-.](?:alpha|beta|rc)[-.]?[0-9]+)?$/iu.test(
trimmed,
);
}

View File

@@ -24,7 +24,11 @@ mapfile -t publish_plan < <(
import { resolveNpmPublishPlan } from "./scripts/openclaw-npm-release-check.ts";
const requestedPublishTag =
process.env.REQUESTED_PUBLISH_TAG === "latest" ? "latest" : "beta";
process.env.REQUESTED_PUBLISH_TAG === "latest"
? "latest"
: process.env.REQUESTED_PUBLISH_TAG === "alpha"
? "alpha"
: "beta";
const plan = resolveNpmPublishPlan(process.env.PACKAGE_VERSION ?? "", undefined, requestedPublishTag);
console.log(plan.channel);
console.log(plan.publishTag);

View File

@@ -32,10 +32,11 @@ type PackageJson = {
export type ParsedReleaseVersion = {
version: string;
baseVersion: string;
channel: "stable" | "beta";
channel: "stable" | "alpha" | "beta";
year: number;
month: number;
day: number;
alphaNumber?: number;
betaNumber?: number;
correctionNumber?: number;
date: Date;
@@ -45,15 +46,15 @@ export type ParsedReleaseTag = {
version: string;
packageVersion: string;
baseVersion: string;
channel: "stable" | "beta";
channel: "stable" | "alpha" | "beta";
correctionNumber?: number;
date: Date;
};
export type NpmPublishPlan = {
channel: "stable" | "beta";
publishTag: "latest" | "beta";
mirrorDistTags: ("latest" | "beta")[];
channel: "stable" | "alpha" | "beta";
publishTag: "latest" | "alpha" | "beta";
mirrorDistTags: ("latest" | "alpha" | "beta")[];
};
export type NpmDistTagMirrorAuth = {
@@ -193,14 +194,30 @@ export function compareReleaseVersions(left: string, right: string): number | nu
export function resolveNpmPublishPlan(
version: string,
_currentBetaVersion?: string | null,
requestedPublishTag?: "latest" | "beta" | null,
requestedPublishTag?: "latest" | "alpha" | "beta" | null,
): NpmPublishPlan {
const parsedVersion = parseReleaseVersion(version);
if (parsedVersion === null) {
throw new Error(`Unsupported release version "${version}".`);
}
const publishTag = requestedPublishTag?.trim() === "latest" ? "latest" : "beta";
const publishTag =
requestedPublishTag?.trim() === "latest"
? "latest"
: requestedPublishTag?.trim() === "alpha"
? "alpha"
: "beta";
if (parsedVersion.channel === "alpha") {
if (publishTag !== "alpha") {
throw new Error("Alpha prereleases must publish to the alpha dist-tag.");
}
return {
channel: "alpha",
publishTag: "alpha",
mirrorDistTags: [],
};
}
if (parsedVersion.channel === "beta") {
if (publishTag !== "beta") {
@@ -336,7 +353,7 @@ export function collectReleaseTagErrors(params: {
const parsedVersion = parseReleaseVersion(packageVersion);
if (parsedVersion === null) {
errors.push(
`package.json version must match YYYY.M.D, YYYY.M.D-N, or YYYY.M.D-beta.N; found "${packageVersion || "<missing>"}".`,
`package.json version must match YYYY.M.D, YYYY.M.D-N, YYYY.M.D-alpha.N, or YYYY.M.D-beta.N; found "${packageVersion || "<missing>"}".`,
);
}
@@ -348,7 +365,7 @@ export function collectReleaseTagErrors(params: {
const parsedTag = parseReleaseTagVersion(tagVersion);
if (parsedTag === null) {
errors.push(
`Release tag must match vYYYY.M.D, vYYYY.M.D-beta.N, or fallback correction tag vYYYY.M.D-N; found "${releaseTag || "<missing>"}".`,
`Release tag must match vYYYY.M.D, vYYYY.M.D-alpha.N, vYYYY.M.D-beta.N, or fallback correction tag vYYYY.M.D-N; found "${releaseTag || "<missing>"}".`,
);
}

View File

@@ -12,7 +12,7 @@ import { fileURLToPath } from "node:url";
const ROOT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const DEFAULT_OUTPUT_NAME = "openclaw-current.tgz";
export const OPENCLAW_PACKAGE_SPEC_RE =
/^openclaw@(beta|latest|[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(-[1-9][0-9]*|-beta\.[1-9][0-9]*)?)$/u;
/^openclaw@(alpha|beta|latest|[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(-[1-9][0-9]*|-(alpha|beta)\.[1-9][0-9]*)?)$/u;
function usage() {
return `Usage: node scripts/resolve-openclaw-package-candidate.mjs --source <ref|npm|url|artifact> --output-dir <dir> [options]
@@ -82,7 +82,7 @@ export function parseArgs(argv) {
export function validateOpenClawPackageSpec(spec) {
if (!OPENCLAW_PACKAGE_SPEC_RE.test(spec)) {
throw new Error(
`package_spec must be openclaw@beta, openclaw@latest, or an exact OpenClaw release version; got: ${spec}`,
`package_spec must be openclaw@alpha, openclaw@beta, openclaw@latest, or an exact OpenClaw release version; got: ${spec}`,
);
}
}

View File

@@ -45,6 +45,16 @@ describe("shouldRequireNpmDistTagMirrorAuth", () => {
).toBe(false);
});
it("publishes alpha prereleases without dist-tag mirroring", () => {
const plan = resolveNpmPublishPlan("2026.4.1-alpha.1");
expect(plan).toEqual({
channel: "alpha",
publishTag: "alpha",
mirrorDistTags: [],
});
});
it("does not require auth when a publish already has npm auth", () => {
const plan = resolveNpmPublishPlan("2026.4.1");
const auth = resolveNpmDistTagMirrorAuth({ npmToken: "token" });

View File

@@ -54,6 +54,18 @@ describe("parseReleaseVersion", () => {
});
});
it("parses alpha CalVer releases", () => {
expect(parseReleaseVersion("2026.3.10-alpha.2")).toMatchObject({
version: "2026.3.10-alpha.2",
baseVersion: "2026.3.10",
channel: "alpha",
year: 2026,
month: 3,
day: 10,
alphaNumber: 2,
});
});
it("parses stable correction releases", () => {
expect(parseReleaseVersion("2026.3.10-1")).toMatchObject({
version: "2026.3.10-1",
@@ -101,6 +113,14 @@ describe("resolveNpmPublishPlan", () => {
});
});
it("publishes alpha prereleases to alpha only", () => {
expect(resolveNpmPublishPlan("2026.3.29-alpha.2", undefined, "alpha")).toEqual({
channel: "alpha",
publishTag: "alpha",
mirrorDistTags: [],
});
});
it("publishes stable releases to beta first", () => {
expect(resolveNpmPublishPlan("2026.3.29")).toEqual({
channel: "stable",
@@ -138,6 +158,15 @@ describe("resolveNpmPublishPlan", () => {
"Beta prereleases must publish to the beta dist-tag.",
);
});
it("rejects publishing alpha prereleases to beta or latest", () => {
expect(() => resolveNpmPublishPlan("2026.3.29-alpha.2")).toThrow(
"Alpha prereleases must publish to the alpha dist-tag.",
);
expect(() => resolveNpmPublishPlan("2026.3.29-alpha.2", undefined, "latest")).toThrow(
"Alpha prereleases must publish to the alpha dist-tag.",
);
});
});
describe("resolveNpmDistTagMirrorAuth", () => {
@@ -205,6 +234,10 @@ describe("compareReleaseVersions", () => {
expect(compareReleaseVersions("2026.3.29", "2026.3.29-beta.2")).toBe(1);
});
it("orders alpha before beta on the same day", () => {
expect(compareReleaseVersions("2026.3.29-alpha.2", "2026.3.29-beta.1")).toBe(-1);
});
it("treats a newer beta day as newer than an older stable day", () => {
expect(compareReleaseVersions("2026.4.1-beta.1", "2026.3.29")).toBe(1);
});

View File

@@ -134,7 +134,7 @@ describe("collectPublishablePluginPackageErrors", () => {
'package name must start with "@openclaw/"; found "broken".',
"package.json private must not be true.",
`package.json repository.url must be "${OPENCLAW_PLUGIN_NPM_REPOSITORY_URL}" so npm provenance can validate GitHub trusted publishing; found "<missing>".`,
'package.json version must match YYYY.M.D, YYYY.M.D-N, or YYYY.M.D-beta.N; found "latest".',
'package.json version must match YYYY.M.D, YYYY.M.D-N, YYYY.M.D-alpha.N, or YYYY.M.D-beta.N; found "latest".',
"openclaw.extensions must contain only non-empty strings.",
"openclaw.install.npmSpec must be a non-empty string for publishable plugins.",
]);
@@ -314,6 +314,37 @@ describe("collectPublishablePluginPackages", () => {
}),
).toEqual([]);
});
it("publishes alpha plugin packages to the alpha dist-tag", () => {
const repoDir = makeTempRepoRoot(tempDirs, "openclaw-plugin-npm-release-");
mkdirSync(join(repoDir, "extensions", "demo-plugin"), { recursive: true });
writeJsonFile(join(repoDir, "extensions", "demo-plugin", "package.json"), {
name: "@openclaw/demo-plugin",
version: "2026.4.10-alpha.1",
repository: {
type: "git",
url: OPENCLAW_PLUGIN_NPM_REPOSITORY_URL,
},
openclaw: {
extensions: ["./index.ts"],
install: {
npmSpec: "@openclaw/demo-plugin",
},
release: {
publishToNpm: true,
},
},
});
expect(collectPublishablePluginPackages(repoDir)).toEqual([
expect.objectContaining({
channel: "alpha",
packageName: "@openclaw/demo-plugin",
publishTag: "alpha",
version: "2026.4.10-alpha.1",
}),
]);
});
});
describe("resolveSelectedPublishablePluginPackages", () => {

View File

@@ -55,6 +55,10 @@ describe("gateway version normalization", () => {
expect(normalizeGatewayVersionToPinnedIosVersion("2026.4.6-beta.2")).toBe("2026.4.6");
});
it("strips alpha suffixes when pinning from gateway version", () => {
expect(normalizeGatewayVersionToPinnedIosVersion("2026.4.6-alpha.2")).toBe("2026.4.6");
});
it("strips fallback correction suffixes when pinning from gateway version", () => {
expect(normalizeGatewayVersionToPinnedIosVersion("2026.4.6-3")).toBe("2026.4.6");
});

View File

@@ -233,6 +233,8 @@ describe("scripts/openclaw-cross-os-release-checks", () => {
expect(looksLikeReleaseVersionRef("2026.4.5")).toBe(true);
expect(looksLikeReleaseVersionRef("refs/tags/v2026.4.5-beta.1")).toBe(true);
expect(looksLikeReleaseVersionRef("v2026.4.5-beta.1")).toBe(true);
expect(looksLikeReleaseVersionRef("refs/tags/v2026.4.5-alpha.1")).toBe(true);
expect(looksLikeReleaseVersionRef("v2026.4.5-alpha.1")).toBe(true);
expect(looksLikeReleaseVersionRef("v2026.4.7-1")).toBe(true);
expect(looksLikeReleaseVersionRef("main")).toBe(false);
expect(looksLikeReleaseVersionRef("codex/cross-os-release-checks")).toBe(false);

View File

@@ -228,7 +228,7 @@ describe("package artifact reuse", () => {
expect(scheduler).toContain('["OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS",');
expect(packageJson).toContain("OPENCLAW_UPGRADE_SURVIVOR_PUBLISHED_BASELINE=1");
expect(publishedUpgradeSurvivor).toContain("validate_baseline_package_spec");
expect(publishedUpgradeSurvivor).toContain("openclaw@(beta|latest|");
expect(publishedUpgradeSurvivor).toContain("openclaw@(alpha|beta|latest|");
expect(publishedUpgradeSurvivor).toContain("plugin_deps_cleanup_plugin_dirs");
expect(publishedUpgradeSurvivor).toContain('"$(package_root)/extensions/$plugin"');
expect(publishedUpgradeSurvivor).toContain("probe_gateway_endpoint");
@@ -623,7 +623,7 @@ describe("package artifact reuse", () => {
});
expectTextToIncludeAll(validateStep.run, [
'if [[ -z "${PACKAGE_ARTIFACT_NAME// }" ]]; then',
"package_spec must be openclaw@beta",
"package_spec must be openclaw@alpha",
]);
expectTextToIncludeAll(runStep.run, [
'export OPENCLAW_NPM_TELEGRAM_PACKAGE_TGZ="${package_tgzs[0]}"',

View File

@@ -11,25 +11,27 @@ import {
describe("resolve-openclaw-package-candidate", () => {
it("accepts only OpenClaw release package specs for npm candidates", () => {
expect(() => validateOpenClawPackageSpec("openclaw@beta")).not.toThrow();
expect(() => validateOpenClawPackageSpec("openclaw@alpha")).not.toThrow();
expect(() => validateOpenClawPackageSpec("openclaw@latest")).not.toThrow();
expect(() => validateOpenClawPackageSpec("openclaw@2026.4.27")).not.toThrow();
expect(() => validateOpenClawPackageSpec("openclaw@2026.4.27-1")).not.toThrow();
expect(() => validateOpenClawPackageSpec("openclaw@2026.4.27-beta.2")).not.toThrow();
expect(() => validateOpenClawPackageSpec("openclaw@2026.4.27-alpha.2")).not.toThrow();
expect(() => validateOpenClawPackageSpec("@evil/openclaw@1.0.0")).toThrow(
"package_spec must be openclaw@beta",
"package_spec must be openclaw@alpha",
);
expect(() => validateOpenClawPackageSpec("openclaw@canary")).toThrow(
"package_spec must be openclaw@beta",
"package_spec must be openclaw@alpha",
);
expect(() => validateOpenClawPackageSpec("openclaw@2026.04.27")).toThrow(
"package_spec must be openclaw@beta",
"package_spec must be openclaw@alpha",
);
expect(() => validateOpenClawPackageSpec("openclaw@npm:other-package")).toThrow(
"package_spec must be openclaw@beta",
"package_spec must be openclaw@alpha",
);
expect(() => validateOpenClawPackageSpec("openclaw@file:../other-package.tgz")).toThrow(
"package_spec must be openclaw@beta",
"package_spec must be openclaw@alpha",
);
});

View File

@@ -21,12 +21,16 @@ const FIXTURE_PATH = path.resolve(TEST_DIR, "../fixtures/telegram-qa-summary-rtt
describe("RTT harness", () => {
it("validates OpenClaw package specs", () => {
expect(validateOpenClawPackageSpec("openclaw@main")).toBe("openclaw@main");
expect(validateOpenClawPackageSpec("openclaw@alpha")).toBe("openclaw@alpha");
expect(validateOpenClawPackageSpec("openclaw@beta")).toBe("openclaw@beta");
expect(validateOpenClawPackageSpec("openclaw@latest")).toBe("openclaw@latest");
expect(validateOpenClawPackageSpec("openclaw@2026.4.30")).toBe("openclaw@2026.4.30");
expect(validateOpenClawPackageSpec("openclaw@2026.4.30-beta.2")).toBe(
"openclaw@2026.4.30-beta.2",
);
expect(validateOpenClawPackageSpec("openclaw@2026.4.30-alpha.2")).toBe(
"openclaw@2026.4.30-alpha.2",
);
expect(() => validateOpenClawPackageSpec("@openclaw/openclaw@beta")).toThrow(
/Package spec must be/,