diff --git a/bin/release-android.mjs b/bin/release-android.mjs deleted file mode 100755 index 028ae02..0000000 --- a/bin/release-android.mjs +++ /dev/null @@ -1,640 +0,0 @@ -#!/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();