Files
self/app/scripts/mobile-deploy-confirm.cjs
Justin Hernandez 6b285c9bf3 chore: address TypeScript ESLint v8 upgrade feedback and improve type safety (#895)
* cr feedback

* update based on feedback

* typing updates

* unify yarn package version

* update lock
2025-08-14 09:19:34 -07:00

682 lines
20 KiB
JavaScript
Executable File

// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11
const fs = require('fs');
const path = require('path');
let { execSync } = require('child_process');
// Constants
const DEPLOYMENT_METHODS = {
GITHUB_RUNNER: 'github-runner',
LOCAL_FASTLANE: 'local-fastlane',
};
const PLATFORMS = {
IOS: 'ios',
ANDROID: 'android',
BOTH: 'both',
};
const SUPPORTED_PLATFORMS = Object.values(PLATFORMS);
const FILE_PATHS = {
PACKAGE_JSON: '../package.json',
VERSION_JSON: '../version.json',
IOS_INFO_PLIST: '../ios/OpenPassport/Info.plist',
IOS_PROJECT_PBXPROJ: '../ios/Self.xcodeproj/project.pbxproj',
ANDROID_BUILD_GRADLE: '../android/app/build.gradle',
};
const CONSOLE_SYMBOLS = {
MOBILE: '📱',
PACKAGE: '📦',
ROCKET: '🚀',
WARNING: '⚠️',
SUCCESS: '✅',
ERROR: '❌',
APPLE: '🍎',
ANDROID: '🤖',
CLOUD: '☁️',
LOCATION: '📍',
MEMO: '📝',
CHART: '📊',
BROOM: '🧹',
REPEAT: '🔄',
};
const REGEX_PATTERNS = {
IOS_VERSION:
/<key>CFBundleShortVersionString<\/key>\s*<string>(.*?)<\/string>/,
IOS_BUILD: /CURRENT_PROJECT_VERSION = (\d+);/,
ANDROID_VERSION: /versionName\s+"(.+?)"/,
ANDROID_VERSION_CODE: /versionCode\s+(\d+)/,
};
// Utility Functions
/**
* Safely reads a file and returns its content or null if failed
* @param {string} filePath - Path to the file to read
* @param {string} description - Description of the file for error messages
* @returns {string|null} File content or null if failed
*/
function safeReadFile(filePath, description) {
try {
return fs.readFileSync(filePath, 'utf8');
} catch (_error) {
console.warn(`Warning: Could not read ${description} at ${filePath}`);
return null;
}
}
/**
* Safely executes a command and returns its output
* @param {string} command - Command to execute
* @param {string} description - Description for error messages
* @returns {string|null} Command output or null if failed
*/
function safeExecSync(command, description) {
// Whitelist of allowed commands to prevent command injection
const allowedCommands = [
'git branch --show-current',
'git status --porcelain',
];
// Validate that the command is in the whitelist
if (!allowedCommands.includes(command)) {
console.warn(
`Warning: Command '${command}' is not allowed for security reasons`,
);
return null;
}
try {
return execSync(command, { encoding: 'utf8' }).trim();
} catch (_error) {
console.warn(`Warning: Could not ${description}`);
return null;
}
}
/**
* Validates the provided platform argument
* @param {string} platform - Platform argument to validate
* @returns {boolean} True if valid, false otherwise
*/
function validatePlatform(platform) {
return platform && SUPPORTED_PLATFORMS.includes(platform);
}
/**
* Displays usage information and exits
*/
function displayUsageAndExit() {
console.error('Usage: node mobile-deploy-confirm.cjs <ios|android|both>');
console.error('');
console.error('Recommended: Use yarn commands instead:');
console.error(
' yarn mobile-deploy # Deploy to both platforms (GitHub runner)',
);
console.error(
' yarn mobile-deploy:ios # Deploy to iOS only (GitHub runner)',
);
console.error(
' yarn mobile-deploy:android # Deploy to Android only (GitHub runner)',
);
console.error(
' yarn mobile-local-deploy # Deploy to both platforms (local fastlane)',
);
console.error(
' yarn mobile-local-deploy:ios # Deploy to iOS only (local fastlane)',
);
console.error(
' yarn mobile-local-deploy:android # Deploy to Android only (local fastlane)',
);
console.error('');
console.error('Direct script usage:');
console.error(' node mobile-deploy-confirm.cjs ios');
console.error(' node mobile-deploy-confirm.cjs android');
console.error(' node mobile-deploy-confirm.cjs both');
console.error('');
console.error('Environment Variables:');
console.error(
' FORCE_UPLOAD_LOCAL_DEV=true Use local fastlane instead of GitHub runner',
);
console.error(
' IOS_PROJECT_PBXPROJ_PATH Override iOS project.pbxproj path',
);
process.exit(1);
}
// Core Functions
/**
* Determines the deployment method based on environment variables
* @returns {'github-runner' | 'local-fastlane'} The deployment method to use
*/
function getDeploymentMethod() {
// Check if running in GitHub Actions
if (process.env.GITHUB_ACTIONS === 'true') {
return DEPLOYMENT_METHODS.GITHUB_RUNNER;
}
// Check if force upload is explicitly set for local development
if (process.env.FORCE_UPLOAD_LOCAL_DEV === 'true') {
return DEPLOYMENT_METHODS.LOCAL_FASTLANE;
}
// Default to GitHub runner (safer default)
// Users must explicitly set FORCE_UPLOAD_LOCAL_DEV=true to use local fastlane
return DEPLOYMENT_METHODS.GITHUB_RUNNER;
}
/**
* Reads the main version from package.json
* @returns {string} The main version number
*/
function getMainVersion() {
const packageJsonPath = path.join(__dirname, FILE_PATHS.PACKAGE_JSON);
try {
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
return packageJson.version || 'Unknown';
} catch (error) {
console.warn(`Warning: Could not parse package.json: ${error.message}`);
return 'Unknown';
}
}
/**
* Reads iOS version information from Info.plist and project.pbxproj
* @returns {Object} iOS version information
*/
function getIOSVersion() {
const infoPlistPath = path.join(__dirname, FILE_PATHS.IOS_INFO_PLIST);
const infoPlist = safeReadFile(infoPlistPath, 'iOS Info.plist');
if (!infoPlist) {
return { version: 'Unknown', build: 'Unknown' };
}
const iosVersionMatch = infoPlist.match(REGEX_PATTERNS.IOS_VERSION);
const version = iosVersionMatch ? iosVersionMatch[1] : 'Unknown';
// Extract build number from project.pbxproj
// Allow iOS project path to be overridden by environment variable
const iosProjectPath =
process.env.IOS_PROJECT_PBXPROJ_PATH || FILE_PATHS.IOS_PROJECT_PBXPROJ;
const projectPath = path.join(__dirname, iosProjectPath);
const projectFile = safeReadFile(projectPath, 'iOS project.pbxproj');
let build = 'Unknown';
if (projectFile) {
const buildMatch = projectFile.match(REGEX_PATTERNS.IOS_BUILD);
build = buildMatch ? buildMatch[1] : 'Unknown';
}
return { version, build };
}
/**
* Reads Android version information from build.gradle
* @returns {Object} Android version information
*/
function getAndroidVersion() {
const buildGradlePath = path.join(__dirname, FILE_PATHS.ANDROID_BUILD_GRADLE);
const buildGradle = safeReadFile(buildGradlePath, 'Android build.gradle');
if (!buildGradle) {
return { version: 'Unknown', versionCode: 'Unknown' };
}
const androidVersionMatch = buildGradle.match(REGEX_PATTERNS.ANDROID_VERSION);
const androidVersionCodeMatch = buildGradle.match(
REGEX_PATTERNS.ANDROID_VERSION_CODE,
);
return {
version: androidVersionMatch ? androidVersionMatch[1] : 'Unknown',
versionCode: androidVersionCodeMatch
? androidVersionCodeMatch[1]
: 'Unknown',
};
}
/**
* Reads version.json for build numbers and deployment history
* @returns {Object|null} Version data or null if not found
*/
function getVersionJsonData() {
const versionJsonPath = path.join(__dirname, FILE_PATHS.VERSION_JSON);
try {
const versionData = JSON.parse(fs.readFileSync(versionJsonPath, 'utf8'));
return versionData;
} catch (error) {
console.warn(`Warning: Could not read version.json: ${error.message}`);
return null;
}
}
/**
* Formats time elapsed since last deployment
* @param {string} timestamp - ISO timestamp of last deployment
* @returns {string} Human-readable time elapsed
*/
function getTimeAgo(timestamp) {
if (!timestamp) return 'Never deployed';
const now = new Date();
const then = new Date(timestamp);
const diffMs = now - then;
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffHours / 24);
if (diffDays > 0) {
return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`;
} else if (diffHours > 0) {
return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
} else {
return 'Less than an hour ago';
}
}
/**
* Reads version information from package.json, iOS Info.plist, and Android build.gradle
* @returns {Object} Object containing version information for all platforms
*/
function getCurrentVersions() {
const versionJson = getVersionJsonData();
return {
main: getMainVersion(),
ios: getIOSVersion(),
android: getAndroidVersion(),
versionJson: versionJson,
};
}
// Git Operations
/**
* Gets the current git branch name
* @returns {string|null} Current branch name or null if failed
*/
function getCurrentBranch() {
return safeExecSync(
'git branch --show-current',
'determine current git branch',
);
}
/**
* Checks if there are uncommitted changes
* @returns {boolean} True if there are uncommitted changes
*/
function hasUncommittedChanges() {
const gitStatus = safeExecSync('git status --porcelain', 'check git status');
return gitStatus && gitStatus.trim().length > 0;
}
// Display Functions
/**
* Displays the header and platform information
* @param {string} platform - Target platform
*/
function displayDeploymentHeader(platform) {
console.log(`\n${CONSOLE_SYMBOLS.MOBILE} Mobile App Deployment Confirmation`);
console.log('=====================================');
console.log(`${CONSOLE_SYMBOLS.ROCKET} Platform: ${platform.toUpperCase()}`);
}
/**
* Displays deployment method information
* @param {string} deploymentMethod - The deployment method to use
*/
function displayDeploymentMethod(deploymentMethod) {
if (deploymentMethod === DEPLOYMENT_METHODS.LOCAL_FASTLANE) {
console.log(
`${CONSOLE_SYMBOLS.LOCATION} Deployment: Local fastlane upload`,
);
} else {
console.log(`${CONSOLE_SYMBOLS.CLOUD} Deployment: GitHub Actions workflow`);
}
}
/**
* Displays platform-specific version information
* @param {string} platform - Target platform
* @param {Object} versions - Version information object
*/
function displayPlatformVersions(platform, versions) {
console.log(`${CONSOLE_SYMBOLS.PACKAGE} Main Version: ${versions.main}`);
if (platform === PLATFORMS.IOS || platform === PLATFORMS.BOTH) {
const currentBuild = versions.ios.build;
const nextBuild = versions.versionJson
? versions.versionJson.ios.build + 1
: parseInt(currentBuild, 10) + 1;
const lastDeployed = versions.versionJson
? getTimeAgo(versions.versionJson.ios.lastDeployed)
: 'Unknown';
console.log(
`${CONSOLE_SYMBOLS.APPLE} iOS Version: ${versions.ios.version}`,
);
console.log(
`${CONSOLE_SYMBOLS.APPLE} iOS Build: ${currentBuild}${nextBuild}`,
);
console.log(`${CONSOLE_SYMBOLS.APPLE} Last iOS Deploy: ${lastDeployed}`);
}
if (platform === PLATFORMS.ANDROID || platform === PLATFORMS.BOTH) {
const currentBuild = versions.android.versionCode;
const nextBuild = versions.versionJson
? versions.versionJson.android.build + 1
: parseInt(currentBuild, 10) + 1;
const lastDeployed = versions.versionJson
? getTimeAgo(versions.versionJson.android.lastDeployed)
: 'Unknown';
console.log(
`${CONSOLE_SYMBOLS.ANDROID} Android Version: ${versions.android.version}`,
);
console.log(
`${CONSOLE_SYMBOLS.ANDROID} Android Version Code: ${currentBuild}${nextBuild}`,
);
console.log(
`${CONSOLE_SYMBOLS.ANDROID} Last Android Deploy: ${lastDeployed}`,
);
}
// Check for potential issues
if (versions.versionJson) {
if (platform === PLATFORMS.IOS || platform === PLATFORMS.BOTH) {
const jsonBuild = versions.versionJson.ios.build;
const actualBuild = parseInt(versions.ios.build, 10);
if (jsonBuild !== actualBuild) {
console.log(
`\n${CONSOLE_SYMBOLS.WARNING} iOS build mismatch: version.json has ${jsonBuild}, but Xcode has ${actualBuild}`,
);
}
}
if (platform === PLATFORMS.ANDROID || platform === PLATFORMS.BOTH) {
const jsonBuild = versions.versionJson.android.build;
const actualBuild = parseInt(versions.android.versionCode, 10);
if (jsonBuild !== actualBuild) {
console.log(
`\n${CONSOLE_SYMBOLS.WARNING} Android build mismatch: version.json has ${jsonBuild}, but gradle has ${actualBuild}`,
);
}
}
}
}
/**
* Displays warnings and git status information
*/
function displayWarningsAndGitStatus() {
const currentBranch = getCurrentBranch();
const hasUncommitted = hasUncommittedChanges();
console.log(`\n${CONSOLE_SYMBOLS.WARNING} Important Notes:`);
console.log(
'• Deploys to internal testing (TestFlight/Google Play Internal)',
);
if (currentBranch) {
console.log(`• Current branch: ${currentBranch}`);
}
if (hasUncommitted) {
console.log('• You have uncommitted changes - consider committing first');
}
}
/**
* Displays all confirmation information
* @param {string} platform - Target platform
* @param {Object} versions - Version information object
* @param {string} deploymentMethod - The deployment method to use
*/
function displayFullConfirmation(platform, versions, deploymentMethod) {
displayDeploymentHeader(platform);
displayDeploymentMethod(deploymentMethod);
displayPlatformVersions(platform, versions);
displayWarningsAndGitStatus();
}
/**
* Prompts the user for confirmation
* @returns {Promise<boolean>} True if user confirms, false otherwise
*/
function promptConfirmation() {
const readline = require('readline').createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise(resolve => {
readline.question('\nDo you want to proceed? (y/N): ', answer => {
readline.close();
// Trim whitespace and normalize to lowercase for robust comparison
const normalizedAnswer = answer.trim().toLowerCase();
resolve(normalizedAnswer === 'y' || normalizedAnswer === 'yes');
});
});
}
// Deployment Functions
/**
* Performs yarn reinstall to ensure clean dependencies
*/
function performYarnReinstall() {
console.log(
`\n${CONSOLE_SYMBOLS.BROOM} Performing yarn reinstall to ensure clean dependencies...`,
);
execSync('yarn reinstall', {
stdio: 'inherit',
cwd: path.join(__dirname, '..'),
});
console.log(
`${CONSOLE_SYMBOLS.SUCCESS} Yarn reinstall completed successfully!`,
);
}
/**
* Gets the fastlane commands for the specified platform
* @param {string} platform - Target platform
* @returns {string[]} Array of fastlane commands to execute
*/
function getFastlaneCommands(platform) {
const commands = [];
if (platform === PLATFORMS.IOS || platform === PLATFORMS.BOTH) {
commands.push('cd .. && bundle exec fastlane ios internal_test');
}
if (platform === PLATFORMS.ANDROID || platform === PLATFORMS.BOTH) {
commands.push('cd .. && bundle exec fastlane android internal_test');
}
return commands;
}
/**
* Executes iOS build cleanup script
* @param {string} platform - Target platform
*/
let performIOSBuildCleanup = function (platform) {
// Only run cleanup for iOS deployments
if (platform !== PLATFORMS.IOS && platform !== PLATFORMS.BOTH) {
return;
}
console.log(`\n${CONSOLE_SYMBOLS.BROOM} Cleaning up iOS build artifacts...`);
try {
const cleanupScript = path.join(__dirname, 'cleanup-ios-build.sh');
execSync(`bash "${cleanupScript}"`, {
stdio: 'inherit',
cwd: __dirname,
});
console.log(
`${CONSOLE_SYMBOLS.SUCCESS} iOS build cleanup completed successfully!`,
);
} catch (error) {
console.error(
`${CONSOLE_SYMBOLS.WARNING} iOS build cleanup failed (non-fatal):`,
error.message,
);
// Don't exit on cleanup failure - it's not critical
}
};
/**
* Executes local fastlane deployment
* @param {string} platform - Target platform
*/
async function executeLocalFastlaneDeployment(platform) {
console.log(
`\n${CONSOLE_SYMBOLS.ROCKET} Starting local fastlane deployment...`,
);
let deploymentSuccessful = false;
try {
performYarnReinstall();
const commands = getFastlaneCommands(platform);
// Create environment with FORCE_UPLOAD_LOCAL_DEV set for child processes
const envWithForceUpload = {
...process.env,
FORCE_UPLOAD_LOCAL_DEV: 'true',
};
for (const command of commands) {
console.log(`\n${CONSOLE_SYMBOLS.REPEAT} Running: ${command}`);
execSync(command, {
stdio: 'inherit',
cwd: __dirname,
env: envWithForceUpload,
});
}
deploymentSuccessful = true;
console.log(
`${CONSOLE_SYMBOLS.SUCCESS} Local fastlane deployment completed successfully!`,
);
console.log(
`${CONSOLE_SYMBOLS.MOBILE} Check your app store dashboards for the new builds.`,
);
} catch (error) {
console.error(
`${CONSOLE_SYMBOLS.ERROR} Local fastlane deployment failed:`,
error.message,
);
} finally {
// Always run cleanup after deployment, regardless of success/failure
performIOSBuildCleanup(platform);
// Only exit with error code if deployment failed
if (!deploymentSuccessful) {
process.exit(1);
}
}
}
/**
* Executes GitHub runner deployment
* @param {string} platform - Target platform
*/
async function executeGithubRunnerDeployment(platform) {
console.log(
`\n${CONSOLE_SYMBOLS.ROCKET} Starting GitHub runner deployment...`,
);
// Safely get the current branch name to avoid command injection
const currentBranch = getCurrentBranch();
if (!currentBranch) {
console.error(
`${CONSOLE_SYMBOLS.ERROR} Could not determine current git branch`,
);
process.exit(1);
}
const command = `gh workflow run mobile-deploy.yml --ref ${currentBranch} -f platform=${platform}`;
try {
execSync(command, { stdio: 'inherit' });
console.log(
`${CONSOLE_SYMBOLS.SUCCESS} GitHub workflow triggered successfully!`,
);
console.log(
`${CONSOLE_SYMBOLS.CHART} Check GitHub Actions for build progress.`,
);
} catch (error) {
console.error(
`${CONSOLE_SYMBOLS.ERROR} Failed to trigger GitHub workflow:`,
error.message,
);
process.exit(1);
}
}
/**
* Executes the deployment based on the specified method
* @param {string} platform - Target platform
* @param {string} deploymentMethod - The deployment method to use
*/
async function executeDeployment(platform, deploymentMethod) {
if (deploymentMethod === DEPLOYMENT_METHODS.LOCAL_FASTLANE) {
await executeLocalFastlaneDeployment(platform);
} else {
await executeGithubRunnerDeployment(platform);
}
}
// Main Function
/**
* Main function that orchestrates the deployment confirmation process
*/
async function main() {
const platform = process.argv[2];
if (!validatePlatform(platform)) {
displayUsageAndExit();
}
const deploymentMethod = getDeploymentMethod();
const versions = getCurrentVersions();
displayFullConfirmation(platform, versions, deploymentMethod);
const confirmed = await promptConfirmation();
if (confirmed) {
await executeDeployment(platform, deploymentMethod);
} else {
console.log(`\n${CONSOLE_SYMBOLS.ERROR} Deployment cancelled.`);
process.exit(0);
}
}
// Execute main function
if (require.main === module) {
main().catch(error => {
console.error(`${CONSOLE_SYMBOLS.ERROR} Error:`, error.message);
process.exit(1);
});
} else {
module.exports = {
performIOSBuildCleanup,
executeLocalFastlaneDeployment,
_setExecSync: fn => {
execSync = fn;
},
_setPerformIOSBuildCleanup: fn => {
performIOSBuildCleanup = fn;
},
};
}