openclaw/scripts/lib/android-store-version.ts
Peter Steinberger 9491d5a5a3
refactor(scripts): deslop scripts/lib (#159362)
* refactor(scripts): deslop scripts/lib

Consolidate script lifecycle, scenario, planner, report, release and
packaging helpers while retaining CLI and publication contracts.

Keep config-boundary source caches within one scan so repeated in-process
checks observe edits. Carry extracted helpers through selective fixtures
and iOS scope routing. Leave PR tooling and its library import closure intact.

* fix(scripts): retain sanitizer fast path after helper cleanup

* fix(scripts): model watchdog shell consumer in dead-code checks
2026-09-27 17:13:58 -07:00

353 lines
14 KiB
TypeScript

import { z } from "zod";
import { encodeMobileStoreVersion, MAX_MOBILE_STORE_REVISION } from "./mobile-store-version.ts";
import { compareReleaseVersions, parseReleaseVersion } from "./release-version.mjs";
const ANDROID_VERSION_CODE_MAX = 2_100_000_000;
export const ANDROID_RELEASE_REF_PREFIX = "refs/openclaw/mobile-releases/android";
const Code = z.number().int().positive().max(ANDROID_VERSION_CODE_MAX);
const LegacyMax = z.number().int().nonnegative().max(ANDROID_VERSION_CODE_MAX);
const Sha = z.string().regex(/^[a-f0-9]{40}$/u);
const Baseline = z.object({
audience: z.enum(["phone", "wear"]),
version: z.string().nullable(),
build: z
.string()
.regex(/^[1-9]\d*$/u)
.nullable(),
sourceRef: z.string().optional(),
});
const Identity = z.object({
gatewayVersion: z.string(),
revision: z.number().int().min(0).max(MAX_MOBILE_STORE_REVISION),
buildNumber: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
version: z.string(),
versionCode: Code,
wearVersionCode: Code,
});
const Plan = Identity.extend({
schemaVersion: z.literal(2),
legacyMaxVersionCode: LegacyMax,
sourceSha: Sha,
releaseNotesBaselines: z.array(Baseline),
});
const Snapshot = z.object({
uploadedVersionCodes: z.array(Code),
tracks: z.array(
z.object({
track: z.string(),
releases: z.array(z.object({ status: z.string(), versionCodes: z.array(z.string()) })),
}),
),
});
export type AndroidStorePlan = z.infer<typeof Plan>;
type AndroidPlaySnapshot = z.infer<typeof Snapshot>;
type StoreIdentity = z.infer<typeof Identity>;
export type AndroidReleaseRef = { ref: string; sha: string };
type ReleaseRecord = StoreIdentity & { sourceRef: string; legacy: boolean };
function validateIdentity(value: unknown): StoreIdentity {
const identity = Identity.parse(value);
if (identity.version !== encodeMobileStoreVersion(identity.gatewayVersion, identity.revision)) {
throw new Error("Android store version does not match its Gateway version and revision.");
}
if (identity.wearVersionCode !== identity.versionCode + 1) {
throw new Error("Android store Wear versionCode must equal the phone code plus 1.");
}
return identity;
}
export function androidStoreReleaseRef(plan: StoreIdentity): string {
const identity = validateIdentity(plan);
return `${ANDROID_RELEASE_REF_PREFIX}/v2/${identity.gatewayVersion}/${identity.revision}/${identity.buildNumber}/${identity.versionCode}-${identity.wearVersionCode}`;
}
export function androidStoreCutoverRef(legacyMaxVersionCode: number): string {
return `${ANDROID_RELEASE_REF_PREFIX}/cutover-v2/${LegacyMax.parse(legacyMaxVersionCode)}`;
}
function parseAndroidStoreReleaseRef(ref: string): StoreIdentity {
const match =
/^refs\/openclaw\/mobile-releases\/android\/v2\/([^/]+)\/(0|[1-9]\d*)\/([1-9]\d*)\/([1-9]\d*)-([1-9]\d*)$/u.exec(
ref,
);
if (!match) {
throw new Error(`Invalid Android store release ref: ${ref}.`);
}
const gatewayVersion = match[1]!;
const revision = Number(match[2]);
const identity = validateIdentity({
gatewayVersion,
revision,
buildNumber: Number(match[3]),
version: encodeMobileStoreVersion(gatewayVersion, revision),
versionCode: Number(match[4]),
wearVersionCode: Number(match[5]),
});
if (androidStoreReleaseRef(identity) !== ref) {
throw new Error(`Noncanonical Android store release ref: ${ref}.`);
}
return identity;
}
export function validateAndroidStoreBaseline(baseline: z.infer<typeof Baseline>): void {
if (baseline.version === null || baseline.build === null) {
if (baseline.version !== null || baseline.build !== null || baseline.sourceRef !== undefined) {
throw new Error("Android first-release baseline must have no version, build, or source ref.");
}
return;
}
const code = Code.parse(Number(baseline.build));
if (baseline.sourceRef?.startsWith(`${ANDROID_RELEASE_REF_PREFIX}/v2/`)) {
const identity = parseAndroidStoreReleaseRef(baseline.sourceRef);
const expectedCode =
baseline.audience === "phone" ? identity.versionCode : identity.wearVersionCode;
if (baseline.version !== identity.version || code !== expectedCode) {
throw new Error("Android public baseline does not match its recorded store identity.");
}
return;
}
const legacy = legacyRelease(code, baseline.audience);
if (
legacy.version !== baseline.version ||
(baseline.sourceRef !== undefined && baseline.sourceRef !== legacy.sourceRef)
) {
throw new Error("Android public baseline does not match its legacy source ref.");
}
}
export function validateAndroidStorePlan(value: unknown): AndroidStorePlan {
const plan = Plan.parse(value);
validateIdentity(plan);
if (plan.versionCode <= plan.legacyMaxVersionCode) {
throw new Error("Android store codes must be above the legacy cutover maximum.");
}
if (
plan.releaseNotesBaselines.length !== 2 ||
plan.releaseNotesBaselines[0]?.audience !== "phone" ||
plan.releaseNotesBaselines[1]?.audience !== "wear"
) {
throw new Error("Android release plan must contain phone and Wear production baselines.");
}
for (const baseline of plan.releaseNotesBaselines) {
validateAndroidStoreBaseline(baseline);
}
return plan;
}
function legacyRelease(code: number, audience?: "phone" | "wear"): ReleaseRecord {
const match = /^(20\d{2})(0[1-9]|1[0-2])(0[1-9]|[1-9]\d)(\d{2})$/u.exec(String(code));
const suffix = Number(match?.[4]);
const actualAudience = suffix >= 51 && suffix <= 99 ? "wear" : "phone";
if (
!match ||
!((suffix >= 1 && suffix <= 49) || (suffix >= 51 && suffix <= 99)) ||
(audience && audience !== actualAudience)
) {
throw new Error(`Cannot identify legacy Android ${audience ?? "store"} versionCode ${code}.`);
}
const version = `${match[1]}.${Number(match[2])}.${Number(match[3])}`;
const versionCode = actualAudience === "wear" ? code - 50 : code;
return {
gatewayVersion: version,
revision: 0,
buildNumber: suffix % 50,
version,
versionCode,
wearVersionCode: versionCode + 50,
sourceRef: `${ANDROID_RELEASE_REF_PREFIX}/${version}-${versionCode}`,
legacy: true,
};
}
function storeHistory(snapshot: AndroidPlaySnapshot, refs: AndroidReleaseRef[]) {
const markers = refs.filter(({ ref }) =>
ref.startsWith(`${ANDROID_RELEASE_REF_PREFIX}/cutover-v2/`),
);
if (markers.length > 1) {
throw new Error(
"Multiple Android store version cutover markers exist; reconcile them before releasing.",
);
}
const marker = markers[0];
const legacyMax = marker ? LegacyMax.parse(Number(marker.ref.split("/").at(-1))) : null;
if (marker && marker.ref !== androidStoreCutoverRef(legacyMax!)) {
throw new Error(`Invalid Android cutover marker ${marker.ref}.`);
}
const records = new Map<number, ReleaseRecord>();
const candidates = new Map<string, string>();
for (const { ref, sha } of refs) {
Sha.parse(sha);
if (ref.startsWith(`${ANDROID_RELEASE_REF_PREFIX}/cutover-v2/`)) {
continue;
}
let record: ReleaseRecord;
if (ref.startsWith(`${ANDROID_RELEASE_REF_PREFIX}/v2/`)) {
const identity = parseAndroidStoreReleaseRef(ref);
if (legacyMax === null || identity.versionCode <= legacyMax) {
throw new Error(`Android store ref ${ref} has no matching legacy cutover boundary.`);
}
record = { ...identity, sourceRef: ref, legacy: false };
const key = `${record.gatewayVersion}/${record.revision}/${record.buildNumber}`;
if (candidates.has(key) && candidates.get(key) !== ref) {
throw new Error(`Multiple Android store records identify candidate ${key}.`);
}
candidates.set(key, ref);
} else {
const match = /^refs\/openclaw\/mobile-releases\/android\/([^/]+)-([1-9]\d*)$/u.exec(ref);
if (!match) {
throw new Error(`Unknown Android release ref ${ref}.`);
}
record = legacyRelease(Code.parse(Number(match[2])), "phone");
if (record.sourceRef !== ref) {
throw new Error(`Invalid legacy Android release ref ${ref}.`);
}
}
for (const code of [record.versionCode, record.wearVersionCode]) {
if (records.has(code)) {
throw new Error(`Multiple Android source records claim versionCode ${code}.`);
}
records.set(code, record);
}
}
const liveCodes = new Set(snapshot.uploadedVersionCodes);
for (const track of snapshot.tracks) {
for (const release of track.releases) {
for (const raw of release.versionCodes) {
if (!/^[1-9]\d*$/u.test(raw)) {
throw new Error(`Invalid Google Play versionCode ${raw}.`);
}
liveCodes.add(Code.parse(Number(raw)));
}
}
}
for (const code of liveCodes) {
if (records.has(code)) {
continue;
}
if (legacyMax !== null && code > legacyMax) {
throw new Error(
`Google Play versionCode ${code} has no v2 source record. Inspect the retained Android plan and uploaded AAB hashes, then perform authorized record-only recovery; do not re-upload.`,
);
}
}
return { records, liveCodes, legacyMaxVersionCode: legacyMax ?? Math.max(0, ...liveCodes) };
}
function publicBaselines(
snapshot: AndroidPlaySnapshot,
records: Map<number, ReleaseRecord>,
): AndroidStorePlan["releaseNotesBaselines"] {
return (["phone", "wear"] as const).map((audience) => {
const trackName = audience === "phone" ? "production" : "wear:production";
const tracks = snapshot.tracks.filter(({ track }) => track === trackName);
if (tracks.length > 1) {
throw new Error(`Google Play returned multiple ${trackName} tracks.`);
}
const releases = tracks[0]?.releases ?? [];
if (releases.some(({ status }) => !["draft", "completed"].includes(status))) {
throw new Error(
`Google Play ${trackName} has ambiguous public release state. Complete or resolve the rollout before releasing.`,
);
}
const published = releases.filter(({ status }) => status === "completed");
if (published.length === 0) {
return { audience, version: null, build: null };
}
const codes = [...new Set(published.flatMap(({ versionCodes }) => versionCodes))];
if (published.length !== 1 || codes.length !== 1) {
throw new Error(
`Google Play ${trackName} has multiple public builds; release-note baseline is ambiguous.`,
);
}
const build = codes[0]!;
const record = records.get(Number(build));
if (!record) {
throw new Error(
`Google Play ${trackName} versionCode ${build} has no recorded source identity. Verify that build's version and source before releasing.`,
);
}
const expected = audience === "phone" ? record.versionCode : record.wearVersionCode;
if (Number(build) !== expected) {
throw new Error(
`Google Play ${trackName} versionCode ${build} belongs to a different form factor.`,
);
}
return { audience, version: record.version, build, sourceRef: record.sourceRef };
});
}
export function resolveAndroidPublicBaselines(snapshotValue: unknown, refs: AndroidReleaseRef[]) {
const snapshot = Snapshot.parse(snapshotValue);
return publicBaselines(snapshot, storeHistory(snapshot, refs).records);
}
export function resolveAndroidStorePlan(input: {
gatewayVersion: string;
pinnedVersion: string;
pinnedVersionCode: number;
sourceSha: string;
snapshot: unknown;
refs: AndroidReleaseRef[];
}): AndroidStorePlan {
const parsed = parseReleaseVersion(input.gatewayVersion.trim().replace(/^v/u, ""));
if (!parsed) {
throw new Error(`Invalid Gateway release version ${input.gatewayVersion}.`);
}
const gatewayVersion = parsed.baseVersion;
encodeMobileStoreVersion(gatewayVersion, 0);
const snapshot = Snapshot.parse(input.snapshot);
const history = storeHistory(snapshot, input.refs);
const releaseNotesBaselines = publicBaselines(snapshot, history.records);
const records = [...new Set(history.records.values())];
for (const version of [input.pinnedVersion, ...records.map((record) => record.gatewayVersion)]) {
const comparison = compareReleaseVersions(gatewayVersion, version);
if (comparison === null || comparison < 0) {
throw new Error(
`Gateway version ${gatewayVersion} precedes or cannot compare with Android release ${version}.`,
);
}
}
const sameGateway = records.filter((record) => record.gatewayVersion === gatewayVersion);
const highestRevision = Math.max(0, ...sameGateway.map((record) => record.revision));
const highestIsPublic = releaseNotesBaselines.some(({ build }) => {
const record = build ? history.records.get(Number(build)) : undefined;
return record?.gatewayVersion === gatewayVersion && record.revision === highestRevision;
});
const revision = highestRevision + (highestIsPublic ? 1 : 0);
if (revision > MAX_MOBILE_STORE_REVISION) {
throw new Error(
`Android public revisions for ${gatewayVersion} are exhausted. Advance the Gateway patch before releasing.`,
);
}
const buildNumber =
Math.max(
0,
...sameGateway
.filter((record) => !record.legacy && record.revision === revision)
.map((record) => record.buildNumber),
) + 1;
const highWater = Math.max(
history.legacyMaxVersionCode,
Code.parse(input.pinnedVersionCode) + 50,
...history.liveCodes,
...history.records.keys(),
);
if (highWater > ANDROID_VERSION_CODE_MAX - 2) {
throw new Error(
"Android versionCode space is exhausted; there is no room for another phone/Wear pair.",
);
}
return validateAndroidStorePlan({
schemaVersion: 2,
gatewayVersion,
revision,
buildNumber,
version: encodeMobileStoreVersion(gatewayVersion, revision),
versionCode: highWater + 1,
wearVersionCode: highWater + 2,
legacyMaxVersionCode: history.legacyMaxVersionCode,
sourceSha: input.sourceSha,
releaseNotesBaselines,
});
}