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); }); }