feat: ship portal and android release updates

This commit is contained in:
yoyuzh
2026-04-05 13:57:13 +08:00
parent 52b5bbfe8e
commit ed837f5ec9
46 changed files with 1507 additions and 189 deletions

View File

@@ -0,0 +1,112 @@
#!/usr/bin/env node
import fs from 'node:fs/promises';
import path from 'node:path';
import process from 'node:process';
import {spawnSync} from 'node:child_process';
import {fileURLToPath} from 'node:url';
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(scriptDir, '..');
const frontDir = path.join(repoRoot, 'front');
const androidDir = path.join(frontDir, 'android');
const capacitorPluginsGradlePath = path.join(androidDir, 'capacitor-cordova-android-plugins', 'build.gradle');
const capacitorAppGradlePath = path.join(frontDir, 'node_modules', '@capacitor', 'app', 'android', 'build.gradle');
const googleMirrorValue = 'https://maven.aliyun.com/repository/google';
function runCommand(command, args, cwd, extraEnv = {}) {
const result = spawnSync(command, args, {
cwd,
env: {
...process.env,
...extraEnv,
},
stdio: 'inherit',
shell: process.platform === 'win32',
});
if (result.status !== 0) {
throw new Error(`Command failed: ${command} ${args.join(' ')}`);
}
}
function createAndroidBuildVersion() {
const now = new Date();
const year = String(now.getFullYear()).slice(-2);
const startOfYear = new Date(now.getFullYear(), 0, 1);
const dayOfYear = String(Math.floor((now - startOfYear) / 86400000) + 1).padStart(3, '0');
const hour = String(now.getHours()).padStart(2, '0');
const minute = String(now.getMinutes()).padStart(2, '0');
return {
versionCode: `${year}${dayOfYear}${hour}${minute}`,
versionName: `${now.getFullYear()}.${String(now.getMonth() + 1).padStart(2, '0')}.${String(now.getDate()).padStart(2, '0')}.${hour}${minute}`,
};
}
async function ensureAndroidGoogleMirror() {
await Promise.all([
patchCapacitorPluginGradle(capacitorPluginsGradlePath),
patchCapacitorPluginGradle(capacitorAppGradlePath),
]);
}
async function patchCapacitorPluginGradle(gradlePath) {
try {
const original = await fs.readFile(gradlePath, 'utf-8');
let next = original;
if (!next.includes(`def googleMirror = '${googleMirrorValue}'`)) {
next = next.replace(
'buildscript {\n',
`buildscript {\n def googleMirror = '${googleMirrorValue}'\n`,
);
}
next = next.replace(
/buildscript \{\n\s+def googleMirror = 'https:\/\/maven\.aliyun\.com\/repository\/google'\n\s+repositories \{\n\s+google\(\)\n/,
`buildscript {\n def googleMirror = '${googleMirrorValue}'\n repositories {\n maven { url googleMirror }\n`,
);
next = next.replace(
/repositories \{\n\s+google\(\)\n\s+mavenCentral\(\)/g,
`repositories {\n maven { url '${googleMirrorValue}' }\n mavenCentral()`,
);
if (next !== original) {
await fs.writeFile(gradlePath, next, 'utf-8');
}
} catch (error) {
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
return;
}
throw error;
}
}
async function main() {
const buildVersion = createAndroidBuildVersion();
const buildEnv = {
YOYUZH_ANDROID_VERSION_CODE: buildVersion.versionCode,
YOYUZH_ANDROID_VERSION_NAME: buildVersion.versionName,
};
console.log(`Android versionCode=${buildVersion.versionCode}`);
console.log(`Android versionName=${buildVersion.versionName}`);
runCommand('npm', ['run', 'build'], frontDir, buildEnv);
runCommand('npx', ['cap', 'sync', 'android'], frontDir, buildEnv);
await ensureAndroidGoogleMirror();
const gradleCommand = process.platform === 'win32' ? 'gradlew.bat' : './gradlew';
runCommand(gradleCommand, ['assembleDebug'], androidDir, buildEnv);
runCommand('node', ['scripts/deploy-front-oss.mjs', '--skip-build'], repoRoot, buildEnv);
runCommand('node', ['scripts/deploy-android-release.mjs'], repoRoot, buildEnv);
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});

View File

@@ -0,0 +1,210 @@
#!/usr/bin/env node
import fs from 'node:fs/promises';
import path from 'node:path';
import process from 'node:process';
import {
buildObjectKey,
createAwsV4Headers,
encodeObjectKey,
getCacheControl,
normalizeEndpoint,
parseSimpleEnv,
requestDogeCloudTemporaryS3Session,
} from './oss-deploy-lib.mjs';
const repoRoot = process.cwd();
const envFilePath = path.join(repoRoot, '.env.oss.local');
const apkSourcePath = path.join(repoRoot, 'front', 'android', 'app', 'build', 'outputs', 'apk', 'debug', 'app-debug.apk');
function parseArgs(argv) {
return {
dryRun: argv.includes('--dry-run'),
};
}
async function loadEnvFileIfPresent() {
try {
const raw = await fs.readFile(envFilePath, 'utf-8');
const values = parseSimpleEnv(raw);
for (const [key, value] of Object.entries(values)) {
if (!process.env[key]) {
process.env[key] = value;
}
}
} catch (error) {
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
return;
}
throw error;
}
}
function requireEnv(name) {
const value = process.env[name];
if (!value) {
throw new Error(`Missing required environment variable: ${name}`);
}
return value;
}
function getAndroidReleaseVersion() {
const versionCode = requireEnv('YOYUZH_ANDROID_VERSION_CODE').trim();
const versionName = requireEnv('YOYUZH_ANDROID_VERSION_NAME').trim();
return {versionCode, versionName};
}
function getAndroidReleasePrefix() {
return (process.env.YOYUZH_ANDROID_RELEASE_PREFIX || 'android/releases').trim().replace(/^\/+|\/+$/g, '');
}
function getAndroidReleaseScope() {
return (process.env.YOYUZH_DOGECLOUD_ANDROID_SCOPE || process.env.YOYUZH_DOGECLOUD_STORAGE_SCOPE || '').trim();
}
function getAndroidReleaseApkObjectKey(versionName) {
const safeVersionName = versionName.replace(/[^0-9A-Za-z._-]/g, '-');
return buildObjectKey(getAndroidReleasePrefix(), `yoyuzh-portal-${safeVersionName}.apk`);
}
function getAndroidReleaseMetadataObjectKey() {
return buildObjectKey(getAndroidReleasePrefix(), 'latest.json');
}
function buildAndroidReleaseMetadata() {
const {versionCode, versionName} = getAndroidReleaseVersion();
const fileName = path.posix.basename(getAndroidReleaseApkObjectKey(versionName));
return {
versionCode,
versionName,
fileName,
objectKey: getAndroidReleaseApkObjectKey(versionName),
publishedAt: new Date().toISOString(),
};
}
async function uploadFile({
bucket,
endpoint,
region,
objectKey,
filePath,
contentType,
accessKeyId,
secretAccessKey,
sessionToken,
}) {
const body = await fs.readFile(filePath);
const amzDate = new Date().toISOString().replace(/[:-]|\.\d{3}/g, '');
const url = `https://${bucket}.${normalizeEndpoint(endpoint)}/${encodeObjectKey(objectKey)}`;
const signatureHeaders = createAwsV4Headers({
method: 'PUT',
endpoint,
region,
bucket,
objectKey,
headers: {
'Content-Type': contentType,
},
amzDate,
accessKeyId,
secretAccessKey,
sessionToken,
});
const response = await fetch(url, {
method: 'PUT',
headers: {
...signatureHeaders,
'Cache-Control': getCacheControl(objectKey),
'Content-Length': String(body.byteLength),
},
body,
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Upload failed for ${objectKey}: ${response.status} ${response.statusText}\n${text}`);
}
}
async function main() {
const {dryRun} = parseArgs(process.argv.slice(2));
await loadEnvFileIfPresent();
const androidScope = getAndroidReleaseScope();
if (!androidScope) {
throw new Error('Missing required environment variable: YOYUZH_DOGECLOUD_ANDROID_SCOPE or YOYUZH_DOGECLOUD_STORAGE_SCOPE');
}
await fs.access(apkSourcePath);
const apiAccessKey = requireEnv('YOYUZH_DOGECLOUD_API_ACCESS_KEY');
const apiSecretKey = requireEnv('YOYUZH_DOGECLOUD_API_SECRET_KEY');
const apiBaseUrl = process.env.YOYUZH_DOGECLOUD_API_BASE_URL || 'https://api.dogecloud.com';
const region = process.env.YOYUZH_DOGECLOUD_S3_REGION || 'automatic';
const ttlSeconds = Number(process.env.YOYUZH_DOGECLOUD_ANDROID_TTL_SECONDS || process.env.YOYUZH_DOGECLOUD_STORAGE_TTL_SECONDS || '3600');
const {
accessKeyId,
secretAccessKey,
sessionToken,
endpoint,
bucket,
} = await requestDogeCloudTemporaryS3Session({
apiBaseUrl,
accessKey: apiAccessKey,
secretKey: apiSecretKey,
scope: androidScope,
ttlSeconds,
});
const metadata = buildAndroidReleaseMetadata();
const tempMetadataPath = path.join(repoRoot, '.tmp-android-release.json');
await fs.writeFile(tempMetadataPath, JSON.stringify(metadata, null, 2) + '\n', 'utf-8');
try {
const uploads = [
{
objectKey: metadata.objectKey,
filePath: apkSourcePath,
contentType: 'application/vnd.android.package-archive',
},
{
objectKey: getAndroidReleaseMetadataObjectKey(),
filePath: tempMetadataPath,
contentType: 'application/json; charset=utf-8',
},
];
for (const upload of uploads) {
if (dryRun) {
console.log(`[dry-run] upload ${upload.filePath} -> ${upload.objectKey}`);
continue;
}
await uploadFile({
bucket,
endpoint,
region,
objectKey: upload.objectKey,
filePath: upload.filePath,
contentType: upload.contentType,
accessKeyId,
secretAccessKey,
sessionToken,
});
console.log(`uploaded ${upload.objectKey}`);
}
} finally {
await fs.rm(tempMetadataPath, {force: true});
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});

View File

@@ -23,8 +23,6 @@ const repoRoot = process.cwd();
const frontDir = path.join(repoRoot, 'front');
const distDir = path.join(frontDir, 'dist');
const envFilePath = path.join(repoRoot, '.env.oss.local');
const apkSourcePath = path.join(frontDir, 'android', 'app', 'build', 'outputs', 'apk', 'debug', 'app-debug.apk');
const apkObjectPath = 'downloads/yoyuzh-portal.apk';
function parseArgs(argv) {
return {
@@ -155,48 +153,6 @@ async function uploadSpaAliases({
}
}
async function uploadApkIfPresent({
bucket,
endpoint,
region,
accessKeyId,
secretAccessKey,
sessionToken,
remotePrefix,
dryRun,
}) {
try {
await fs.access(apkSourcePath);
} catch (error) {
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
console.warn(`skip apk upload: not found at ${apkSourcePath}`);
return;
}
throw error;
}
const objectKey = buildObjectKey(remotePrefix, apkObjectPath);
if (dryRun) {
console.log(`[dry-run] upload ${apkObjectPath} -> ${objectKey}`);
return;
}
await uploadFile({
bucket,
endpoint,
region,
objectKey,
filePath: apkSourcePath,
contentTypeOverride: 'application/vnd.android.package-archive',
accessKeyId,
secretAccessKey,
sessionToken,
});
console.log(`uploaded ${objectKey}`);
}
async function main() {
const {dryRun, skipBuild} = parseArgs(process.argv.slice(2));
@@ -265,17 +221,6 @@ async function main() {
remotePrefix,
dryRun,
});
await uploadApkIfPresent({
bucket,
endpoint,
region,
accessKeyId,
secretAccessKey,
sessionToken,
remotePrefix,
dryRun,
});
}
main().catch((error) => {