Closes #121. Supersedes the separate Deploy Android workflow in #58 by attaching hesychia-<version>-<code>.aab to an android-v* Gitea release after the prod web deploy.
This commit is contained in:
1 parent
36341f6e72
commit
0d233b077c
8 files changed
+542
-18
No files matched your search
@@ -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
|
||||
+49
-8
@@ -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<versionName>-<versionCode>` and attaches `hesychia-<versionName>-<versionCode>.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
|
||||
|
||||
@@ -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<versionName>-<versionCode>`, asset `hesychia-<versionName>-<versionCode>.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):
|
||||
|
||||
@@ -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']
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -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": {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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' },
|
||||
]);
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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)}`);
|
||||
}
|
||||
Reference in new issue
Block a user