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
This commit is contained in:
494
bin/release-android.mjs
Executable file
494
bin/release-android.mjs
Executable file
@@ -0,0 +1,494 @@
|
||||
#!/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();
|
||||
61
command/release-android.md
Normal file
61
command/release-android.md
Normal file
@@ -0,0 +1,61 @@
|
||||
---
|
||||
description: Build and release Android APK to Gitea
|
||||
---
|
||||
|
||||
# Android Release Command
|
||||
|
||||
Build Android APK and upload to Gitea Release.
|
||||
|
||||
## Prerequisites Check
|
||||
|
||||
First, verify the environment:
|
||||
|
||||
```bash
|
||||
# Check GITEA_TOKEN
|
||||
echo "GITEA_TOKEN: ${GITEA_TOKEN:+SET}"
|
||||
|
||||
# Check current directory for Android project
|
||||
ls -la app/build.gradle.kts 2>/dev/null || ls -la android/app/build.gradle.kts 2>/dev/null || echo "No Android project found"
|
||||
|
||||
# Check current version
|
||||
grep -h 'versionName' app/build.gradle.kts android/app/build.gradle.kts 2>/dev/null | head -1
|
||||
|
||||
# Check existing tags
|
||||
git tag -l '*android*' -l 'v*' | tail -5
|
||||
```
|
||||
|
||||
## Release Process
|
||||
|
||||
**If there are uncommitted changes:**
|
||||
1. Increment version: update `versionCode` (+1) and `versionName` in `app/build.gradle.kts`
|
||||
2. Commit the changes with a descriptive message
|
||||
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 commit and tag: `git push && git push origin {tag}`
|
||||
|
||||
**If no uncommitted changes but tag doesn't exist:**
|
||||
1. Create tag and push it
|
||||
|
||||
## Build and Upload
|
||||
|
||||
After tag is created and pushed, run:
|
||||
|
||||
```bash
|
||||
node ~/.config/opencode/bin/release-android.mjs
|
||||
```
|
||||
|
||||
The script will:
|
||||
- Auto-detect Android project root (standalone or monorepo)
|
||||
- Auto-detect Gitea config from git remote URL (HTTPS/SSH)
|
||||
- Auto-detect Java from Android Studio
|
||||
- Build release APK
|
||||
- Upload to Gitea Release
|
||||
|
||||
## Error Handling
|
||||
|
||||
- If `GITEA_TOKEN` is not set: `export GITEA_TOKEN="your_token"`
|
||||
- If build fails: check Java/Android SDK installation
|
||||
- If tag not found: create and push the tag first
|
||||
|
||||
$ARGUMENTS
|
||||
@@ -1,5 +1,27 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"provider": {
|
||||
"opencode": {
|
||||
"models": {
|
||||
"claude-opus-4-5": {
|
||||
"options": {
|
||||
"thinking": {
|
||||
"type": "enabled",
|
||||
"budgetTokens": 16000
|
||||
}
|
||||
}
|
||||
},
|
||||
"claude-sonnet-4-5": {
|
||||
"options": {
|
||||
"thinking": {
|
||||
"type": "enabled",
|
||||
"budgetTokens": 16000
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"mcp": {
|
||||
"ref": {
|
||||
"type": "remote",
|
||||
|
||||
352
skill/release-android/SKILL.md
Normal file
352
skill/release-android/SKILL.md
Normal file
@@ -0,0 +1,352 @@
|
||||
---
|
||||
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>
|
||||
Reference in New Issue
Block a user