diff --git a/.github/workflows/openclaw-npm-release.yml b/.github/workflows/openclaw-npm-release.yml new file mode 100644 index 00000000000..09126ed6ad2 --- /dev/null +++ b/.github/workflows/openclaw-npm-release.yml @@ -0,0 +1,79 @@ +name: OpenClaw NPM Release + +on: + push: + tags: + - "v*" + +concurrency: + group: openclaw-npm-release-${{ github.ref }} + cancel-in-progress: false + +env: + NODE_VERSION: "22.x" + PNPM_VERSION: "10.23.0" + +jobs: + publish_openclaw_npm: + # npm trusted publishing + provenance requires a GitHub-hosted runner. + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node environment + uses: ./.github/actions/setup-node-env + with: + node-version: ${{ env.NODE_VERSION }} + pnpm-version: ${{ env.PNPM_VERSION }} + install-bun: "false" + use-sticky-disk: "false" + + - name: Validate release tag and package metadata + env: + RELEASE_SHA: ${{ github.sha }} + RELEASE_TAG: ${{ github.ref_name }} + RELEASE_MAIN_REF: origin/main + run: | + set -euo pipefail + # Fetch the full main ref so merge-base ancestry checks keep working + # for older tagged commits that are still contained in main. + git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main + pnpm release:openclaw:npm:check + + - name: Ensure version is not already published + run: | + set -euo pipefail + PACKAGE_VERSION=$(node -p "require('./package.json').version") + + if npm view "openclaw@${PACKAGE_VERSION}" version >/dev/null 2>&1; then + echo "openclaw@${PACKAGE_VERSION} is already published on npm." + exit 1 + fi + + echo "Publishing openclaw@${PACKAGE_VERSION}" + + - name: Check + run: pnpm check + + - name: Build + run: pnpm build + + - name: Verify release contents + run: pnpm release:check + + - name: Publish + run: | + set -euo pipefail + PACKAGE_VERSION=$(node -p "require('./package.json').version") + + if [[ "$PACKAGE_VERSION" == *-beta.* ]]; then + npm publish --access public --tag beta --provenance + else + npm publish --access public --provenance + fi diff --git a/.gitignore b/.gitignore index 0627a573c79..4defa8acb33 100644 --- a/.gitignore +++ b/.gitignore @@ -81,6 +81,7 @@ apps/ios/*.mobileprovision # Local untracked files .local/ docs/.local/ +tmp/ IDENTITY.md USER.md .tgz diff --git a/docs/reference/RELEASING.md b/docs/reference/RELEASING.md index 6b5dc29c9b9..b13803e69f3 100644 --- a/docs/reference/RELEASING.md +++ b/docs/reference/RELEASING.md @@ -19,6 +19,32 @@ When the operator says “release”, immediately do this preflight (no extra qu - Load env from `~/.profile` and confirm `SPARKLE_PRIVATE_KEY_FILE` + App Store Connect vars are set (SPARKLE_PRIVATE_KEY_FILE should live in `~/.profile`). - Use Sparkle keys from `~/Library/CloudStorage/Dropbox/Backup/Sparkle` if needed. +## Versioning + +Current OpenClaw releases use date-based versioning. + +- Stable release version: `YYYY.M.D` + - Git tag: `vYYYY.M.D` + - Examples from repo history: `v2026.2.26`, `v2026.3.8` +- 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` +- 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` + - GitHub release title: `openclaw 2026.3.8` +- Do not zero-pad month or day. Use `2026.3.8`, not `2026.03.08`. +- Stable and beta are npm dist-tags, not separate release lines: + - `latest` = stable + - `beta` = prerelease/testing +- Dev is the moving head of `main`, not a normal git-tagged release. +- The release workflow 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. + +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. + 1. **Version & metadata** - [ ] Bump `package.json` version (e.g., `2026.1.29`). @@ -67,8 +93,11 @@ When the operator says “release”, immediately do this preflight (no extra qu 6. **Publish (npm)** - [ ] Confirm git status is clean; commit and push as needed. -- [ ] `npm login` (verify 2FA) if needed. -- [ ] `npm publish --access public` (use `--tag beta` for pre-releases). +- [ ] Confirm npm trusted publishing is configured for the `openclaw` package. +- [ ] Push the matching git tag to trigger `.github/workflows/openclaw-npm-release.yml`. + - Stable tags publish to npm `latest`. + - Beta tags publish to npm `beta`. + - The workflow rejects 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. - [ ] 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) @@ -84,6 +113,7 @@ When the operator says “release”, immediately do this preflight (no extra qu 7. **GitHub release + appcast** - [ ] Tag and push: `git tag vX.Y.Z && git push origin vX.Y.Z` (or `git push --tags`). + - Pushing the tag also triggers the npm release workflow. - [ ] Create/refresh the GitHub release for `vX.Y.Z` with **title `openclaw X.Y.Z`** (not just the tag); body should include the **full** changelog section for that version (Highlights + Changes + Fixes), inline (no bare links), and **must not repeat the title inside the body**. - [ ] Attach artifacts: `npm pack` tarball (optional), `OpenClaw-X.Y.Z.zip`, and `OpenClaw-X.Y.Z.dSYM.zip` (if generated). - [ ] Commit the updated `appcast.xml` and push it (Sparkle feeds from main). diff --git a/package.json b/package.json index bc625b74e71..43fd734092a 100644 --- a/package.json +++ b/package.json @@ -295,6 +295,7 @@ "protocol:gen": "node --import tsx scripts/protocol-gen.ts", "protocol:gen:swift": "node --import tsx scripts/protocol-gen-swift.ts", "release:check": "node --import tsx scripts/release-check.ts", + "release:openclaw:npm:check": "node --import tsx scripts/openclaw-npm-release-check.ts", "start": "node scripts/run-node.mjs", "test": "node scripts/test-parallel.mjs", "test:all": "pnpm lint && pnpm build && pnpm test && pnpm test:e2e && pnpm test:live && pnpm test:docker:all", diff --git a/scripts/openclaw-npm-release-check.ts b/scripts/openclaw-npm-release-check.ts new file mode 100644 index 00000000000..267558a0d0d --- /dev/null +++ b/scripts/openclaw-npm-release-check.ts @@ -0,0 +1,251 @@ +#!/usr/bin/env -S node --import tsx + +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +type PackageJson = { + name?: string; + version?: string; + description?: string; + license?: string; + repository?: { url?: string } | string; + bin?: Record; +}; + +export type ParsedReleaseVersion = { + version: string; + channel: "stable" | "beta"; + year: number; + month: number; + day: number; + betaNumber?: 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 EXPECTED_REPOSITORY_URL = "https://github.com/openclaw/openclaw"; +const MAX_CALVER_DISTANCE_DAYS = 2; + +function normalizeRepoUrl(value: unknown): string { + if (typeof value !== "string") { + return ""; + } + + return value + .trim() + .replace(/^git\+/, "") + .replace(/\.git$/i, "") + .replace(/\/+$/, ""); +} + +function parseDateParts( + version: string, + groups: Record, + channel: "stable" | "beta", +): ParsedReleaseVersion | null { + const year = Number.parseInt(groups.year ?? "", 10); + const month = Number.parseInt(groups.month ?? "", 10); + const day = Number.parseInt(groups.day ?? "", 10); + const betaNumber = channel === "beta" ? Number.parseInt(groups.beta ?? "", 10) : undefined; + + if ( + !Number.isInteger(year) || + !Number.isInteger(month) || + !Number.isInteger(day) || + month < 1 || + month > 12 || + day < 1 || + day > 31 + ) { + return null; + } + if (channel === "beta" && (!Number.isInteger(betaNumber) || (betaNumber ?? 0) < 1)) { + return null; + } + + const date = new Date(Date.UTC(year, month - 1, day)); + if ( + date.getUTCFullYear() !== year || + date.getUTCMonth() !== month - 1 || + date.getUTCDate() !== day + ) { + return null; + } + + return { + version, + channel, + year, + month, + day, + betaNumber, + date, + }; +} + +export function parseReleaseVersion(version: string): ParsedReleaseVersion | null { + const trimmed = version.trim(); + if (!trimmed) { + return null; + } + + const stableMatch = STABLE_VERSION_REGEX.exec(trimmed); + if (stableMatch?.groups) { + return parseDateParts(trimmed, stableMatch.groups, "stable"); + } + + const betaMatch = BETA_VERSION_REGEX.exec(trimmed); + if (betaMatch?.groups) { + return parseDateParts(trimmed, betaMatch.groups, "beta"); + } + + return null; +} + +function startOfUtcDay(date: Date): number { + return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()); +} + +export function utcCalendarDayDistance(left: Date, right: Date): number { + return Math.round(Math.abs(startOfUtcDay(left) - startOfUtcDay(right)) / 86_400_000); +} + +export function collectReleasePackageMetadataErrors(pkg: PackageJson): string[] { + const actualRepositoryUrl = normalizeRepoUrl( + typeof pkg.repository === "string" ? pkg.repository : pkg.repository?.url, + ); + const errors: string[] = []; + + if (pkg.name !== "openclaw") { + errors.push(`package.json name must be "openclaw"; found "${pkg.name ?? ""}".`); + } + if (!pkg.description?.trim()) { + errors.push("package.json description must be non-empty."); + } + if (pkg.license !== "MIT") { + errors.push(`package.json license must be "MIT"; found "${pkg.license ?? ""}".`); + } + if (actualRepositoryUrl !== EXPECTED_REPOSITORY_URL) { + errors.push( + `package.json repository.url must resolve to ${EXPECTED_REPOSITORY_URL}; found ${ + actualRepositoryUrl || "" + }.`, + ); + } + if (pkg.bin?.openclaw !== "openclaw.mjs") { + errors.push( + `package.json bin.openclaw must be "openclaw.mjs"; found "${pkg.bin?.openclaw ?? ""}".`, + ); + } + + return errors; +} + +export function collectReleaseTagErrors(params: { + packageVersion: string; + releaseTag: string; + releaseSha?: string; + releaseMainRef?: string; + now?: Date; +}): string[] { + const errors: string[] = []; + const releaseTag = params.releaseTag.trim(); + const packageVersion = params.packageVersion.trim(); + const now = params.now ?? new Date(); + + const parsedVersion = parseReleaseVersion(packageVersion); + if (parsedVersion === null) { + errors.push( + `package.json version must match YYYY.M.D or YYYY.M.D-beta.N; found "${packageVersion || ""}".`, + ); + } + + if (!releaseTag.startsWith("v")) { + errors.push(`Release tag must start with "v"; found "${releaseTag || ""}".`); + } + + const tagVersion = releaseTag.startsWith("v") ? releaseTag.slice(1) : releaseTag; + const parsedTag = parseReleaseVersion(tagVersion); + if (parsedTag === null) { + errors.push( + `Release tag must match vYYYY.M.D or vYYYY.M.D-beta.N; found "${releaseTag || ""}".`, + ); + } + + const expectedTag = packageVersion ? `v${packageVersion}` : ""; + if (releaseTag !== expectedTag) { + errors.push( + `Release tag ${releaseTag || ""} does not match package.json version ${ + packageVersion || "" + }; expected ${expectedTag || ""}.`, + ); + } + + if (parsedVersion !== null) { + const dayDistance = utcCalendarDayDistance(parsedVersion.date, now); + if (dayDistance > MAX_CALVER_DISTANCE_DAYS) { + const nowLabel = now.toISOString().slice(0, 10); + const versionDate = parsedVersion.date.toISOString().slice(0, 10); + errors.push( + `Release version ${packageVersion} is ${dayDistance} days away from current UTC date ${nowLabel}; release CalVer date ${versionDate} must be within ${MAX_CALVER_DISTANCE_DAYS} days.`, + ); + } + } + + if (params.releaseSha?.trim() && params.releaseMainRef?.trim()) { + try { + execFileSync( + "git", + ["merge-base", "--is-ancestor", params.releaseSha, params.releaseMainRef], + { stdio: "ignore" }, + ); + } catch { + errors.push( + `Tagged commit ${params.releaseSha} is not contained in ${params.releaseMainRef}.`, + ); + } + } + + return errors; +} + +function loadPackageJson(): PackageJson { + return JSON.parse(readFileSync("package.json", "utf8")) as PackageJson; +} + +function main(): number { + const pkg = loadPackageJson(); + const metadataErrors = collectReleasePackageMetadataErrors(pkg); + const tagErrors = collectReleaseTagErrors({ + packageVersion: pkg.version ?? "", + releaseTag: process.env.RELEASE_TAG ?? "", + releaseSha: process.env.RELEASE_SHA, + releaseMainRef: process.env.RELEASE_MAIN_REF, + }); + const errors = [...metadataErrors, ...tagErrors]; + + if (errors.length > 0) { + for (const error of errors) { + console.error(`openclaw-npm-release-check: ${error}`); + } + return 1; + } + + const parsedVersion = parseReleaseVersion(pkg.version ?? ""); + const channel = parsedVersion?.channel ?? "unknown"; + const dayDistance = + parsedVersion === null + ? "unknown" + : String(utcCalendarDayDistance(parsedVersion.date, new Date())); + console.log( + `openclaw-npm-release-check: validated ${channel} release ${pkg.version} (${dayDistance} day UTC delta).`, + ); + return 0; +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + process.exit(main()); +} diff --git a/test/openclaw-npm-release-check.test.ts b/test/openclaw-npm-release-check.test.ts new file mode 100644 index 00000000000..7bd1c98d92d --- /dev/null +++ b/test/openclaw-npm-release-check.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; +import { + collectReleasePackageMetadataErrors, + collectReleaseTagErrors, + parseReleaseVersion, + utcCalendarDayDistance, +} from "../scripts/openclaw-npm-release-check.ts"; + +describe("parseReleaseVersion", () => { + it("parses stable CalVer releases", () => { + expect(parseReleaseVersion("2026.3.9")).toMatchObject({ + version: "2026.3.9", + channel: "stable", + year: 2026, + month: 3, + day: 9, + }); + }); + + it("parses beta CalVer releases", () => { + expect(parseReleaseVersion("2026.3.9-beta.2")).toMatchObject({ + version: "2026.3.9-beta.2", + channel: "beta", + year: 2026, + month: 3, + day: 9, + betaNumber: 2, + }); + }); + + it("rejects legacy and malformed release formats", () => { + expect(parseReleaseVersion("2026.3.9-1")).toBeNull(); + expect(parseReleaseVersion("2026.03.09")).toBeNull(); + expect(parseReleaseVersion("v2026.3.9")).toBeNull(); + expect(parseReleaseVersion("2026.2.30")).toBeNull(); + expect(parseReleaseVersion("2.0.0-beta2")).toBeNull(); + }); +}); + +describe("utcCalendarDayDistance", () => { + it("compares UTC calendar days rather than wall-clock hours", () => { + const left = new Date("2026-03-09T23:59:59Z"); + const right = new Date("2026-03-11T00:00:01Z"); + expect(utcCalendarDayDistance(left, right)).toBe(2); + }); +}); + +describe("collectReleaseTagErrors", () => { + it("accepts versions within the two-day CalVer window", () => { + expect( + collectReleaseTagErrors({ + packageVersion: "2026.3.9", + releaseTag: "v2026.3.9", + now: new Date("2026-03-11T12:00:00Z"), + }), + ).toEqual([]); + }); + + it("rejects versions outside the two-day CalVer window", () => { + expect( + collectReleaseTagErrors({ + packageVersion: "2026.3.9", + releaseTag: "v2026.3.9", + now: new Date("2026-03-12T00:00:00Z"), + }), + ).toContainEqual(expect.stringContaining("must be within 2 days")); + }); + + it("rejects tags that do not match the current release format", () => { + expect( + collectReleaseTagErrors({ + packageVersion: "2026.3.9", + releaseTag: "v2026.3.9-1", + now: new Date("2026-03-09T00:00:00Z"), + }), + ).toContainEqual(expect.stringContaining("must match vYYYY.M.D or vYYYY.M.D-beta.N")); + }); +}); + +describe("collectReleasePackageMetadataErrors", () => { + it("validates the expected npm package metadata", () => { + expect( + collectReleasePackageMetadataErrors({ + name: "openclaw", + description: "Multi-channel AI gateway with extensible messaging integrations", + license: "MIT", + repository: { url: "git+https://github.com/openclaw/openclaw.git" }, + bin: { openclaw: "openclaw.mjs" }, + }), + ).toEqual([]); + }); +});