#!/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()); }