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