Files
NovaSonic/scripts/publish-apk.mjs
T
marcandClaude Opus 5 5f1650b82a build(apk): Publish-Skript reicht den App-Link-Host an Gradle durch
Der Host steht fest im Manifest und muss beim Bauen mitgegeben werden. Das
Publish-Skript rief Gradle bisher ohne ihn auf, ein so veroeffentlichtes APK
haette also den Platzhalter aus build.gradle.kts getragen und geteilte Links
waeren im Browser gelandet statt in der App. NOVASONIC_HOST (und optional
NOVASONIC_HOST2) kommen aus scripts/.env.release; fehlt der Wert, warnt das
Skript deutlich, statt still das Falsche zu bauen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 12:25:14 +02:00

144 lines
7.8 KiB
JavaScript

#!/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';
// Host fuer die App-Links geteilter Songs (https://<host>/?song=…). Steht fest im Manifest, muss
// also beim BAUEN mitgegeben werden — ohne ihn traegt das APK den Platzhalter aus build.gradle.kts
// und eingehende Links landen im Browser statt in der App. NOVASONIC_HOST2 optional fuer einen
// Domainwechsel (beide Adressen oeffnen dann in der App), siehe docs/share-links.md.
const HOST = process.env.NOVASONIC_HOST || '';
const HOST2 = process.env.NOVASONIC_HOST2 || '';
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} …`);
if (!HOST) {
console.warn('⚠ NOVASONIC_HOST ist nicht gesetzt — das APK bekommt den Platzhalter-Host aus');
console.warn(' build.gradle.kts, geteilte Links oeffnen dann NICHT in der App.');
console.warn(' In scripts/.env.release eintragen: NOVASONIC_HOST=music.deine-domain.de');
}
const gradleArgs = [`:app:${TASK}`];
if (HOST) gradleArgs.push(`-PnovasonicHost=${HOST}`);
if (HOST2) gradleArgs.push(`-PnovasonicHost2=${HOST2}`);
const build = spawnSync(gradlew, gradleArgs, {
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.`);