Files
opencode/bin/release-android.mjs
voson dce31f9729 docs: 更新命令文档和发布脚本
- 更新 release-android.md: 明确前置条件和通用项目支持
- 更新 auto-commit.md: 添加中英文 commit 信息平台选择规则
- 更新 commit-push.md: 添加中英文 commit 信息平台选择规则
- 更新 sync-oc-push.md: 添加中英文 commit 信息平台选择规则
- 更新 release-android.mjs: 支持从 tags 自动推断项目名,不再硬编码 android 前缀
- 更新 AGENTS.md: 补充中文交流规则的注意事项
2026-01-09 16:54:56 +08:00

641 lines
19 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 [--project <name>] [android-dir]
*
* --project <name> Specify project name (e.g., admin-tool, android)
* android-dir Android project directory (optional, auto-detect if not specified)
*
* Examples:
* node release-android.mjs --project admin-tool
* node release-android.mjs --project android ./android
*/
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;
}
/**
* Infer project name from Android directory name and git tags
* Returns the prefix used in tags (e.g., "myapp", "android", or "")
*/
function inferProjectName(gitRoot, androidDir) {
try {
// First, try to infer from Android directory name
const dirName = path.basename(androidDir);
// Get all tags with version pattern
const tags = execSync('git tag -l --sort=-version:refname', {
cwd: gitRoot,
encoding: 'utf-8',
shell: true,
stdio: ['pipe', 'pipe', 'pipe']
}).trim().split('\n').filter(Boolean);
if (tags.length === 0) {
return null;
}
// If directory name matches any tag prefix, prefer that
// For example: if dir is "admin-tool", look for "admin-tool-*" tags
const dirPrefixTags = tags.filter(tag => tag.startsWith(`${dirName}-`));
if (dirPrefixTags.length > 0) {
return dirName;
}
// If directory is "android" or "app", try "android" prefix
if (dirName === 'android' || dirName === 'app') {
const androidTags = tags.filter(tag => tag.startsWith('android-'));
if (androidTags.length > 0) {
return 'android';
}
}
// Fallback: try to extract prefix from recent tags
// Patterns: "myapp-1.0.0", "android-1.0.0", "v1.0.0"
for (const tag of tags.slice(0, 10)) { // Check recent 10 tags
const match = tag.match(/^(.+?)-\d+\.\d+/);
if (match) {
return match[1]; // Return prefix like "myapp", "android"
}
if (tag.match(/^v\d+\.\d+/)) {
return ''; // v-prefix style (no project name)
}
}
return null;
} catch {
return null;
}
}
/**
* Get latest tag that matches the pattern
*/
function getLatestTag(gitRoot, projectPrefix) {
try {
const pattern = projectPrefix ? `${projectPrefix}-*` : 'v*';
const tag = execSync(`git tag -l '${pattern}' --sort=-version:refname | head -1`, {
cwd: gitRoot,
encoding: 'utf-8',
shell: true,
stdio: ['pipe', 'pipe', 'pipe']
}).trim();
return tag || null;
} catch {
return null;
}
}
/**
* 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('正在构建 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(`找到已存在的 Release (ID: ${releaseId})`);
// Delete existing asset with same name
const existingAsset = (existingRelease.assets || []).find((a) => a.name === fileName);
if (existingAsset) {
console.log(`删除已存在的文件: ${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('创建新 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 已创建 (ID: ${releaseId})`);
}
// Upload APK
console.log('上传 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
// ============================================================================
/**
* Parse command line arguments
*/
function parseArgs() {
const args = process.argv.slice(2);
let projectName = null;
let androidDir = null;
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === '--project' && i + 1 < args.length) {
projectName = args[i + 1];
i++; // skip next arg
} else if (!arg.startsWith('--')) {
androidDir = arg;
}
}
return { projectName, androidDir };
}
function main() {
const cwd = process.cwd();
const { projectName, androidDir: argDir } = parseArgs();
console.log('='.repeat(60));
console.log('Android 发布脚本');
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('错误:找不到 Android 项目');
console.error('请确保当前目录是 Android 项目或指定正确的路径');
console.error('用法: release-android [android-dir]');
process.exit(1);
}
console.log(`Android 项目: ${androidDir}`);
// 2. Find git root
const gitRoot = findGitRoot(androidDir);
if (!gitRoot) {
console.error('错误:不是 git 仓库');
process.exit(1);
}
console.log(`Git 根目录: ${gitRoot}`);
const monorepo = isMonorepo(androidDir, gitRoot);
console.log(`项目类型: ${monorepo ? 'Monorepo' : '独立项目'}`);
// 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('错误:未设置 GITEA_TOKEN 环境变量');
console.error('请设置: export GITEA_TOKEN="your_token"');
process.exit(1);
}
if (!GITEA_API_URL || !GITEA_OWNER || !GITEA_REPO) {
console.error('错误:无法检测 Gitea 仓库配置');
console.error('请设置环境变量: 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('错误:无法从 build.gradle 读取 versionName');
process.exit(1);
}
console.log(`版本: ${version}`);
// 5. Determine project prefix
let projectPrefix;
if (projectName) {
// Use provided project name
console.log(`项目前缀: ${projectName} (指定)`);
projectPrefix = projectName;
// Verify that tags with this prefix exist
const testTag = getLatestTag(gitRoot, projectPrefix);
if (!testTag) {
console.error(`错误:找不到匹配 "${projectPrefix}-*" 模式的 git tag`);
console.error('');
console.error('可用的 tags:');
try {
const allTags = execSync('git tag -l --sort=-version:refname | head -10', {
cwd: gitRoot,
encoding: 'utf-8',
shell: true,
stdio: ['pipe', 'pipe', 'pipe']
}).trim();
console.error(allTags || ' (未找到 tags)');
} catch {
console.error(' (无法列出 tags)');
}
console.error('');
console.error(`请创建正确前缀的 tag:`);
console.error(` git tag -a ${projectPrefix}-${version} -m "发布说明"`);
console.error(` git push origin ${projectPrefix}-${version}`);
process.exit(1);
}
} else {
// Infer project name from directory and tags
projectPrefix = inferProjectName(gitRoot, androidDir);
if (projectPrefix === null) {
console.error('错误:无法从 git tags 推断项目名称');
console.error('请确保至少存在一个以下格式的 tag:');
console.error(' myapp-1.0.0 或 v1.0.0');
console.error('');
console.error('或使用 --project 明确指定项目名称:');
console.error(' node release-android.mjs --project admin-tool');
process.exit(1);
}
console.log(`项目前缀: ${projectPrefix || '(v-style)'} (自动检测)`);
}
const tagName = getLatestTag(gitRoot, projectPrefix);
if (!tagName) {
const expectedTag = projectPrefix ? `${projectPrefix}-${version}` : `v${version}`;
console.error(`错误:找不到匹配的 git tag`);
console.error(`期望的 tag 格式: ${expectedTag}`);
console.error('');
console.error('请先创建 tag:');
console.error(` git tag -a ${expectedTag} -m "发布说明"`);
console.error(` git push origin ${expectedTag}`);
process.exit(1);
}
const tagInfo = getTagInfo(tagName, gitRoot);
if (!tagInfo.exists) {
console.error(`错误Git tag "${tagName}" 不存在`);
console.error('请推送 tag:');
console.error(` git push origin ${tagName}`);
process.exit(1);
}
console.log(`Tag: ${tagName}`);
// 6. Detect Java
const javaHome = detectJavaHome();
if (!javaHome) {
console.error('错误:找不到 Java 运行环境');
console.error('请安装 JDK 或确保 Android Studio 已正确安装');
process.exit(1);
}
console.log(`Java: ${javaHome}`);
console.log('');
console.log('开始构建...');
// 7. Build APK
try {
buildApk(androidDir, javaHome);
} catch (err) {
console.error('构建失败:', err.message);
process.exit(1);
}
// 8. Find APK
const apk = findApk(androidDir);
if (!apk) {
console.error('错误:找不到 APK 文件');
console.error('检查构建输出: app/build/outputs/apk/release/');
process.exit(1);
}
console.log(`APK: ${apk.path} (${apk.signed ? '已签名' : '未签名'})`);
// 9. Upload to Gitea
const fileName = projectPrefix
? `${projectPrefix}-${version}.apk`
: `${GITEA_REPO}-${version}.apk`;
console.log('');
console.log('上传到 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('发布成功!');
console.log(`文件: ${fileName}`);
console.log(`大小: ${(result.fileSize / 1024 / 1024).toFixed(2)} MB`);
console.log(`URL: ${result.releaseUrl}`);
console.log('='.repeat(60));
} catch (err) {
console.error('上传失败:', err.message);
process.exit(1);
}
}
main();