From d33f3f843ad3b8aa00f44ff04cd9b0e1e07db7e6 Mon Sep 17 00:00:00 2001 From: Onur Date: Sat, 14 Mar 2026 19:38:14 +0100 Subject: [PATCH] ci: allow fallback npm correction tags (#46486) --- .github/workflows/openclaw-npm-release.yml | 30 +++++++++- docs/reference/RELEASING.md | 17 ++++-- scripts/openclaw-npm-release-check.ts | 68 ++++++++++++++++++++-- test/openclaw-npm-release-check.test.ts | 31 +++++++++- 4 files changed, 131 insertions(+), 15 deletions(-) diff --git a/.github/workflows/openclaw-npm-release.yml b/.github/workflows/openclaw-npm-release.yml index d11f2a4f9ae..c7f53567612 100644 --- a/.github/workflows/openclaw-npm-release.yml +++ b/.github/workflows/openclaw-npm-release.yml @@ -7,7 +7,7 @@ on: workflow_dispatch: inputs: tag: - description: Release tag to publish (for example v2026.3.14 or v2026.3.14-beta.1) + description: Release tag to publish (for example v2026.3.14, v2026.3.14-beta.1, or fallback v2026.3.14-1) required: true type: string @@ -47,9 +47,18 @@ jobs: set -euo pipefail RELEASE_SHA=$(git rev-parse HEAD) PACKAGE_VERSION=$(node -p "require('./package.json').version") + if [[ "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*-[1-9][0-9]*$ ]]; then + TAG_KIND="fallback correction" + else + TAG_KIND="standard" + fi echo "Release plan for ${RELEASE_TAG}:" echo "Resolved release SHA: ${RELEASE_SHA}" echo "Resolved package version: ${PACKAGE_VERSION}" + echo "Resolved tag kind: ${TAG_KIND}" + if [[ "${TAG_KIND}" == "fallback correction" ]]; then + echo "Correction tag note: npm version remains ${PACKAGE_VERSION}" + fi echo "Would run: git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main" echo "Would run with env: RELEASE_SHA=${RELEASE_SHA} RELEASE_TAG=${RELEASE_TAG} RELEASE_MAIN_REF=origin/main pnpm release:openclaw:npm:check" echo "Would run: npm view openclaw@${PACKAGE_VERSION} version" @@ -71,16 +80,31 @@ jobs: pnpm release:openclaw:npm:check - name: Ensure version is not already published + env: + RELEASE_TAG: ${{ github.ref_name }} run: | set -euxo pipefail PACKAGE_VERSION=$(node -p "require('./package.json').version") + IS_CORRECTION_TAG=0 + if [[ "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*-[1-9][0-9]*$ ]]; then + IS_CORRECTION_TAG=1 + fi if npm view "openclaw@${PACKAGE_VERSION}" version >/dev/null 2>&1; then + if [[ "${IS_CORRECTION_TAG}" == "1" ]]; then + echo "openclaw@${PACKAGE_VERSION} is already published on npm." + echo "Correction tag ${RELEASE_TAG} is allowed as a fallback release tag, so preview will continue without treating this as an error." + exit 0 + fi echo "openclaw@${PACKAGE_VERSION} is already published on npm." exit 1 fi - echo "Previewing openclaw@${PACKAGE_VERSION}" + if [[ "${IS_CORRECTION_TAG}" == "1" ]]; then + echo "Previewing fallback correction tag ${RELEASE_TAG} for npm version openclaw@${PACKAGE_VERSION}" + else + echo "Previewing openclaw@${PACKAGE_VERSION}" + fi - name: Check run: | @@ -114,7 +138,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]*((-beta\.[1-9][0-9]*)|(-[1-9][0-9]*))?$ ]]; then echo "Invalid release tag format: ${RELEASE_TAG}" exit 1 fi diff --git a/docs/reference/RELEASING.md b/docs/reference/RELEASING.md index ed11040d325..9100968550a 100644 --- a/docs/reference/RELEASING.md +++ b/docs/reference/RELEASING.md @@ -29,6 +29,10 @@ Current OpenClaw releases use date-based versioning. - Beta prerelease version: `YYYY.M.D-beta.N` - Git tag: `vYYYY.M.D-beta.N` - Examples from repo history: `v2026.2.15-beta.1`, `v2026.3.8-beta.1` +- Fallback correction tag: `vYYYY.M.D-N` + - Use only as a last-resort recovery tag when a published immutable release burned the original stable tag and you cannot reuse it. + - The npm package version stays `YYYY.M.D`; the `-N` suffix is only for the git tag and GitHub release. + - Prefer betas for normal pre-release iteration, then cut a clean stable tag once ready. - Use the same version string everywhere, minus the leading `v` where Git tags are not used: - `package.json`: `2026.3.8` - Git tag: `v2026.3.8` @@ -38,12 +42,12 @@ Current OpenClaw releases use date-based versioning. - `latest` = stable - `beta` = prerelease/testing - Dev is the moving head of `main`, not a normal git-tagged release. -- The tag-triggered preview run enforces the current stable/beta tag formats and rejects versions whose CalVer date is more than 2 UTC calendar days away from the release date. +- The tag-triggered preview run accepts stable, beta, and fallback correction tags, and rejects versions whose CalVer date is more than 2 UTC calendar days away from the release date. Historical note: - Older tags such as `v2026.1.11-1`, `v2026.2.6-3`, and `v2.0.0-beta2` exist in repo history. -- Treat those as legacy tag patterns. New releases should use `vYYYY.M.D` for stable and `vYYYY.M.D-beta.N` for beta. +- Treat correction tags as a fallback-only escape hatch. New releases should still use `vYYYY.M.D` for stable and `vYYYY.M.D-beta.N` for beta. 1. **Version & metadata** @@ -99,7 +103,9 @@ Historical note: - [ ] Run `OpenClaw NPM Release` manually with the same tag to publish after `npm-release` environment approval. - Stable tags publish to npm `latest`. - Beta tags publish to npm `beta`. - - Both the preview run and the manual publish run reject tags that do not match `package.json`, are not on `main`, or whose CalVer date is more than 2 UTC calendar days away from the release date. + - Fallback correction tags like `v2026.3.13-1` map to npm version `2026.3.13`. + - Both the preview run and the manual publish run reject tags that do not map back to `package.json`, are not on `main`, or whose CalVer date is more than 2 UTC calendar days away from the release date. + - If `openclaw@YYYY.M.D` is already published, a fallback correction tag is still useful for GitHub release and Docker recovery, but npm publish will not republish that version. - [ ] Verify the registry: `npm view openclaw version`, `npm view openclaw dist-tags`, and `npx -y openclaw@X.Y.Z --version` (or `--help`). ### Troubleshooting (notes from 2.0.0-beta2 release) @@ -109,8 +115,9 @@ Historical note: - `NPM_CONFIG_AUTH_TYPE=legacy npm dist-tag add openclaw@X.Y.Z latest` - **`npx` verification fails with `ECOMPROMISED: Lock compromised`**: retry with a fresh cache: - `NPM_CONFIG_CACHE=/tmp/npm-cache-$(date +%s) npx -y openclaw@X.Y.Z --version` -- **Tag needs repointing after a late fix**: force-update and push the tag, then ensure the GitHub release assets still match: - - `git tag -f vX.Y.Z && git push -f origin vX.Y.Z` +- **Tag needs recovery after a late fix**: if the original stable tag is tied to an immutable GitHub release, mint a fallback correction tag like `vX.Y.Z-1` instead of trying to force-update `vX.Y.Z`. + - Keep the npm package version at `X.Y.Z`; the correction suffix is for the git tag and GitHub release only. + - Use this only as a last resort. For normal iteration, prefer beta tags and then cut a clean stable release. 7. **GitHub release + appcast** diff --git a/scripts/openclaw-npm-release-check.ts b/scripts/openclaw-npm-release-check.ts index 1b19fc0f8b6..768fee6caee 100644 --- a/scripts/openclaw-npm-release-check.ts +++ b/scripts/openclaw-npm-release-check.ts @@ -25,9 +25,18 @@ export type ParsedReleaseVersion = { date: Date; }; +export type ParsedReleaseTag = { + version: string; + packageVersion: string; + channel: "stable" | "beta"; + correctionNumber?: number; + date: Date; +}; + const STABLE_VERSION_REGEX = /^(?\d{4})\.(?[1-9]\d?)\.(?[1-9]\d?)$/; const BETA_VERSION_REGEX = /^(?\d{4})\.(?[1-9]\d?)\.(?[1-9]\d?)-beta\.(?[1-9]\d*)$/; +const CORRECTION_TAG_REGEX = /^(?\d{4}\.[1-9]\d?\.[1-9]\d?)-(?[1-9]\d*)$/; const EXPECTED_REPOSITORY_URL = "https://github.com/openclaw/openclaw"; const MAX_CALVER_DISTANCE_DAYS = 2; @@ -107,6 +116,49 @@ export function parseReleaseVersion(version: string): ParsedReleaseVersion | nul return null; } +export function parseReleaseTagVersion(version: string): ParsedReleaseTag | null { + const trimmed = version.trim(); + if (!trimmed) { + return null; + } + + const parsedVersion = parseReleaseVersion(trimmed); + if (parsedVersion !== null) { + return { + version: trimmed, + packageVersion: parsedVersion.version, + channel: parsedVersion.channel, + date: parsedVersion.date, + correctionNumber: undefined, + }; + } + + const correctionMatch = CORRECTION_TAG_REGEX.exec(trimmed); + if (!correctionMatch?.groups) { + return null; + } + + const baseVersion = correctionMatch.groups.base ?? ""; + const parsedBaseVersion = parseReleaseVersion(baseVersion); + const correctionNumber = Number.parseInt(correctionMatch.groups.correction ?? "", 10); + if ( + parsedBaseVersion === null || + parsedBaseVersion.channel !== "stable" || + !Number.isInteger(correctionNumber) || + correctionNumber < 1 + ) { + return null; + } + + return { + version: trimmed, + packageVersion: parsedBaseVersion.version, + channel: "stable", + correctionNumber, + date: parsedBaseVersion.date, + }; +} + function startOfUtcDay(date: Date): number { return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()); } @@ -180,19 +232,25 @@ export function collectReleaseTagErrors(params: { } const tagVersion = releaseTag.startsWith("v") ? releaseTag.slice(1) : releaseTag; - const parsedTag = parseReleaseVersion(tagVersion); + const parsedTag = parseReleaseTagVersion(tagVersion); if (parsedTag === null) { errors.push( - `Release tag must match vYYYY.M.D or vYYYY.M.D-beta.N; found "${releaseTag || ""}".`, + `Release tag must match vYYYY.M.D, vYYYY.M.D-beta.N, or fallback correction tag vYYYY.M.D-N; found "${releaseTag || ""}".`, ); } - const expectedTag = packageVersion ? `v${packageVersion}` : ""; - if (releaseTag !== expectedTag) { + const expectedTag = packageVersion ? `v${packageVersion}` : ""; + const expectedCorrectionTag = parsedVersion?.channel === "stable" ? `${expectedTag}-N` : null; + const matchesExpectedTag = + parsedTag !== null && + parsedVersion !== null && + parsedTag.packageVersion === parsedVersion.version && + parsedTag.channel === parsedVersion.channel; + if (!matchesExpectedTag) { errors.push( `Release tag ${releaseTag || ""} does not match package.json version ${ packageVersion || "" - }; expected ${expectedTag || ""}.`, + }; expected ${expectedCorrectionTag ? `${expectedTag} or ${expectedCorrectionTag}` : expectedTag}.`, ); } diff --git a/test/openclaw-npm-release-check.test.ts b/test/openclaw-npm-release-check.test.ts index 66cf7d9b5cf..6ce0d35cfdb 100644 --- a/test/openclaw-npm-release-check.test.ts +++ b/test/openclaw-npm-release-check.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { collectReleasePackageMetadataErrors, collectReleaseTagErrors, + parseReleaseTagVersion, parseReleaseVersion, utcCalendarDayDistance, } from "../scripts/openclaw-npm-release-check.ts"; @@ -37,6 +38,22 @@ describe("parseReleaseVersion", () => { }); }); +describe("parseReleaseTagVersion", () => { + it("accepts fallback correction tags for stable releases", () => { + expect(parseReleaseTagVersion("2026.3.10-2")).toMatchObject({ + version: "2026.3.10-2", + packageVersion: "2026.3.10", + channel: "stable", + correctionNumber: 2, + }); + }); + + it("rejects beta correction tags and malformed correction tags", () => { + expect(parseReleaseTagVersion("2026.3.10-beta.1-1")).toBeNull(); + expect(parseReleaseTagVersion("2026.3.10-0")).toBeNull(); + }); +}); + describe("utcCalendarDayDistance", () => { it("compares UTC calendar days rather than wall-clock hours", () => { const left = new Date("2026-03-09T23:59:59Z"); @@ -66,14 +83,24 @@ describe("collectReleaseTagErrors", () => { ).toContainEqual(expect.stringContaining("must be within 2 days")); }); - it("rejects tags that do not match the current release format", () => { + it("accepts fallback correction tags for stable package versions", () => { expect( collectReleaseTagErrors({ packageVersion: "2026.3.10", releaseTag: "v2026.3.10-1", now: new Date("2026-03-10T00:00:00Z"), }), - ).toContainEqual(expect.stringContaining("must match vYYYY.M.D or vYYYY.M.D-beta.N")); + ).toEqual([]); + }); + + it("rejects beta package versions paired with fallback correction tags", () => { + expect( + collectReleaseTagErrors({ + packageVersion: "2026.3.10-beta.1", + releaseTag: "v2026.3.10-1", + now: new Date("2026-03-10T00:00:00Z"), + }), + ).toContainEqual(expect.stringContaining("does not match package.json version")); }); });