Publish a signed Play AAB from Deploy Prod.
Unit Tests / test (pull_request) Successful in 14s

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:
westfarn committed 2026-10-19 10:26:36 -05:00
1 parent 36341f6e72
commit 0d233b077c
8 files changed
+542 -18

No files matched your search

+142
View File
@@ -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);
});
}
+113
View File
@@ -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)}`);
}