android: derive release notes from changelog

This commit is contained in:
joshavant
2026-06-16 21:54:21 +02:00
parent 08e0b8cf6b
commit 529150868c
7 changed files with 198 additions and 0 deletions

11
apps/android/CHANGELOG.md Normal file
View File

@@ -0,0 +1,11 @@
# OpenClaw Android Changelog
## Unreleased
Maintenance update for the current OpenClaw Android release.
## 2026.6.2 - 2026-06-02
OpenClaw is now available on Android.
Connect to your OpenClaw Gateway to chat with your assistant, use realtime Talk mode, review approvals, and bring Android device capabilities like camera, location, screen, and notifications into your private automation workflows.

View File

@@ -30,7 +30,9 @@ const PUBLIC_EXTENSION_CONTRACT_RE =
*/
export const RELEASE_METADATA_PATHS = new Set([
"CHANGELOG.md",
"apps/android/CHANGELOG.md",
"apps/android/Config/Version.properties",
"apps/android/fastlane/metadata/android/en-US/release_notes.txt",
"apps/android/version.json",
"apps/ios/CHANGELOG.md",
"apps/ios/Config/Version.xcconfig",

View File

@@ -46,7 +46,9 @@ const LINTABLE_CORE_PATH_RE = /^(?:src|ui|packages)\/.+\.[cm]?[jt]sx?$/u;
const CORE_LINT_OPTIMIZATION_NEUTRAL_PATH_RE =
/^(?:scripts|test\/scripts)\/|^\.github\/workflows\/ci\.yml$/u;
const ANDROID_VERSION_SYNC_PATHS = new Set([
"apps/android/CHANGELOG.md",
"apps/android/Config/Version.properties",
"apps/android/fastlane/metadata/android/en-US/release_notes.txt",
"apps/android/version.json",
]);
let corepackPnpmShimDir;

View File

@@ -4,7 +4,9 @@ import path from "node:path";
import { parseReleaseVersion } from "./npm-publish-plan.mjs";
const ANDROID_VERSION_FILE = "apps/android/version.json";
const ANDROID_CHANGELOG_FILE = "apps/android/CHANGELOG.md";
const ANDROID_VERSION_PROPERTIES_FILE = "apps/android/Config/Version.properties";
const ANDROID_RELEASE_NOTES_FILE = "apps/android/fastlane/metadata/android/en-US/release_notes.txt";
const ANDROID_VERSION_CODE_MAX = 2_100_000_000;
type AndroidVersionManifest = {
@@ -14,6 +16,8 @@ type AndroidVersionManifest = {
export type ResolvedAndroidVersion = {
canonicalVersion: string;
changelogPath: string;
releaseNotesPath: string;
versionCode: number;
versionFilePath: string;
versionPropertiesPath: string;
@@ -161,13 +165,17 @@ export function writeAndroidVersionManifest(
export function resolveAndroidVersion(rootDir = path.resolve(".")): ResolvedAndroidVersion {
const versionFilePath = path.join(rootDir, ANDROID_VERSION_FILE);
const changelogPath = path.join(rootDir, ANDROID_CHANGELOG_FILE);
const versionPropertiesPath = path.join(rootDir, ANDROID_VERSION_PROPERTIES_FILE);
const releaseNotesPath = path.join(rootDir, ANDROID_RELEASE_NOTES_FILE);
const manifest = readAndroidVersionManifest(rootDir);
const canonicalVersion = normalizePinnedAndroidVersion(manifest.version ?? "");
const versionCode = normalizeAndroidVersionCode(manifest.versionCode, canonicalVersion);
return {
canonicalVersion,
changelogPath,
releaseNotesPath,
versionCode,
versionFilePath,
versionPropertiesPath,
@@ -178,6 +186,51 @@ export function renderAndroidVersionProperties(version: ResolvedAndroidVersion):
return `# Shared Android version defaults.\n# Source of truth: apps/android/version.json\n# Generated by scripts/android-sync-versioning.ts.\n\nOPENCLAW_ANDROID_VERSION_NAME=${version.canonicalVersion}\nOPENCLAW_ANDROID_VERSION_CODE=${version.versionCode}\n`;
}
function matchChangelogHeading(line: string, heading: string): boolean {
const normalized = line.trim();
return normalized === `## ${heading}` || normalized.startsWith(`## ${heading} - `);
}
export function extractChangelogSection(content: string, heading: string): string | null {
const lines = content.split(/\r?\n/u);
const startIndex = lines.findIndex((line) => matchChangelogHeading(line, heading));
if (startIndex === -1) {
return null;
}
let endIndex = lines.length;
for (let index = startIndex + 1; index < lines.length; index += 1) {
if (lines[index]?.startsWith("## ")) {
endIndex = index;
break;
}
}
const body = lines
.slice(startIndex + 1, endIndex)
.join("\n")
.trim();
return body || null;
}
export function renderAndroidReleaseNotes(
version: ResolvedAndroidVersion,
changelogContent: string,
): string {
const candidateHeadings = [version.canonicalVersion, "Unreleased"];
for (const heading of candidateHeadings) {
const body = extractChangelogSection(changelogContent, heading);
if (body) {
return `${body}\n`;
}
}
throw new Error(
`Unable to find Android changelog notes for ${version.canonicalVersion}. Add a matching section to ${ANDROID_CHANGELOG_FILE}.`,
);
}
function syncFile(params: {
mode: SyncAndroidVersioningMode;
path: string;
@@ -207,7 +260,9 @@ export function syncAndroidVersioning(params?: {
const mode = params?.mode ?? "write";
const rootDir = path.resolve(params?.rootDir ?? ".");
const version = resolveAndroidVersion(rootDir);
const changelogContent = readFileSync(version.changelogPath, "utf8");
const nextVersionProperties = renderAndroidVersionProperties(version);
const nextReleaseNotes = renderAndroidReleaseNotes(version, changelogContent);
const updatedPaths: string[] = [];
if (
@@ -221,5 +276,16 @@ export function syncAndroidVersioning(params?: {
updatedPaths.push(version.versionPropertiesPath);
}
if (
syncFile({
mode,
path: version.releaseNotesPath,
nextContent: nextReleaseNotes,
label: "Android release notes",
})
) {
updatedPaths.push(version.releaseNotesPath);
}
return { updatedPaths };
}

View File

@@ -15,12 +15,17 @@ export function installAndroidFixtureCleanup(): void {
export function writeAndroidFixture(params: {
version: string;
versionCode: number;
changelog?: string;
releaseNotes?: string;
packageVersion?: string;
versionProperties?: string;
prefix?: string;
}): string {
const rootDir = makeTempDir(tempDirs, params.prefix ?? "openclaw-android-version-");
fs.mkdirSync(path.join(rootDir, "apps", "android", "Config"), { recursive: true });
fs.mkdirSync(path.join(rootDir, "apps", "android", "fastlane", "metadata", "android", "en-US"), {
recursive: true,
});
fs.writeFileSync(
path.join(rootDir, "package.json"),
`${JSON.stringify({ version: params.packageVersion ?? "2026.6.2" }, null, 2)}\n`,
@@ -31,10 +36,31 @@ export function writeAndroidFixture(params: {
`${JSON.stringify({ version: params.version, versionCode: params.versionCode }, null, 2)}\n`,
"utf8",
);
const releaseNotes =
"OpenClaw is now available on Android.\n\nConnect to your OpenClaw Gateway.\n";
fs.writeFileSync(
path.join(rootDir, "apps", "android", "CHANGELOG.md"),
params.changelog ?? `# OpenClaw Android Changelog\n\n## Unreleased\n\n${releaseNotes}`,
"utf8",
);
fs.writeFileSync(
path.join(rootDir, "apps", "android", "Config", "Version.properties"),
params.versionProperties ?? "",
"utf8",
);
fs.writeFileSync(
path.join(
rootDir,
"apps",
"android",
"fastlane",
"metadata",
"android",
"en-US",
"release_notes.txt",
),
params.releaseNotes ?? releaseNotes,
"utf8",
);
return rootDir;
}

View File

@@ -3,11 +3,14 @@ import path from "node:path";
import { describe, expect, it } from "vitest";
import {
canonicalAndroidVersionCode,
extractChangelogSection,
normalizeGatewayVersionToPinnedAndroidVersion,
normalizePinnedAndroidVersion,
renderAndroidReleaseNotes,
renderAndroidVersionProperties,
resolveAndroidVersion,
resolveGatewayVersionForAndroidRelease,
syncAndroidVersioning,
} from "../../scripts/lib/android-version.ts";
import {
installAndroidFixtureCleanup,
@@ -25,6 +28,11 @@ describe("resolveAndroidVersion", () => {
expect(resolveAndroidVersion(rootDir)).toEqual({
canonicalVersion: "2026.6.2",
changelogPath: path.join(rootDir, "apps/android/CHANGELOG.md"),
releaseNotesPath: path.join(
rootDir,
"apps/android/fastlane/metadata/android/en-US/release_notes.txt",
),
versionCode: 2026060201,
versionFilePath: path.join(rootDir, "apps/android/version.json"),
versionPropertiesPath: path.join(rootDir, "apps/android/Config/Version.properties"),
@@ -134,3 +142,84 @@ describe("renderAndroidVersionProperties", () => {
);
});
});
describe("renderAndroidReleaseNotes", () => {
it("extracts exact pinned-version notes before Unreleased notes", () => {
const rootDir = writeAndroidFixture({
version: "2026.6.2",
versionCode: 2026060201,
changelog: [
"# OpenClaw Android Changelog",
"",
"## Unreleased",
"",
"Future Android changes.",
"",
"## 2026.6.2 - 2026-06-02",
"",
"Pinned Android release notes.",
"",
].join("\n"),
});
const version = resolveAndroidVersion(rootDir);
expect(
renderAndroidReleaseNotes(
version,
"# OpenClaw Android Changelog\n\n## Unreleased\n\nFuture Android changes.\n\n## 2026.6.2 - 2026-06-02\n\nPinned Android release notes.\n",
),
).toBe("Pinned Android release notes.\n");
});
it("falls back to Unreleased notes while iterating on a release train", () => {
const rootDir = writeAndroidFixture({
version: "2026.6.2",
versionCode: 2026060201,
});
const version = resolveAndroidVersion(rootDir);
expect(
renderAndroidReleaseNotes(
version,
"# OpenClaw Android Changelog\n\n## Unreleased\n\nPending Android notes.\n",
),
).toBe("Pending Android notes.\n");
});
it("rejects changelogs without exact-version or Unreleased notes", () => {
const rootDir = writeAndroidFixture({
version: "2026.6.2",
versionCode: 2026060201,
});
const version = resolveAndroidVersion(rootDir);
expect(() =>
renderAndroidReleaseNotes(
version,
"# OpenClaw Android Changelog\n\n## 2026.6.1\n\nOld notes.\n",
),
).toThrow("Unable to find Android changelog notes for 2026.6.2");
});
it("treats empty changelog sections as absent", () => {
expect(
extractChangelogSection("## Unreleased\n\n\n## 2026.6.2\n\nNotes.\n", "Unreleased"),
).toBeNull();
});
});
describe("syncAndroidVersioning", () => {
it("syncs generated Gradle version properties and Fastlane release notes", () => {
const rootDir = writeAndroidFixture({
version: "2026.6.2",
versionCode: 2026060201,
releaseNotes: "stale notes\n",
versionProperties: "stale version\n",
});
expect(syncAndroidVersioning({ mode: "write", rootDir }).updatedPaths).toEqual([
path.join(rootDir, "apps/android/Config/Version.properties"),
path.join(rootDir, "apps/android/fastlane/metadata/android/en-US/release_notes.txt"),
]);
});
});

View File

@@ -1172,7 +1172,9 @@ describe("scripts/changed-lanes", () => {
it("keeps release metadata commits off the full changed gate", () => {
const result = detectChangedLanes([
"CHANGELOG.md",
"apps/android/CHANGELOG.md",
"apps/android/Config/Version.properties",
"apps/android/fastlane/metadata/android/en-US/release_notes.txt",
"apps/android/version.json",
"apps/ios/CHANGELOG.md",
"apps/ios/Config/Version.xcconfig",