From 3000dc16222fd2f285947da5d5164169dc804e17 Mon Sep 17 00:00:00 2001 From: Honey Date: Tue, 25 Aug 2026 18:23:27 +0200 Subject: [PATCH] feat(scripts): one-command APK build + upload to the fixed Gitea release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- .gitignore | 1 + package.json | 3 +- scripts/publish-apk.mjs | 118 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 scripts/publish-apk.mjs diff --git a/.gitignore b/.gitignore index 6cfd8ed..e5498b8 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ *.log node_modules/ dist/ +scripts/.env.release diff --git a/package.json b/package.json index 1525efe..898d80c 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "dev": "vite", "build": "vite build", "preview": "vite preview", - "icons": "node scripts/gen-icons.mjs" + "icons": "node scripts/gen-icons.mjs", + "publish-apk": "node scripts/publish-apk.mjs" }, "devDependencies": { "sharp": "^0.34.5", diff --git a/scripts/publish-apk.mjs b/scripts/publish-apk.mjs new file mode 100644 index 0000000..6e9d2bd --- /dev/null +++ b/scripts/publish-apk.mjs @@ -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.`);