From 529150868c82b7b2519102c544c055eeb041dca3 Mon Sep 17 00:00:00 2001 From: joshavant <830519+joshavant@users.noreply.github.com> Date: Tue, 16 Jun 2026 21:54:21 +0200 Subject: [PATCH] android: derive release notes from changelog --- apps/android/CHANGELOG.md | 11 +++ scripts/changed-lanes.mjs | 2 + scripts/check-changed.mjs | 2 + scripts/lib/android-version.ts | 66 +++++++++++++++ test/scripts/android-version.test-support.ts | 26 ++++++ test/scripts/android-version.test.ts | 89 ++++++++++++++++++++ test/scripts/changed-lanes.test.ts | 2 + 7 files changed, 198 insertions(+) create mode 100644 apps/android/CHANGELOG.md diff --git a/apps/android/CHANGELOG.md b/apps/android/CHANGELOG.md new file mode 100644 index 00000000000..f6b79296ef9 --- /dev/null +++ b/apps/android/CHANGELOG.md @@ -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. diff --git a/scripts/changed-lanes.mjs b/scripts/changed-lanes.mjs index 99027c92cc1..79798fc7148 100644 --- a/scripts/changed-lanes.mjs +++ b/scripts/changed-lanes.mjs @@ -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", diff --git a/scripts/check-changed.mjs b/scripts/check-changed.mjs index 916d4d047d9..eb9b6c39e76 100644 --- a/scripts/check-changed.mjs +++ b/scripts/check-changed.mjs @@ -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; diff --git a/scripts/lib/android-version.ts b/scripts/lib/android-version.ts index 825f79afad8..d44ac1095cb 100644 --- a/scripts/lib/android-version.ts +++ b/scripts/lib/android-version.ts @@ -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 }; } diff --git a/test/scripts/android-version.test-support.ts b/test/scripts/android-version.test-support.ts index 110264da95e..b0ce7b838cd 100644 --- a/test/scripts/android-version.test-support.ts +++ b/test/scripts/android-version.test-support.ts @@ -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; } diff --git a/test/scripts/android-version.test.ts b/test/scripts/android-version.test.ts index ab28d0cc1a4..165e6bb7c09 100644 --- a/test/scripts/android-version.test.ts +++ b/test/scripts/android-version.test.ts @@ -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"), + ]); + }); +}); diff --git a/test/scripts/changed-lanes.test.ts b/test/scripts/changed-lanes.test.ts index 61fbff13c16..d09158296ef 100644 --- a/test/scripts/changed-lanes.test.ts +++ b/test/scripts/changed-lanes.test.ts @@ -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",