#!/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 [android-dir] * * If android-dir is not specified, will auto-detect from current directory. */ 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; } /** * Get tag name based on project structure */ function getTagName(version, isMonorepoProject) { // Monorepo uses prefixed tag, standalone uses v-prefix return isMonorepoProject ? `android-${version}` : `v${version}`; } /** * 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('Building 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(`Found existing Release (ID: ${releaseId})`); // Delete existing asset with same name const existingAsset = (existingRelease.assets || []).find((a) => a.name === fileName); if (existingAsset) { console.log(`Deleting existing asset: ${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('Creating new 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 created (ID: ${releaseId})`); } // Upload APK console.log('Uploading 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 // ============================================================================ function main() { const cwd = process.cwd(); const argDir = process.argv[2]; console.log('='.repeat(60)); console.log('Android Release Script'); 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('Error: Cannot find Android project'); console.error('Make sure you are in an Android project directory or specify the path'); console.error('Usage: release-android [android-dir]'); process.exit(1); } console.log(`Android project: ${androidDir}`); // 2. Find git root const gitRoot = findGitRoot(androidDir); if (!gitRoot) { console.error('Error: Not a git repository'); process.exit(1); } console.log(`Git root: ${gitRoot}`); const monorepo = isMonorepo(androidDir, gitRoot); console.log(`Project type: ${monorepo ? 'Monorepo' : 'Standalone'}`); // 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('Error: GITEA_TOKEN environment variable is not set'); console.error('Please set: export GITEA_TOKEN="your_token"'); process.exit(1); } if (!GITEA_API_URL || !GITEA_OWNER || !GITEA_REPO) { console.error('Error: Cannot detect Gitea repository configuration'); console.error('Please set environment variables: 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('Error: Cannot read versionName from build.gradle'); process.exit(1); } console.log(`Version: ${version}`); // 5. Check tag const tagName = getTagName(version, monorepo); const tagInfo = getTagInfo(tagName, gitRoot); if (!tagInfo.exists) { console.error(`Error: Git tag "${tagName}" not found`); console.error(''); console.error('Please create a tag before releasing:'); console.error(` git tag -a ${tagName} -m "Release notes"`); console.error(` git push origin ${tagName}`); process.exit(1); } console.log(`Tag: ${tagName}`); // 6. Detect Java const javaHome = detectJavaHome(); if (!javaHome) { console.error('Error: Cannot find Java Runtime'); console.error('Please install JDK or ensure Android Studio is properly installed'); process.exit(1); } console.log(`Java: ${javaHome}`); console.log(''); console.log('Building...'); // 7. Build APK try { buildApk(androidDir, javaHome); } catch (err) { console.error('Build failed:', err.message); process.exit(1); } // 8. Find APK const apk = findApk(androidDir); if (!apk) { console.error('Error: APK file not found'); console.error('Check build output at: app/build/outputs/apk/release/'); process.exit(1); } console.log(`APK: ${apk.path} (${apk.signed ? 'signed' : 'unsigned'})`); // 9. Upload to Gitea const fileName = `${GITEA_REPO}-android-${version}.apk`; console.log(''); console.log('Uploading to 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('Release successful!'); console.log(`File: ${fileName}`); console.log(`Size: ${(result.fileSize / 1024 / 1024).toFixed(2)} MB`); console.log(`URL: ${result.releaseUrl}`); console.log('='.repeat(60)); } catch (err) { console.error('Upload failed:', err.message); process.exit(1); } } main();