diff --git a/skill/release-android/SKILL.md b/skill/release-android/SKILL.md deleted file mode 100644 index e77cdb7..0000000 --- a/skill/release-android/SKILL.md +++ /dev/null @@ -1,352 +0,0 @@ ---- -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(); -``` - -