From 0d233b077c6cae88be444983d5c662a14cf46865 Mon Sep 17 00:00:00 2001 From: Ryan Westfall Date: Mon, 19 Oct 2026 10:26:36 -0500 Subject: [PATCH] Publish a signed Play AAB from Deploy Prod. Closes #121. Supersedes the separate Deploy Android workflow in #58 by attaching hesychia--.aab to an android-v* Gitea release after the prod web deploy. --- .gitea/workflows/deploy-prod.yml | 58 +++++++ llm-fe/ANDROID.md | 57 ++++++- llm-fe/android/README.md | 17 +- llm-fe/android/app/build.gradle | 18 ++- llm-fe/package.json | 3 +- llm-fe/scripts/android-version.mjs | 142 ++++++++++++++++ llm-fe/scripts/android-version.test.mjs | 113 +++++++++++++ .../scripts/publish-android-gitea-release.mjs | 152 ++++++++++++++++++ 8 files changed, 542 insertions(+), 18 deletions(-) create mode 100644 llm-fe/scripts/android-version.mjs create mode 100644 llm-fe/scripts/android-version.test.mjs create mode 100644 llm-fe/scripts/publish-android-gitea-release.mjs diff --git a/.gitea/workflows/deploy-prod.yml b/.gitea/workflows/deploy-prod.yml index 82d96cf..3210c15 100644 --- a/.gitea/workflows/deploy-prod.yml +++ b/.gitea/workflows/deploy-prod.yml @@ -36,3 +36,61 @@ jobs: --app chat_web_app \ --env prod \ --ref "${{ gitea.sha }}" + + # Same commit the workflow was started from (the branch picked in Run workflow). + android-release: + needs: deploy-prod + runs-on: self-hosted + permissions: + contents: write + defaults: + run: + working-directory: llm-fe + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ gitea.sha }} + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install dependencies + run: npm ci + + - name: Resolve Play version + env: + GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITEA_SHA: ${{ gitea.sha }} + run: node scripts/android-version.mjs + + - name: Build store web bundle + run: npm run build:android:store + + - name: Sign with the Play upload key + run: | + set -euo pipefail + test -f /home/westfarn/.android/hesychia-keystore.properties + test -f /home/westfarn/.android/hesychia-upload.jks + test -d /home/westfarn/Android/Sdk + ln -sfn /home/westfarn/.android/hesychia-keystore.properties android/keystore.properties + printf 'sdk.dir=%s\n' /home/westfarn/Android/Sdk > android/local.properties + + - name: Package signed app bundle + run: | + set -euo pipefail + cd android + chmod +x ./gradlew + ./gradlew bundleRelease \ + -PhesychiaVersionCode="$HESYCHIA_VERSION_CODE" \ + -PhesychiaVersionName="$HESYCHIA_VERSION_NAME" + jarsigner -verify app/build/outputs/bundle/release/app-release.aab >/tmp/jarsigner-aab.txt 2>&1 + echo "jar verified" + + - name: Publish Gitea release + env: + GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITEA_SHA: ${{ gitea.sha }} + GITEA_REF_NAME: ${{ gitea.ref_name }} + run: node scripts/publish-android-gitea-release.mjs diff --git a/llm-fe/ANDROID.md b/llm-fe/ANDROID.md index 8b20ea2..ea308d9 100644 --- a/llm-fe/ANDROID.md +++ b/llm-fe/ANDROID.md @@ -50,12 +50,37 @@ After plugin changes: `npm run build:mobile` (runs `cap sync`). ## Versioning -In `android/app/build.gradle`: +`android/app/build.gradle` keeps a floor: -- **versionName** — user-visible string; keep aligned with `package.json` `version` (currently `0.1.0`) -- **versionCode** — integer; **must increase** for every Play Store upload +- `hesychiaVersionNameFloor` — `"0.1.0"` (user-visible string) +- `hesychiaVersionCodeFloor` — `1` (integer; Play requires a higher code on every upload) -Bump both before each store release. +Local `./gradlew bundleRelease` uses that floor when you do not pass properties. **Deploy Prod** does not edit the file. After the web deploy it looks up existing `android-v*` Gitea releases, reuses the pair when the tag already points at this commit, otherwise ships the next patch and the next `versionCode` via `-PhesychiaVersionName` / `-PhesychiaVersionCode`. + +Raise the floor in git only when you want the next automatic release to jump ahead of older tags. + +## Deploy Prod publishes the Play bundle + +**Deploy Prod** (`.gitea/workflows/deploy-prod.yml`) runs `android-release` after the prod web deploy of the same commit. The job: + +1. Resolves the Play version (`llm-fe/scripts/android-version.mjs`) +2. `npm run build:android:store` — `.env.mobile` (prod API) plus `cap sync android` +3. Signs with the upload keystore on the runner (`~/.android/`, not in git) +4. `jarsigner -verify` on `app-release.aab` +5. Creates or reuses a Gitea release tagged `android-v-` and attaches `hesychia--.aab` + +Example of the same shape on RealPath: [android-v1.0.2-3](https://git.aimloperations.com/Ditch_The_Agent/dta_webapp/releases/tag/android-v1.0.2-3). + +Download that AAB from the Gitea release and upload it to the Play **internal** track. Deploy Prod does not call the Play Developer API. A separate **Deploy Android** workflow is not used ([#58](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/58) is superseded by this job, [#121](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/121)). + +Manual store binary (same web bundle the job builds): + +```bash +cd llm-fe +npm run build:android:store +cd android +./gradlew bundleRelease +``` ## Icons / splash @@ -76,23 +101,39 @@ Same command also regenerates iOS `Assets.xcassets` (see [`IOS.md`](IOS.md)). ## Signing (upload keystore) -**Never commit keystores.** Store outside the repo (e.g. host secrets / password manager). +**Never commit keystores or `keystore.properties`.** Passwords stay on the runner, not in Gitea Actions secrets. + +Deploy Prod expects these host files (separate from RealPath’s `keystore.properties`, so the two apps do not share one symlink target): + +```text +/home/westfarn/.android/hesychia-upload.jks +/home/westfarn/.android/hesychia-keystore.properties # mode 600 +``` + +Create the upload key **once**. If Play already has an upload key for `ai.hesychia.chat`, reuse that keystore. A second keystore will not upload. ```bash -keytool -genkey -v -keystore ~/Documents/secrets/aiml-chat-upload.jks \ +keytool -genkey -v \ + -keystore /home/westfarn/.android/hesychia-upload.jks \ -keyalg RSA -keysize 2048 -validity 10000 \ -alias aiml-chat-upload ``` -Create `llm-fe/android/keystore.properties` (gitignored): +`hesychia-keystore.properties` (`storeFile` may be absolute): ```properties -storeFile=/absolute/path/to/aiml-chat-upload.jks +storeFile=/home/westfarn/.android/hesychia-upload.jks storePassword=... keyAlias=aiml-chat-upload keyPassword=... ``` +The workflow symlinks that file to `llm-fe/android/keystore.properties` and writes `android/local.properties` with `sdk.dir=/home/westfarn/Android/Sdk`. For a local release build, put the same `keystore.properties` in `llm-fe/android/` (gitignored). + +Also on the runner: JDK 17+ and Android SDK Platform 35 at `/home/westfarn/Android/Sdk`. + +Gitea Actions secret on `chat_web_app`: `GITHUB_TOKEN` (repo release write). The workflow maps it to `GITEA_TOKEN` for version lookup and release upload. Purchase verification uses backend `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON`, not this keystore. + Release App Bundle: ```bash diff --git a/llm-fe/android/README.md b/llm-fe/android/README.md index 9fc126d..3a05767 100644 --- a/llm-fe/android/README.md +++ b/llm-fe/android/README.md @@ -19,7 +19,8 @@ From the **npm root** (`llm-fe/`, one level up): ```bash cd .. npm ci -npm run build:mobile # .env.mobile → react-scripts build → cap sync android +npm run build:android:store # Play binary: .env.mobile → build → cap sync android +# npm run build:mobile # same web env, also syncs iOS ``` Open this project in Android Studio: @@ -54,11 +55,11 @@ adb install -r app/build/outputs/apk/debug/app-debug.apk ## Version before a store release -Edit `app/build.gradle` → `defaultConfig`: +`app/build.gradle` stores the floor (`hesychiaVersionNameFloor` / `hesychiaVersionCodeFloor`). A local `bundleRelease` uses that floor. **Deploy Prod** passes the next pair with `-PhesychiaVersionName` and `-PhesychiaVersionCode` after reading `android-v*` Gitea releases, so a prod run does not need a version commit. | Field | Rule | |-------|------| -| `versionName` | User-visible string; keep in sync with `../package.json` `version` | +| `versionName` | User-visible `major.minor.patch` | | `versionCode` | Integer; **must increase** for every Play upload | ## Sign a release build @@ -68,15 +69,17 @@ Edit `app/build.gradle` → `defaultConfig`: 1. Create an upload keystore (once), outside the repo: ```bash -keytool -genkey -v -keystore ~/Documents/secrets/aiml-chat-upload.jks \ +keytool -genkey -v -keystore /home/westfarn/.android/hesychia-upload.jks \ -keyalg RSA -keysize 2048 -validity 10000 \ -alias aiml-chat-upload ``` -2. Create `keystore.properties` in **this** directory (`android/`, gitignored): +Reuse an existing Play upload key. Do not generate a second keystore for an app that already has one. + +2. Create `keystore.properties` in **this** directory (`android/`, gitignored). On the Deploy Prod runner the file is `/home/westfarn/.android/hesychia-keystore.properties` (not RealPath’s `keystore.properties`): ```properties -storeFile=/absolute/path/to/aiml-chat-upload.jks +storeFile=/home/westfarn/.android/hesychia-upload.jks storePassword=REPLACE_ME keyAlias=aiml-chat-upload keyPassword=REPLACE_ME @@ -105,6 +108,8 @@ Without `keystore.properties`, release builds are unsigned; debug builds do not ## Deploy to Google Play +**Deploy Prod** builds the signed AAB and attaches it to a Gitea release (`android-v-`, asset `hesychia--.aab`). Download that asset and upload it yourself. See [`../ANDROID.md`](../ANDROID.md). + 1. [Play Console](https://play.google.com/console) → app **Hesychia** (`ai.hesychia.chat`). 2. Complete store listing: privacy policy URL, data-safety form, screenshots. 3. Set **Account deletion URL** to the public instructions page (no sign-in required): diff --git a/llm-fe/android/app/build.gradle b/llm-fe/android/app/build.gradle index d1bef84..f69b0ef 100644 --- a/llm-fe/android/app/build.gradle +++ b/llm-fe/android/app/build.gradle @@ -1,5 +1,17 @@ apply plugin: 'com.android.application' +// Last bundle recorded in git. Prod deploy passes -PhesychiaVersionCode and +// -PhesychiaVersionName; these stay the floor when those properties are absent. +def hesychiaVersionCodeFloor = 1 +def hesychiaVersionNameFloor = "0.1.0" + +// Resolve before the android block. A parenthesized expression on the versionCode +// setter is parsed as a cast of the setter result, so AGP throws "Value is null". +def hesychiaVersionCodeProp = project.findProperty('hesychiaVersionCode')?.toString()?.trim() +def hesychiaVersionNameProp = project.findProperty('hesychiaVersionName')?.toString()?.trim() +def resolvedVersionCode = hesychiaVersionCodeProp ? hesychiaVersionCodeProp.toInteger() : hesychiaVersionCodeFloor +def resolvedVersionName = hesychiaVersionNameProp ? hesychiaVersionNameProp : hesychiaVersionNameFloor + def keystorePropertiesFile = rootProject.file('keystore.properties') def keystoreProperties = new Properties() if (keystorePropertiesFile.exists()) { @@ -13,8 +25,8 @@ android { applicationId "ai.hesychia.chat" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 1 - versionName "0.1.0" + versionCode resolvedVersionCode + versionName resolvedVersionName testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. @@ -27,7 +39,7 @@ android { if (keystorePropertiesFile.exists()) { keyAlias keystoreProperties['keyAlias'] keyPassword keystoreProperties['keyPassword'] - storeFile file(keystoreProperties['storeFile']) + storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null storePassword keystoreProperties['storePassword'] } } diff --git a/llm-fe/package.json b/llm-fe/package.json index 079e545..bab7383 100644 --- a/llm-fe/package.json +++ b/llm-fe/package.json @@ -58,13 +58,14 @@ "build:prod": "node scripts/writeSitemap.js && NODE_ENV=production react-scripts build && node scripts/stampCanonicals.js && mkdir -p /var/www/prod.chat.aimloperations/html && cp -r ./build/* /var/www/prod.chat.aimloperations/html/ && node scripts/pingIndexNow.js", "build:beta": "node scripts/writeSitemap.js && bash -c 'set -a; source .env.beta; set +a; NODE_ENV=production react-scripts build' && node scripts/stampCanonicals.js && mkdir -p /var/www/beta.chat.aimloperations/html && cp -r ./build/* /var/www/beta.chat.aimloperations/html/", "build:mobile": "bash -c 'set -a; source .env.mobile; set +a; NODE_ENV=production react-scripts build' && npx cap sync", + "build:android:store": "bash -c 'set -a; source .env.mobile; set +a; NODE_ENV=production react-scripts build' && npx cap sync android", "android:open": "npx cap open android", "android:sync": "npx cap sync android", "ios:open": "npx cap open ios", "ios:sync": "npx cap sync ios", "assets:generate": "npx capacitor-assets generate --ios --android", "test": "NODE_ENV=development react-scripts test", - "test:ci": "CI=true NODE_ENV=development react-scripts test --watchAll=false --coverage=false && node --test scripts/stampCanonicals.test.js scripts/sitemap.test.js", + "test:ci": "CI=true NODE_ENV=development react-scripts test --watchAll=false --coverage=false && node --test scripts/stampCanonicals.test.js scripts/sitemap.test.js scripts/android-version.test.mjs", "eject": "react-scripts eject" }, "eslintConfig": { diff --git a/llm-fe/scripts/android-version.mjs b/llm-fe/scripts/android-version.mjs new file mode 100644 index 0000000..dca61a9 --- /dev/null +++ b/llm-fe/scripts/android-version.mjs @@ -0,0 +1,142 @@ +import { appendFileSync, readFileSync, realpathSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const ANDROID_TAG = /^android-v(\d+\.\d+\.\d+)-(\d+)$/; + +export function parseAndroidTag(tag) { + const match = ANDROID_TAG.exec(tag || ''); + if (!match) return null; + return { versionName: match[1], versionCode: Number(match[2]) }; +} + +export function bumpPatch(versionName) { + const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(versionName); + if (!match) { + throw new Error(`versionName must be major.minor.patch, got ${versionName}`); + } + return `${match[1]}.${match[2]}.${Number(match[3]) + 1}`; +} + +export function readBaseline(gradle) { + const versionCode = Number(gradle.match(/hesychiaVersionCodeFloor\s*=\s*(\d+)/)?.[1]); + const versionName = gradle.match(/hesychiaVersionNameFloor\s*=\s*"([^"]+)"/)?.[1]; + if (!Number.isInteger(versionCode) || !versionName) { + throw new Error( + 'Could not read hesychiaVersionCodeFloor/hesychiaVersionNameFloor from build.gradle', + ); + } + return { versionCode, versionName }; +} + +export function nextAndroidVersion({ baseline, releases, sha }) { + const existing = releases.find((release) => release.sha === sha); + if (existing) { + return { + versionName: existing.versionName, + versionCode: existing.versionCode, + reused: true, + }; + } + + const highest = releases.reduce((best, release) => { + if (!best || release.versionCode > best.versionCode) return release; + return best; + }, null); + + const codeBase = Math.max(baseline.versionCode, highest?.versionCode ?? baseline.versionCode); + const nameBase = + highest && highest.versionCode >= baseline.versionCode + ? highest.versionName + : baseline.versionName; + + return { + versionName: bumpPatch(nameBase), + versionCode: codeBase + 1, + reused: false, + }; +} + +export async function listAndroidReleases({ apiUrl, token, owner, repo, fetchImpl = fetch }) { + const releases = []; + for (let page = 1; page <= 20; page += 1) { + const response = await fetchImpl( + `${apiUrl}/repos/${owner}/${repo}/releases?limit=50&page=${page}`, + { + headers: { + Authorization: `token ${token}`, + Accept: 'application/json', + }, + }, + ); + if (!response.ok) { + throw new Error(`List releases failed (${response.status})`); + } + const payload = await response.json(); + const batch = Array.isArray(payload) ? payload : []; + for (const release of batch) { + const parsed = parseAndroidTag(release.tag_name); + if (!parsed) continue; + releases.push({ ...parsed, sha: release.target_commitish }); + } + if (batch.length < 50) break; + } + return releases; +} + +function releaseEnv() { + const token = process.env.GITEA_TOKEN || process.env.GITHUB_TOKEN; + const repository = process.env.GITHUB_REPOSITORY || process.env.GITEA_REPOSITORY; + const sha = process.env.GITEA_SHA || process.env.GITHUB_SHA; + const apiUrl = ( + process.env.GITHUB_API_URL || + process.env.GITEA_API_URL || + (process.env.GITHUB_SERVER_URL + ? `${process.env.GITHUB_SERVER_URL.replace(/\/$/, '')}/api/v1` + : '') + ).replace(/\/$/, ''); + + if (!token || !repository || !sha || !apiUrl) { + throw new Error('Missing Gitea token, repository, sha, or API URL for the Play version lookup'); + } + const [owner, repo] = repository.split('/'); + if (!owner || !repo) { + throw new Error(`Repository must be owner/repo, got ${repository}`); + } + return { token, owner, repo, sha, apiUrl }; +} + +export async function resolveAndroidVersion() { + const root = join(dirname(fileURLToPath(import.meta.url)), '..'); + const gradle = readFileSync(join(root, 'android', 'app', 'build.gradle'), 'utf8'); + const baseline = readBaseline(gradle); + const { token, owner, repo, sha, apiUrl } = releaseEnv(); + const releases = await listAndroidReleases({ apiUrl, token, owner, repo }); + return nextAndroidVersion({ baseline, releases, sha }); +} + +function isMainModule() { + const entry = process.argv[1]; + if (!entry) return false; + return import.meta.url === pathToFileURL(realpathSync(entry)).href; +} + +if (isMainModule()) { + resolveAndroidVersion() + .then((next) => { + const lines = [ + `HESYCHIA_VERSION_NAME=${next.versionName}`, + `HESYCHIA_VERSION_CODE=${next.versionCode}`, + ]; + if (process.env.GITHUB_ENV) { + appendFileSync(process.env.GITHUB_ENV, `${lines.join('\n')}\n`); + } + console.log(lines.join('\n')); + const action = next.reused ? 'Reusing' : 'Next Play version is'; + console.error(`${action} ${next.versionName} (${next.versionCode})`); + }) + .catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); + }); +} diff --git a/llm-fe/scripts/android-version.test.mjs b/llm-fe/scripts/android-version.test.mjs new file mode 100644 index 0000000..2243db9 --- /dev/null +++ b/llm-fe/scripts/android-version.test.mjs @@ -0,0 +1,113 @@ +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { + bumpPatch, + listAndroidReleases, + nextAndroidVersion, + parseAndroidTag, + readBaseline, +} from './android-version.mjs'; + +const gradle = readFileSync( + join(dirname(fileURLToPath(import.meta.url)), '../android/app/build.gradle'), + 'utf8', +); +const floor = { versionName: '0.1.0', versionCode: 1 }; + +describe('android version', () => { + it('reads the floor still written in build.gradle', () => { + assert.deepEqual(readBaseline(gradle), floor); + }); + + it('assigns versionCode from a resolved int, not a parenthesized cast', () => { + assert.match(gradle, /versionCode resolvedVersionCode/); + assert.doesNotMatch(gradle, /versionCode\s*\(/); + }); + + it('parses Play release tags and ignores other tags', () => { + assert.deepEqual(parseAndroidTag('android-v1.0.2-3'), { + versionName: '1.0.2', + versionCode: 3, + }); + assert.equal(parseAndroidTag('web-v1.0.2'), null); + }); + + it('bumps the patch', () => { + assert.equal(bumpPatch('0.1.0'), '0.1.1'); + assert.equal(bumpPatch('1.0.9'), '1.0.10'); + }); + + it('steps past the floor when nothing has been released yet', () => { + assert.deepEqual( + nextAndroidVersion({ baseline: floor, releases: [], sha: 'abc' }), + { + versionName: '0.1.1', + versionCode: 2, + reused: false, + }, + ); + }); + + it('reuses the release that already points at this commit', () => { + assert.deepEqual( + nextAndroidVersion({ + baseline: floor, + sha: 'abc', + releases: [{ versionName: '0.1.1', versionCode: 2, sha: 'abc' }], + }), + { versionName: '0.1.1', versionCode: 2, reused: true }, + ); + }); + + it('bumps from the highest release rather than the gradle floor', () => { + assert.deepEqual( + nextAndroidVersion({ + baseline: floor, + sha: 'def', + releases: [ + { versionName: '0.1.1', versionCode: 2, sha: 'abc' }, + { versionName: '0.1.4', versionCode: 5, sha: 'old' }, + ], + }), + { versionName: '0.1.5', versionCode: 6, reused: false }, + ); + }); + + it('keeps a hand-raised floor ahead of older releases', () => { + assert.deepEqual( + nextAndroidVersion({ + baseline: { versionName: '1.1.0', versionCode: 10 }, + sha: 'def', + releases: [{ versionName: '0.1.4', versionCode: 5, sha: 'old' }], + }), + { versionName: '1.1.1', versionCode: 11, reused: false }, + ); + }); + + it('lists only android release tags', async () => { + const fetchImpl = async () => ({ + ok: true, + status: 200, + json: async () => [ + { tag_name: 'android-v0.1.1-2', target_commitish: 'abc' }, + { tag_name: 'v0.1.1', target_commitish: 'nope' }, + ], + }); + await assert.doesNotReject( + listAndroidReleases({ + apiUrl: 'https://git.example/api/v1', + token: 'token', + owner: 'ai_ml_operations', + repo: 'chat_web_app', + fetchImpl, + }).then((releases) => { + assert.deepEqual(releases, [ + { versionName: '0.1.1', versionCode: 2, sha: 'abc' }, + ]); + }), + ); + }); +}); diff --git a/llm-fe/scripts/publish-android-gitea-release.mjs b/llm-fe/scripts/publish-android-gitea-release.mjs new file mode 100644 index 0000000..152c241 --- /dev/null +++ b/llm-fe/scripts/publish-android-gitea-release.mjs @@ -0,0 +1,152 @@ +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const gradlePath = join(root, 'android', 'app', 'build.gradle'); +const aabPath = join(root, 'android', 'app', 'build', 'outputs', 'bundle', 'release', 'app-release.aab'); + +const gradle = readFileSync(gradlePath, 'utf8'); +const versionCode = + process.env.HESYCHIA_VERSION_CODE || + gradle.match(/hesychiaVersionCodeFloor\s*=\s*(\d+)/)?.[1]; +const versionName = + process.env.HESYCHIA_VERSION_NAME || + gradle.match(/hesychiaVersionNameFloor\s*=\s*"([^"]+)"/)?.[1]; +if (!versionCode || !versionName) { + console.error( + `Could not read versionCode/versionName from the environment or ${gradlePath}`, + ); + process.exit(1); +} + +const tag = `android-v${versionName}-${versionCode}`; +const assetName = `hesychia-${versionName}-${versionCode}.aab`; + +const token = process.env.GITEA_TOKEN || process.env.GITHUB_TOKEN; +const repository = process.env.GITHUB_REPOSITORY || process.env.GITEA_REPOSITORY; +const sha = process.env.GITEA_SHA || process.env.GITHUB_SHA; +const refName = process.env.GITEA_REF_NAME || process.env.GITHUB_REF_NAME || sha; +const apiUrl = ( + process.env.GITHUB_API_URL || + process.env.GITEA_API_URL || + (process.env.GITHUB_SERVER_URL + ? `${process.env.GITHUB_SERVER_URL.replace(/\/$/, '')}/api/v1` + : '') +).replace(/\/$/, ''); + +if (!token || !repository || !sha || !apiUrl) { + console.error('Missing required env for Gitea Android release:', { + apiUrl: Boolean(apiUrl), + token: Boolean(token), + repository, + sha, + }); + process.exit(1); +} + +const [owner, repo] = repository.split('/'); +if (!owner || !repo) { + console.error(`GITHUB_REPOSITORY must be owner/repo, got ${repository}`); + process.exit(1); +} + +async function api(path, { method = 'GET', json, form } = {}) { + const headers = { + Authorization: `token ${token}`, + Accept: 'application/json', + }; + let body; + if (json !== undefined) { + headers['Content-Type'] = 'application/json'; + body = JSON.stringify(json); + } else if (form) { + body = form; + } + + const response = await fetch(`${apiUrl}/repos/${owner}/${repo}${path}`, { + method, + headers, + body, + }); + const text = await response.text(); + let payload = null; + if (text) { + try { + payload = JSON.parse(text); + } catch { + payload = text; + } + } + return { status: response.status, payload }; +} + +function releaseUrl(release) { + return release?.html_url || release?.url || tag; +} + +function hasAsset(release) { + return Array.isArray(release?.assets) && release.assets.some((asset) => asset.name === assetName); +} + +async function uploadAsset(releaseId) { + const bytes = readFileSync(aabPath); + const form = new FormData(); + form.append('attachment', new Blob([bytes]), assetName); + const { status, payload } = await api( + `/releases/${releaseId}/assets?name=${encodeURIComponent(assetName)}`, + { method: 'POST', form }, + ); + if (status < 200 || status >= 300) { + throw new Error(`Asset upload failed (${status}): ${JSON.stringify(payload)}`); + } +} + +const body = [ + `Play store bundle for the prod deploy of \`${refName}\`.`, + '', + `- Commit: \`${sha}\``, + `- versionName: \`${versionName}\``, + `- versionCode: \`${versionCode}\``, + '- Application id: `ai.hesychia.chat`', + '', + `Upload \`${assetName}\` to Play Console.`, +].join('\n'); + +const existing = await api(`/releases/tags/${encodeURIComponent(tag)}`); +let release = existing.payload; + +if (existing.status === 404) { + const created = await api('/releases', { + method: 'POST', + json: { + tag_name: tag, + target_commitish: sha, + name: `Android ${versionName} (${versionCode})`, + body, + draft: false, + prerelease: false, + }, + }); + if (created.status < 200 || created.status >= 300) { + throw new Error(`Create release failed (${created.status}): ${JSON.stringify(created.payload)}`); + } + release = created.payload; + console.log(`Created ${tag} at ${releaseUrl(release)}`); +} else if (existing.status < 200 || existing.status >= 300) { + throw new Error(`Lookup ${tag} failed (${existing.status}): ${JSON.stringify(existing.payload)}`); +} else if (release.target_commitish !== sha) { + console.error( + `${tag} already points at ${release.target_commitish}, not ${sha}. Refusing to replace that Play version.`, + ); + process.exit(1); +} else { + console.log(`${tag} already exists for ${sha}`); +} + +if (hasAsset(release)) { + console.log(`${assetName} already attached: ${releaseUrl(release)}`); +} else { + await uploadAsset(release.id); + console.log(`Attached ${assetName} to ${releaseUrl(release)}`); +}