diff --git a/bin/release-android.mjs b/bin/release-android.mjs new file mode 100755 index 0000000..028ae02 --- /dev/null +++ b/bin/release-android.mjs @@ -0,0 +1,640 @@ +#!/usr/bin/env node +/** + * Android Release Script - Universal Version + * + * Automatically builds Android APK and uploads to Gitea Release. + * + * Features: + * - Auto-detect Android project root (standalone or monorepo) + * - Auto-detect Gitea config from git remote URL (HTTPS/SSH) + * - Auto-detect Java Home from Android Studio + * - Support tag-based release workflow + * + * Environment Variables: + * - GITEA_TOKEN (required): Gitea API token + * - GITEA_API_URL (optional): Override Gitea API URL + * - GITEA_OWNER (optional): Override repository owner + * - GITEA_REPO (optional): Override repository name + * + * Usage: + * node release-android.mjs [--project ] [android-dir] + * + * --project Specify project name (e.g., admin-tool, android) + * android-dir Android project directory (optional, auto-detect if not specified) + * + * Examples: + * node release-android.mjs --project admin-tool + * node release-android.mjs --project android ./android + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { execSync } from 'node:child_process'; + +// ============================================================================ +// Project Detection +// ============================================================================ + +/** + * Find Android project root directory + * Looks for app/build.gradle.kts or build.gradle.kts with android plugin + */ +function findAndroidRoot(startDir) { + const candidates = [ + startDir, // Current dir is Android root + path.join(startDir, 'android'), // Monorepo: ./android/ + path.join(startDir, '..'), // Parent dir + ]; + + for (const dir of candidates) { + const buildGradle = path.join(dir, 'app/build.gradle.kts'); + const buildGradleGroovy = path.join(dir, 'app/build.gradle'); + + if (fs.existsSync(buildGradle) || fs.existsSync(buildGradleGroovy)) { + return path.resolve(dir); + } + } + + return null; +} + +/** + * Find git repository root + */ +function findGitRoot(startDir) { + try { + const result = execSync('git rev-parse --show-toplevel', { + cwd: startDir, + encoding: 'utf-8', + shell: true, + stdio: ['pipe', 'pipe', 'pipe'] + }).trim(); + return result; + } catch { + return null; + } +} + +/** + * Detect if project is a monorepo (Android is a subdirectory) + */ +function isMonorepo(androidDir, gitRoot) { + if (!gitRoot) return false; + return path.resolve(androidDir) !== path.resolve(gitRoot); +} + +// ============================================================================ +// Git/Gitea Configuration +// ============================================================================ + +/** + * Parse git remote URL to extract Gitea configuration + * Supports both HTTPS and SSH formats + */ +function detectGiteaConfig(gitRoot) { + try { + const remoteUrl = execSync('git remote get-url origin', { + cwd: gitRoot, + encoding: 'utf-8', + shell: true, + stdio: ['pipe', 'pipe', 'pipe'] + }).trim(); + + // HTTPS format: https://git.example.com/owner/repo.git + const httpsMatch = remoteUrl.match(/https?:\/\/([^/]+)\/([^/]+)\/([^/]+?)(\.git)?$/); + if (httpsMatch) { + return { + apiUrl: `https://${httpsMatch[1]}`, + owner: httpsMatch[2], + repo: httpsMatch[3] + }; + } + + // SSH format: git@git.example.com:owner/repo.git + const sshMatch = remoteUrl.match(/git@([^:]+):([^/]+)\/([^/]+?)(\.git)?$/); + if (sshMatch) { + return { + apiUrl: `https://${sshMatch[1]}`, + owner: sshMatch[2], + repo: sshMatch[3] + }; + } + } catch { + // Ignore errors + } + return {}; +} + +// ============================================================================ +// Java Detection +// ============================================================================ + +/** + * Detect Java Home, prioritizing Android Studio's bundled JDK + */ +function detectJavaHome() { + const possiblePaths = [ + // macOS - Android Studio bundled JDK + '/Applications/Android Studio.app/Contents/jbr/Contents/Home', + '/Applications/Android Studio.app/Contents/jre/Contents/Home', + // Linux - Android Studio bundled JDK + `${process.env.HOME}/android-studio/jbr`, + `${process.env.HOME}/android-studio/jre`, + // Environment variable + process.env.JAVA_HOME, + // Common system paths + '/usr/lib/jvm/java-11-openjdk', + '/usr/lib/jvm/java-17-openjdk', + ]; + + for (const javaHome of possiblePaths) { + if (javaHome && fs.existsSync(javaHome)) { + const javaBin = path.join(javaHome, 'bin', 'java'); + if (fs.existsSync(javaBin)) { + return javaHome; + } + } + } + + return null; +} + +// ============================================================================ +// Version & Tag Management +// ============================================================================ + +/** + * Read version from build.gradle.kts + */ +function readVersion(androidDir) { + const buildGradlePath = path.join(androidDir, 'app/build.gradle.kts'); + const buildGradleGroovyPath = path.join(androidDir, 'app/build.gradle'); + + let buildGradle; + if (fs.existsSync(buildGradlePath)) { + buildGradle = fs.readFileSync(buildGradlePath, 'utf-8'); + } else if (fs.existsSync(buildGradleGroovyPath)) { + buildGradle = fs.readFileSync(buildGradleGroovyPath, 'utf-8'); + } else { + return null; + } + + const versionMatch = buildGradle.match(/versionName\s*[=:]\s*["']([^"']+)["']/); + return versionMatch ? versionMatch[1] : null; +} + +/** + * Infer project name from Android directory name and git tags + * Returns the prefix used in tags (e.g., "myapp", "android", or "") + */ +function inferProjectName(gitRoot, androidDir) { + try { + // First, try to infer from Android directory name + const dirName = path.basename(androidDir); + + // Get all tags with version pattern + const tags = execSync('git tag -l --sort=-version:refname', { + cwd: gitRoot, + encoding: 'utf-8', + shell: true, + stdio: ['pipe', 'pipe', 'pipe'] + }).trim().split('\n').filter(Boolean); + + if (tags.length === 0) { + return null; + } + + // If directory name matches any tag prefix, prefer that + // For example: if dir is "admin-tool", look for "admin-tool-*" tags + const dirPrefixTags = tags.filter(tag => tag.startsWith(`${dirName}-`)); + if (dirPrefixTags.length > 0) { + return dirName; + } + + // If directory is "android" or "app", try "android" prefix + if (dirName === 'android' || dirName === 'app') { + const androidTags = tags.filter(tag => tag.startsWith('android-')); + if (androidTags.length > 0) { + return 'android'; + } + } + + // Fallback: try to extract prefix from recent tags + // Patterns: "myapp-1.0.0", "android-1.0.0", "v1.0.0" + for (const tag of tags.slice(0, 10)) { // Check recent 10 tags + const match = tag.match(/^(.+?)-\d+\.\d+/); + if (match) { + return match[1]; // Return prefix like "myapp", "android" + } + if (tag.match(/^v\d+\.\d+/)) { + return ''; // v-prefix style (no project name) + } + } + + return null; + } catch { + return null; + } +} + +/** + * Get latest tag that matches the pattern + */ +function getLatestTag(gitRoot, projectPrefix) { + try { + const pattern = projectPrefix ? `${projectPrefix}-*` : 'v*'; + const tag = execSync(`git tag -l '${pattern}' --sort=-version:refname | head -1`, { + cwd: gitRoot, + encoding: 'utf-8', + shell: true, + stdio: ['pipe', 'pipe', 'pipe'] + }).trim(); + + return tag || null; + } catch { + return null; + } +} + +/** + * Get tag information (check existence and get annotation) + */ +function getTagInfo(tagName, gitRoot) { + try { + const tagExists = execSync(`git tag -l ${tagName}`, { + cwd: gitRoot, + encoding: 'utf-8', + shell: true, + stdio: ['pipe', 'pipe', 'pipe'] + }).trim(); + + if (!tagExists) { + return { exists: false }; + } + + // Get tag annotation + const annotation = execSync(`git tag -l --format='%(contents)' ${tagName}`, { + cwd: gitRoot, + encoding: 'utf-8', + shell: true, + stdio: ['pipe', 'pipe', 'pipe'] + }).trim(); + + if (annotation) { + return { exists: true, message: annotation }; + } + + // Fall back to commit message + const commitMsg = execSync(`git log -1 --format=%B ${tagName}`, { + cwd: gitRoot, + encoding: 'utf-8', + shell: true, + stdio: ['pipe', 'pipe', 'pipe'] + }).trim(); + + return { exists: true, message: commitMsg || `Release ${tagName}` }; + } catch { + return { exists: false }; + } +} + +// ============================================================================ +// Build & Upload +// ============================================================================ + +/** + * Build Android APK + */ +function buildApk(androidDir, javaHome) { + console.log('正在构建 APK...'); + execSync('./gradlew assembleRelease --quiet', { + cwd: androidDir, + stdio: 'inherit', + shell: true, + env: { ...process.env, JAVA_HOME: javaHome } + }); +} + +/** + * Find built APK file + */ +function findApk(androidDir) { + const signedApkPath = path.join(androidDir, 'app/build/outputs/apk/release/app-release.apk'); + const unsignedApkPath = path.join(androidDir, 'app/build/outputs/apk/release/app-release-unsigned.apk'); + + if (fs.existsSync(signedApkPath)) { + return { path: signedApkPath, signed: true }; + } else if (fs.existsSync(unsignedApkPath)) { + return { path: unsignedApkPath, signed: false }; + } + return null; +} + +/** + * Execute curl and return JSON response + */ +function curlJson(cmd) { + try { + const result = execSync(cmd, { encoding: 'utf-8', shell: true, stdio: ['pipe', 'pipe', 'pipe'] }); + return JSON.parse(result); + } catch { + return null; + } +} + +/** + * Upload APK to Gitea Release + */ +function uploadToGitea(config) { + const { apiUrl, owner, repo, token, tagName, fileName, apkPath, releaseMessage } = config; + + const repoApiBase = `${apiUrl}/api/v1/repos/${owner}/${repo}`; + + // Get or create release + let releaseId; + const releases = curlJson( + `curl -s -H "Authorization: token ${token}" "${repoApiBase}/releases"` + ); + + if (Array.isArray(releases)) { + const existingRelease = releases.find((r) => r.tag_name === tagName); + if (existingRelease) { + releaseId = existingRelease.id; + console.log(`找到已存在的 Release (ID: ${releaseId})`); + + // Delete existing asset with same name + const existingAsset = (existingRelease.assets || []).find((a) => a.name === fileName); + if (existingAsset) { + console.log(`删除已存在的文件: ${fileName}`); + execSync( + `curl -s -X DELETE -H "Authorization: token ${token}" "${repoApiBase}/releases/${releaseId}/assets/${existingAsset.id}"`, + { shell: true, stdio: ['pipe', 'pipe', 'pipe'] } + ); + } + } + } + + if (!releaseId) { + console.log('创建新 Release...'); + const releaseData = { + tag_name: tagName, + name: `Android APK ${tagName}`, + body: releaseMessage, + draft: false, + prerelease: false + }; + + const tempJsonPath = path.join(process.cwd(), '.release-data.json'); + fs.writeFileSync(tempJsonPath, JSON.stringify(releaseData)); + + const releaseInfo = curlJson( + `curl -s -X POST "${repoApiBase}/releases" \ + -H "Authorization: token ${token}" \ + -H "Content-Type: application/json" \ + -d @${tempJsonPath}` + ); + + fs.unlinkSync(tempJsonPath); + + if (!releaseInfo || !releaseInfo.id) { + throw new Error(`Failed to create release: ${JSON.stringify(releaseInfo)}`); + } + + releaseId = releaseInfo.id; + console.log(`Release 已创建 (ID: ${releaseId})`); + } + + // Upload APK + console.log('上传 APK...'); + const uploadUrl = `${repoApiBase}/releases/${releaseId}/assets?name=${encodeURIComponent(fileName)}`; + const uploadResult = curlJson( + `curl -s -X POST "${uploadUrl}" \ + -H "Authorization: token ${token}" \ + -F "attachment=@${apkPath}"` + ); + + if (!uploadResult || !uploadResult.id) { + throw new Error(`Failed to upload APK: ${JSON.stringify(uploadResult)}`); + } + + return { + releaseUrl: `${apiUrl}/${owner}/${repo}/releases/tag/${tagName}`, + fileSize: uploadResult.size + }; +} + +// ============================================================================ +// Main +// ============================================================================ + +/** + * Parse command line arguments + */ +function parseArgs() { + const args = process.argv.slice(2); + let projectName = null; + let androidDir = null; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === '--project' && i + 1 < args.length) { + projectName = args[i + 1]; + i++; // skip next arg + } else if (!arg.startsWith('--')) { + androidDir = arg; + } + } + + return { projectName, androidDir }; +} + +function main() { + const cwd = process.cwd(); + const { projectName, androidDir: argDir } = parseArgs(); + + console.log('='.repeat(60)); + console.log('Android 发布脚本'); + console.log('='.repeat(60)); + + // 1. Find Android project root + const searchDir = argDir ? path.resolve(cwd, argDir) : cwd; + const androidDir = findAndroidRoot(searchDir); + + if (!androidDir) { + console.error('错误:找不到 Android 项目'); + console.error('请确保当前目录是 Android 项目或指定正确的路径'); + console.error('用法: release-android [android-dir]'); + process.exit(1); + } + console.log(`Android 项目: ${androidDir}`); + + // 2. Find git root + const gitRoot = findGitRoot(androidDir); + if (!gitRoot) { + console.error('错误:不是 git 仓库'); + process.exit(1); + } + console.log(`Git 根目录: ${gitRoot}`); + + const monorepo = isMonorepo(androidDir, gitRoot); + console.log(`项目类型: ${monorepo ? 'Monorepo' : '独立项目'}`); + + // 3. Detect Gitea configuration + const detected = detectGiteaConfig(gitRoot); + const GITEA_TOKEN = process.env.GITEA_TOKEN || ''; + const GITEA_API_URL = process.env.GITEA_API_URL || detected.apiUrl || ''; + const GITEA_OWNER = process.env.GITEA_OWNER || detected.owner || ''; + const GITEA_REPO = process.env.GITEA_REPO || detected.repo || ''; + + if (!GITEA_TOKEN) { + console.error('错误:未设置 GITEA_TOKEN 环境变量'); + console.error('请设置: export GITEA_TOKEN="your_token"'); + process.exit(1); + } + + if (!GITEA_API_URL || !GITEA_OWNER || !GITEA_REPO) { + console.error('错误:无法检测 Gitea 仓库配置'); + console.error('请设置环境变量: GITEA_API_URL, GITEA_OWNER, GITEA_REPO'); + process.exit(1); + } + + console.log(`Gitea: ${GITEA_API_URL}/${GITEA_OWNER}/${GITEA_REPO}`); + + // 4. Read version + const version = readVersion(androidDir); + if (!version) { + console.error('错误:无法从 build.gradle 读取 versionName'); + process.exit(1); + } + console.log(`版本: ${version}`); + + // 5. Determine project prefix + let projectPrefix; + + if (projectName) { + // Use provided project name + console.log(`项目前缀: ${projectName} (指定)`); + projectPrefix = projectName; + + // Verify that tags with this prefix exist + const testTag = getLatestTag(gitRoot, projectPrefix); + if (!testTag) { + console.error(`错误:找不到匹配 "${projectPrefix}-*" 模式的 git tag`); + console.error(''); + console.error('可用的 tags:'); + try { + const allTags = execSync('git tag -l --sort=-version:refname | head -10', { + cwd: gitRoot, + encoding: 'utf-8', + shell: true, + stdio: ['pipe', 'pipe', 'pipe'] + }).trim(); + console.error(allTags || ' (未找到 tags)'); + } catch { + console.error(' (无法列出 tags)'); + } + console.error(''); + console.error(`请创建正确前缀的 tag:`); + console.error(` git tag -a ${projectPrefix}-${version} -m "发布说明"`); + console.error(` git push origin ${projectPrefix}-${version}`); + process.exit(1); + } + } else { + // Infer project name from directory and tags + projectPrefix = inferProjectName(gitRoot, androidDir); + if (projectPrefix === null) { + console.error('错误:无法从 git tags 推断项目名称'); + console.error('请确保至少存在一个以下格式的 tag:'); + console.error(' myapp-1.0.0 或 v1.0.0'); + console.error(''); + console.error('或使用 --project 明确指定项目名称:'); + console.error(' node release-android.mjs --project admin-tool'); + process.exit(1); + } + console.log(`项目前缀: ${projectPrefix || '(v-style)'} (自动检测)`); + } + + const tagName = getLatestTag(gitRoot, projectPrefix); + if (!tagName) { + const expectedTag = projectPrefix ? `${projectPrefix}-${version}` : `v${version}`; + console.error(`错误:找不到匹配的 git tag`); + console.error(`期望的 tag 格式: ${expectedTag}`); + console.error(''); + console.error('请先创建 tag:'); + console.error(` git tag -a ${expectedTag} -m "发布说明"`); + console.error(` git push origin ${expectedTag}`); + process.exit(1); + } + + const tagInfo = getTagInfo(tagName, gitRoot); + if (!tagInfo.exists) { + console.error(`错误:Git tag "${tagName}" 不存在`); + console.error('请推送 tag:'); + console.error(` git push origin ${tagName}`); + process.exit(1); + } + console.log(`Tag: ${tagName}`); + + // 6. Detect Java + const javaHome = detectJavaHome(); + if (!javaHome) { + console.error('错误:找不到 Java 运行环境'); + console.error('请安装 JDK 或确保 Android Studio 已正确安装'); + process.exit(1); + } + console.log(`Java: ${javaHome}`); + + console.log(''); + console.log('开始构建...'); + + // 7. Build APK + try { + buildApk(androidDir, javaHome); + } catch (err) { + console.error('构建失败:', err.message); + process.exit(1); + } + + // 8. Find APK + const apk = findApk(androidDir); + if (!apk) { + console.error('错误:找不到 APK 文件'); + console.error('检查构建输出: app/build/outputs/apk/release/'); + process.exit(1); + } + console.log(`APK: ${apk.path} (${apk.signed ? '已签名' : '未签名'})`); + + // 9. Upload to Gitea + const fileName = projectPrefix + ? `${projectPrefix}-${version}.apk` + : `${GITEA_REPO}-${version}.apk`; + + console.log(''); + console.log('上传到 Gitea...'); + + try { + const result = uploadToGitea({ + apiUrl: GITEA_API_URL, + owner: GITEA_OWNER, + repo: GITEA_REPO, + token: GITEA_TOKEN, + tagName, + fileName, + apkPath: apk.path, + releaseMessage: tagInfo.message + }); + + console.log(''); + console.log('='.repeat(60)); + console.log('发布成功!'); + console.log(`文件: ${fileName}`); + console.log(`大小: ${(result.fileSize / 1024 / 1024).toFixed(2)} MB`); + console.log(`URL: ${result.releaseUrl}`); + console.log('='.repeat(60)); + } catch (err) { + console.error('上传失败:', err.message); + process.exit(1); + } +} + +main(); diff --git a/command/release-android.md b/command/release-android.md new file mode 100644 index 0000000..11158d1 --- /dev/null +++ b/command/release-android.md @@ -0,0 +1,52 @@ +--- +description: Build and release Android APK to Gitea (generic for any Android project) +--- + +# Android Release Command + +Build Android APK and upload to Gitea Release for the current directory's Android project. + +## Prerequisites + +Before running this command: + +1. **Create and push the git tag first** (e.g., `myapp-1.0.0`, `android-1.0.0`, or `v1.0.0`) +2. Ensure `GITEA_API_TOKEN` environment variable is set +3. Ensure Android project exists in current directory + +Check the environment: + +```bash +# Check GITEA_API_TOKEN +echo "GITEA_API_TOKEN: ${GITEA_API_TOKEN:+SET}" + +# Check current directory for Android project +ls -la app/build.gradle.kts 2>/dev/null || ls -la android/app/build.gradle.kts 2>/dev/null || echo "No Android project found" + +# Check recent tags (script will use the latest tag) +git tag -l | tail -10 +``` + +## Build and Upload + +After creating and pushing the tag, run: + +```bash +node ~/.opencode/bin/release-android.mjs +``` + +The script will: +- Auto-detect Android project root (standalone or monorepo) +- Auto-detect project name from recent git tags or directory name +- Auto-detect Gitea config from git remote URL (HTTPS/SSH) +- Auto-detect Java from Android Studio +- Build release APK +- Upload to Gitea Release matching the tag pattern + +## Error Handling + +- If `GITEA_API_TOKEN` is not set: `export GITEA_API_TOKEN="your_token"` +- If build fails: check Java/Android SDK installation +- If tag not found: create and push the tag first + +$ARGUMENTS