--- name: release-android description: Build Android APK and upload to Gitea Release with auto-detection of project structure and git configuration --- # Android Release Skill Build and release Android APK to Gitea with automatic configuration detection. ## 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 - Support both Kotlin DSL (build.gradle.kts) and Groovy (build.gradle) ## Prerequisites 1. **GITEA_TOKEN**: Environment variable with Gitea API token 2. **Git tag**: Must create a tag before releasing 3. **Java/Android SDK**: Android Studio or JDK installed ## Release Workflow ### Step 1: Check Environment ```bash # Check GITEA_TOKEN is set echo "GITEA_TOKEN: ${GITEA_TOKEN:+SET}" # Check Android project ls app/build.gradle.kts 2>/dev/null || ls android/app/build.gradle.kts 2>/dev/null # Check current version grep -h 'versionName' app/build.gradle.kts android/app/build.gradle.kts 2>/dev/null | head -1 # Check git tags git tag -l '*android*' -l 'v*' | tail -5 ``` ### Step 2: Prepare Release If there are uncommitted changes: 1. Update `versionCode` (+1) and `versionName` in `app/build.gradle.kts` 2. Commit changes 3. Create annotated git tag: - Monorepo: `git tag -a android-{version} -m "Release notes"` - Standalone: `git tag -a v{version} -m "Release notes"` 4. Push: `git push && git push origin {tag}` ### Step 3: Run Release Script ```bash node ~/.config/opencode/bin/release-android.mjs ``` Or specify Android directory: ```bash node ~/.config/opencode/bin/release-android.mjs ./android ``` ## Script Location The release script is located at: `~/.config/opencode/bin/release-android.mjs` ## Environment Variables | Variable | Required | Description | |----------|----------|-------------| | GITEA_TOKEN | Yes | Gitea API token | | GITEA_API_URL | No | Override Gitea API URL (auto-detected from git remote) | | GITEA_OWNER | No | Override repository owner (auto-detected) | | GITEA_REPO | No | Override repository name (auto-detected) | ## Tag Naming Convention - **Monorepo**: `android-{version}` (e.g., `android-1.2.0`) - **Standalone**: `v{version}` (e.g., `v1.2.0`) ## Output APK file naming: `{repo}-android-{version}.apk` Example: `bms-android-1.2.0.apk` ## Error Handling | Error | Solution | |-------|----------| | GITEA_TOKEN not set | `export GITEA_TOKEN="your_token"` | | Tag not found | Create and push tag first | | Build failed | Check Java/Android SDK installation | | APK not found | Check `app/build/outputs/apk/release/` | ## Release Script Source
Click to expand release-android.mjs source code ```javascript #!/usr/bin/env node /** * Android Release Script - Universal Version * * Automatically builds Android APK and uploads to Gitea Release. */ import fs from 'node:fs'; import path from 'node:path'; import { execSync } from 'node:child_process'; // Project Detection function findAndroidRoot(startDir) { const candidates = [ startDir, path.join(startDir, 'android'), path.join(startDir, '..'), ]; 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; } function findGitRoot(startDir) { try { return execSync('git rev-parse --show-toplevel', { cwd: startDir, encoding: 'utf-8', shell: true, stdio: ['pipe', 'pipe', 'pipe'] }).trim(); } catch { return null; } } function isMonorepo(androidDir, gitRoot) { if (!gitRoot) return false; return path.resolve(androidDir) !== path.resolve(gitRoot); } // Gitea Configuration 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: 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: 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 {} return {}; } // Java Detection function detectJavaHome() { const possiblePaths = [ '/Applications/Android Studio.app/Contents/jbr/Contents/Home', '/Applications/Android Studio.app/Contents/jre/Contents/Home', `${process.env.HOME}/android-studio/jbr`, `${process.env.HOME}/android-studio/jre`, process.env.JAVA_HOME, '/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 function readVersion(androidDir) { const paths = [ path.join(androidDir, 'app/build.gradle.kts'), path.join(androidDir, 'app/build.gradle') ]; for (const p of paths) { if (fs.existsSync(p)) { const content = fs.readFileSync(p, 'utf-8'); const match = content.match(/versionName\s*[=:]\s*["']([^"']+)["']/); if (match) return match[1]; } } return null; } function getTagName(version, isMonorepoProject) { return isMonorepoProject ? `android-${version}` : `v${version}`; } 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 }; 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 }; 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 function buildApk(androidDir, javaHome) { console.log('Building APK...'); execSync('./gradlew assembleRelease --quiet', { cwd: androidDir, stdio: 'inherit', shell: true, env: { ...process.env, JAVA_HOME: javaHome } }); } function findApk(androidDir) { const signed = path.join(androidDir, 'app/build/outputs/apk/release/app-release.apk'); const unsigned = path.join(androidDir, 'app/build/outputs/apk/release/app-release-unsigned.apk'); if (fs.existsSync(signed)) return { path: signed, signed: true }; if (fs.existsSync(unsigned)) return { path: unsigned, signed: false }; return null; } function curlJson(cmd) { try { const result = execSync(cmd, { encoding: 'utf-8', shell: true, stdio: ['pipe', 'pipe', 'pipe'] }); return JSON.parse(result); } catch { return null; } } function uploadToGitea(config) { const { apiUrl, owner, repo, token, tagName, fileName, apkPath, releaseMessage } = config; const repoApiBase = `${apiUrl}/api/v1/repos/${owner}/${repo}`; let releaseId; const releases = curlJson(`curl -s -H "Authorization: token ${token}" "${repoApiBase}/releases"`); if (Array.isArray(releases)) { const existing = releases.find((r) => r.tag_name === tagName); if (existing) { releaseId = existing.id; const asset = (existing.assets || []).find((a) => a.name === fileName); if (asset) { execSync(`curl -s -X DELETE -H "Authorization: token ${token}" "${repoApiBase}/releases/${releaseId}/assets/${asset.id}"`, { shell: true, stdio: ['pipe', 'pipe', 'pipe'] }); } } } if (!releaseId) { const tempJson = path.join(process.cwd(), '.release-data.json'); fs.writeFileSync(tempJson, JSON.stringify({ tag_name: tagName, name: `Android APK ${tagName}`, body: releaseMessage, draft: false, prerelease: false })); const releaseInfo = curlJson(`curl -s -X POST "${repoApiBase}/releases" -H "Authorization: token ${token}" -H "Content-Type: application/json" -d @${tempJson}`); fs.unlinkSync(tempJson); if (!releaseInfo?.id) throw new Error(`Failed to create release`); releaseId = releaseInfo.id; } const uploadUrl = `${repoApiBase}/releases/${releaseId}/assets?name=${encodeURIComponent(fileName)}`; const result = curlJson(`curl -s -X POST "${uploadUrl}" -H "Authorization: token ${token}" -F "attachment=@${apkPath}"`); if (!result?.id) throw new Error(`Failed to upload APK`); return { releaseUrl: `${apiUrl}/${owner}/${repo}/releases/tag/${tagName}`, fileSize: result.size }; } // Main function main() { const cwd = process.cwd(); const argDir = process.argv[2]; const searchDir = argDir ? path.resolve(cwd, argDir) : cwd; const androidDir = findAndroidRoot(searchDir); if (!androidDir) { console.error('Error: Cannot find Android project'); process.exit(1); } const gitRoot = findGitRoot(androidDir); if (!gitRoot) { console.error('Error: Not a git repository'); process.exit(1); } const monorepo = isMonorepo(androidDir, gitRoot); 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 not set'); process.exit(1); } if (!GITEA_API_URL || !GITEA_OWNER || !GITEA_REPO) { console.error('Error: Cannot detect Gitea config'); process.exit(1); } const version = readVersion(androidDir); if (!version) { console.error('Error: Cannot read versionName'); process.exit(1); } const tagName = getTagName(version, monorepo); const tagInfo = getTagInfo(tagName, gitRoot); if (!tagInfo.exists) { console.error(`Error: Tag "${tagName}" not found`); process.exit(1); } const javaHome = detectJavaHome(); if (!javaHome) { console.error('Error: Cannot find Java'); process.exit(1); } buildApk(androidDir, javaHome); const apk = findApk(androidDir); if (!apk) { console.error('Error: APK not found'); process.exit(1); } const fileName = `${GITEA_REPO}-android-${version}.apk`; 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(`Release successful! URL: ${result.releaseUrl}`); } main(); ```