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

11 KiB

name, description
name description
release-android 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

# 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

node ~/.config/opencode/bin/release-android.mjs

Or specify Android directory:

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
#!/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();