Files
opencode/skill/release-android/SKILL.md
voson 347d18192c feat: add release-android skill, command and script
- Add bin/release-android.mjs: Universal Android release script
  - 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

- Add command/release-android.md: Command to trigger release process

- Add skill/release-android/SKILL.md: Skill documentation with full script source

- Update opencode.json: Enable thinking for claude models
2026-01-07 09:23:57 +08:00

353 lines
11 KiB
Markdown

---
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
<details>
<summary>Click to expand release-android.mjs source code</summary>
```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();
```
</details>