#!/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, writeFileSync, 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}` }; // ── bump versionCode + read versionName from the Gradle config ─────────────── // The in-app updater compares versionCode, so every published build MUST increment it — we do that // here automatically (pass --version X.Y to also set the versionName). Commit the changed // build.gradle.kts afterwards so the repo reflects what's live. const gradlePath = join(root, 'android', 'app', 'build.gradle.kts'); let gradle = readFileSync(gradlePath, 'utf8'); const curCode = parseInt(gradle.match(/versionCode\s*=\s*(\d+)/)?.[1] || '0', 10); const versionCode = curCode + 1; const versionArg = process.argv.slice(2).find(a => a.startsWith('--version='))?.split('=')[1] || (process.argv.includes('--version') ? process.argv[process.argv.indexOf('--version') + 1] : null); let version = versionArg || gradle.match(/versionName\s*=\s*"([^"]+)"/)?.[1] || `0.${versionCode}`; gradle = gradle .replace(/versionCode\s*=\s*\d+/, `versionCode = ${versionCode}`) .replace(/versionName\s*=\s*"[^"]*"/, `versionName = "${version}"`); writeFileSync(gradlePath, gradle); console.log(`▶ versionCode → ${versionCode}, versionName → ${version} (build.gradle.kts danach committen!)`); const relBody = `versionCode=${versionCode}\nversionName=${version}`; // ── 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: relBody }), }); 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 + versionCode body in sync await api(`/releases/${rel.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: `NovaSonic ${version}`, body: relBody }), }).catch(() => {}); console.log(`\n✔ NovaSonic ${version} hochgeladen → Release "${TAG}". Der Web-Button serviert jetzt diese APK.`);