feat(scripts): one-command APK build + upload to the fixed Gitea release

`npm run publish-apk` builds the APK (assembleDebug by default — the release
build type is unsigned) and uploads it to the apk-latest Gitea release,
replacing the old .apk asset, so the web download button always serves the
latest build. Config via env or a gitignored scripts/.env.release
(GITEA_URL/REPO/TOKEN, APK_GRADLE_TASK). Release title tracks versionName.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Opus 4.8 committed 2026-08-25 18:23:27 +02:00
1 parent 33d24c6ed4
commit 3000dc1622
3 files changed
+121 -1

No files matched your search

+118
View File
@@ -0,0 +1,118 @@
#!/usr/bin/env node
// Build the NovaSonic Android APK and upload it to the fixed Gitea release ("apk-latest"), replacing
// the previous .apk asset — so the web "Android App" download button always serves the latest build.
//
// One command after your changes: npm run publish-apk
//
// Config (env, or a gitignored scripts/.env.release file with KEY=VALUE lines):
// GITEA_URL internal/LAN Gitea base reachable from THIS machine, e.g. http://192.168.1.10:3000
// GITEA_REPO owner/repo, e.g. Docker/NovaSonic
// GITEA_TOKEN Gitea token with write access to the repo (repo/releases)
// GITEA_APK_TAG fixed release tag (default: apk-latest)
// APK_GRADLE_TASK gradle task (default: assembleDebug — installable; release is unsigned here)
// APK_FILE override the built APK path if you use a custom task
//
// The debug build is used by default because the release build type has no signingConfig yet, so a
// release APK would be unsigned and refuse to install. Same dev machine ⇒ stable debug key ⇒ updates
// install over each other. Add a keystore + signingConfig later to switch APK_GRADLE_TASK=assembleRelease.
import { spawnSync } from 'node:child_process';
import { readFileSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join, resolve } from 'node:path';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
// ── tiny .env.release loader (never committed) ───────────────────────────────
const envFile = join(root, 'scripts', '.env.release');
if (existsSync(envFile)) {
for (const line of readFileSync(envFile, 'utf8').split('\n')) {
const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*)\s*$/i);
if (m && !(m[1] in process.env)) process.env[m[1]] = m[2].replace(/^["']|["']$/g, '');
}
}
const GITEA_URL = (process.env.GITEA_URL || '').replace(/\/$/, '');
const GITEA_REPO = process.env.GITEA_REPO || '';
const GITEA_TOKEN = process.env.GITEA_TOKEN || '';
const TAG = process.env.GITEA_APK_TAG || 'apk-latest';
const TASK = process.env.APK_GRADLE_TASK || 'assembleDebug';
function die(msg) { console.error(`\n✖ ${msg}`); process.exit(1); }
if (!GITEA_URL || !GITEA_REPO || !GITEA_TOKEN)
die('GITEA_URL, GITEA_REPO und GITEA_TOKEN müssen gesetzt sein (env oder scripts/.env.release).');
const API = `${GITEA_URL}/api/v1/repos/${GITEA_REPO}`;
const authHeaders = { Authorization: `token ${GITEA_TOKEN}` };
// ── version name from the Gradle config (for the release title) ──────────────
let version = new Date().toISOString().slice(0, 10);
try {
const g = readFileSync(join(root, 'android', 'app', 'build.gradle.kts'), 'utf8');
const m = g.match(/versionName\s*=\s*"([^"]+)"/);
if (m) version = m[1];
} catch {}
// ── 1) build ─────────────────────────────────────────────────────────────────
const isWin = process.platform === 'win32';
const gradlew = join(root, 'android', isWin ? 'gradlew.bat' : 'gradlew');
console.log(`▶ Baue APK: ${TASK} …`);
const build = spawnSync(gradlew, [`:app:${TASK}`], {
cwd: join(root, 'android'), stdio: 'inherit', shell: isWin,
});
if (build.status !== 0) die('Gradle-Build fehlgeschlagen.');
// derive the APK path from the task (assembleDebug → debug/app-debug.apk)
const variant = TASK.replace(/^assemble/, '').toLowerCase() || 'debug';
const apkPath = process.env.APK_FILE
|| join(root, 'android', 'app', 'build', 'outputs', 'apk', variant, `app-${variant}.apk`);
if (!existsSync(apkPath)) die(`APK nicht gefunden: ${apkPath} (APK_FILE setzen, falls Task abweicht).`);
const apkBytes = readFileSync(apkPath);
console.log(`✓ Gebaut: ${apkPath} (${(apkBytes.length / 1048576).toFixed(1)} MB)`);
// ── 2) get-or-create the fixed release ───────────────────────────────────────
async function api(path, opts = {}) {
const r = await fetch(`${API}${path}`, { ...opts, headers: { ...authHeaders, ...(opts.headers || {}) } });
return r;
}
let rel;
{
const r = await api(`/releases/tags/${encodeURIComponent(TAG)}`);
if (r.status === 200) {
rel = await r.json();
} else if (r.status === 404) {
console.log(`▶ Release "${TAG}" existiert nicht — lege sie an …`);
const cr = await api('/releases', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tag_name: TAG, target: 'main', name: `NovaSonic ${version}`, body: 'NovaSonic Android App — neueste Version.' }),
});
if (!cr.ok) die(`Release anlegen fehlgeschlagen: ${cr.status} ${await cr.text()}`);
rel = await cr.json();
} else {
die(`Release-Abfrage fehlgeschlagen: ${r.status} ${await r.text()}`);
}
}
// ── 3) delete old .apk assets, upload the new one ────────────────────────────
for (const a of rel.assets || []) {
if (/\.apk$/i.test(a.name)) {
const d = await api(`/releases/${rel.id}/assets/${a.id}`, { method: 'DELETE' });
console.log(d.ok ? `✓ Altes Asset gelöscht: ${a.name}` : `⚠ Löschen von ${a.name} fehlgeschlagen (${d.status})`);
}
}
const form = new FormData();
form.append('attachment', new Blob([apkBytes], { type: 'application/vnd.android.package-archive' }), 'NovaSonic.apk');
const up = await api(`/releases/${rel.id}/assets?name=NovaSonic.apk`, { method: 'POST', body: form });
if (!up.ok) die(`Upload fehlgeschlagen: ${up.status} ${await up.text()}`);
// keep the release title in sync with the version
await api(`/releases/${rel.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: `NovaSonic ${version}` }),
}).catch(() => {});
console.log(`\n✔ NovaSonic ${version} hochgeladen → Release "${TAG}". Der Web-Button serviert jetzt diese APK.`);