diff --git a/.github/workflows/mobile-deploy.yml b/.github/workflows/mobile-deploy.yml index 9df3b2ada..f21b8782e 100644 --- a/.github/workflows/mobile-deploy.yml +++ b/.github/workflows/mobile-deploy.yml @@ -56,9 +56,7 @@ env: IOS_PROV_PROFILE_DIRECTORY: "~/Library/MobileDevice/Provisioning\ Profiles/" permissions: - contents: write - pull-requests: write - id-token: write + contents: read on: workflow_dispatch: @@ -150,6 +148,8 @@ jobs: # NOTE: Checks out the triggering branch (staging for PR merges, or the branch where manually triggered) bump-version: runs-on: ubuntu-latest + permissions: + contents: read if: | (github.event_name != 'pull_request' || github.event.pull_request.merged == true) && !contains(github.event.pull_request.labels.*.name, 'deploy:skip') @@ -241,9 +241,26 @@ jobs: echo "✅ Version bump calculated successfully" echo "⚠️ Note: Changes are local only. Will be committed in PR after successful builds." + - name: Verify bump outputs were set + run: | + VERSION="${{ steps.bump.outputs.version }}" + IOS_BUILD="${{ steps.bump.outputs.ios_build }}" + ANDROID_BUILD="${{ steps.bump.outputs.android_build }}" + + if [ -z "$VERSION" ] || [ -z "$IOS_BUILD" ] || [ -z "$ANDROID_BUILD" ]; then + echo "❌ Version bump failed to set required outputs" + echo "version='$VERSION', ios_build='$IOS_BUILD', android_build='$ANDROID_BUILD'" + exit 1 + fi + + echo "✅ All version outputs verified" + build-ios: needs: [bump-version] runs-on: macos-latest-large + permissions: + contents: read + actions: write if: | (github.event_name != 'pull_request' || github.event.pull_request.merged == true) && ( @@ -285,7 +302,7 @@ jobs: echo "NODE_VERSION_SANITIZED=${VERSION//\//-}" >> "$GITHUB_ENV" - name: Verify branch and commit (iOS) - if: inputs.platform != 'android' + if: needs.bump-version.outputs.platform != 'android' run: | echo "🔍 Verifying we're building from the correct branch and commit..." echo "Current branch: $(git branch --show-current || git symbolic-ref --short HEAD 2>/dev/null || echo 'detached')" @@ -309,7 +326,7 @@ jobs: fi - name: Apply version bump for build - if: inputs.platform != 'android' + if: needs.bump-version.outputs.platform != 'android' run: | cd ${{ env.APP_PATH }} @@ -804,6 +821,10 @@ jobs: build-android: needs: [bump-version] runs-on: ubuntu-latest + permissions: + contents: read + actions: write + id-token: write if: | (github.event_name != 'pull_request' || github.event.pull_request.merged == true) && ( @@ -910,7 +931,7 @@ jobs: echo "NODE_VERSION_SANITIZED=${VERSION//\//-}" >> "$GITHUB_ENV" - name: Verify branch and commit (Android) - if: inputs.platform != 'ios' + if: needs.bump-version.outputs.platform != 'ios' run: | echo "🔍 Verifying we're building from the correct branch and commit..." echo "Current branch: $(git branch --show-current || git symbolic-ref --short HEAD 2>/dev/null || echo 'detached')" @@ -930,7 +951,7 @@ jobs: fi - name: Apply version bump for build - if: inputs.platform != 'ios' + if: needs.bump-version.outputs.platform != 'ios' run: | cd ${{ env.APP_PATH }} @@ -1234,6 +1255,9 @@ jobs: # but create the version bump PR to dev so it can be reviewed before merging to staging create-version-bump-pr: runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write needs: [bump-version, build-ios, build-android] if: | always() && @@ -1284,6 +1308,20 @@ jobs: echo "✅ Versions applied successfully" + - name: Verify version changes + run: | + cd ${{ env.APP_PATH }} + + # Check that version files actually changed + if ! git diff --quiet package.json version.json; then + echo "✅ Version changes detected" + git diff package.json version.json + else + echo "⚠️ No version changes detected in package.json or version.json" + echo "This may indicate a problem with version application" + exit 1 + fi + - name: Determine platforms that succeeded and PR title id: platforms run: | @@ -1383,6 +1421,9 @@ jobs: # Create git tags after successful deployment create-release-tags: + runs-on: ubuntu-latest + permissions: + contents: write needs: [bump-version, build-ios, build-android, create-version-bump-pr] if: | always() && @@ -1390,7 +1431,6 @@ jobs: needs.create-version-bump-pr.result == 'success' && (needs.build-ios.result == 'success' || needs.build-android.result == 'success') && (inputs.deployment_track == 'production') - runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: diff --git a/.github/workflows/release-calendar.yml b/.github/workflows/release-calendar.yml index f63e24cab..6cf54cc08 100644 --- a/.github/workflows/release-calendar.yml +++ b/.github/workflows/release-calendar.yml @@ -160,7 +160,7 @@ jobs: - name: Create dev to staging release PR if: ${{ steps.guard_schedule.outputs.continue == 'true' && steps.check_dev_staging.outputs.existing_pr == '' }} env: - GH_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ secrets.SELFXYZ_INTERNAL_REPO_PAT }} PR_DATE: ${{ steps.check_dev_staging.outputs.date }} BRANCH_NAME: ${{ steps.check_dev_staging.outputs.branch_name }} shell: bash @@ -328,7 +328,7 @@ jobs: - name: Create staging to main release PR if: ${{ steps.guard_schedule.outputs.continue == 'true' && steps.production_status.outputs.staging_not_ahead != 'true' && steps.production_status.outputs.existing_pr == '' }} env: - GH_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ secrets.SELFXYZ_INTERNAL_REPO_PAT }} PR_DATE: ${{ steps.production_status.outputs.date }} COMMITS_AHEAD: ${{ steps.production_status.outputs.commits }} shell: bash diff --git a/app/fastlane/Fastfile b/app/fastlane/Fastfile index aff78d7b5..7cb68f582 100644 --- a/app/fastlane/Fastfile +++ b/app/fastlane/Fastfile @@ -49,12 +49,12 @@ ios_xcode_profile_path = "../ios/#{PROJECT_NAME}.xcodeproj" default_platform(:ios) platform :ios do - desc "Sync ios version" + desc "Sync ios version (DEPRECATED)" lane :sync_version do - increment_version_number( - xcodeproj: "ios/#{PROJECT_NAME}.xcodeproj", - version_number: package_version, - ) + UI.error("⛔ This lane is deprecated!") + UI.error("Version management is now centralized in CI.") + UI.error("Use: node scripts/version-manager.cjs apply ") + UI.user_error!("sync_version lane is deprecated - use version-manager.cjs instead") end desc "Push a new build to TestFlight Internal Testing" @@ -247,12 +247,12 @@ platform :ios do end platform :android do - desc "Sync android version" + desc "Sync android version (DEPRECATED)" lane :sync_version do - android_set_version_name( - version_name: package_version, - gradle_file: android_gradle_file_path.gsub("../", ""), - ) + UI.error("⛔ This lane is deprecated!") + UI.error("Version management is now centralized in CI.") + UI.error("Use: node scripts/version-manager.cjs apply ") + UI.user_error!("sync_version lane is deprecated - use version-manager.cjs instead") end desc "Push a new build to Google Play Internal Testing" diff --git a/app/fastlane/helpers/version_manager.rb b/app/fastlane/helpers/version_manager.rb index 5c4f9d6d3..3bb97a6cb 100644 --- a/app/fastlane/helpers/version_manager.rb +++ b/app/fastlane/helpers/version_manager.rb @@ -63,10 +63,26 @@ module Fastlane android_matches = android_build == expected_android_build unless version_matches && ios_matches && android_matches - UI.error("Version mismatch detected!") + UI.error("❌ Version mismatch detected!") UI.error("Expected: v#{expected_version} (iOS: #{expected_ios_build}, Android: #{expected_android_build})") UI.error("Actual: v#{pkg_version} (iOS: #{ios_build}, Android: #{android_build})") - UI.user_error!("Version mismatch! CI version-manager script should have set these correctly.") + UI.error("") + + # Add specific diagnostics + UI.error("Mismatched fields:") + UI.error(" • package.json version") unless version_matches + UI.error(" • version.json iOS build") unless ios_matches + UI.error(" • version.json Android build") unless android_matches + UI.error("") + + UI.error("💡 Common causes:") + UI.error(" 1. version-manager.cjs 'apply' command didn't run in workflow") + UI.error(" 2. Files were modified after version bump was applied") + UI.error(" 3. CI_VERSION, CI_IOS_BUILD, or CI_ANDROID_BUILD env vars are incorrect") + UI.error("") + UI.error("🔍 Debug: Check workflow logs for 'Apply version bump' step") + + UI.user_error!("Version verification failed") end UI.success("✅ Version verification passed:") diff --git a/app/jest.config.cjs b/app/jest.config.cjs index b97823593..5dec03868 100644 --- a/app/jest.config.cjs +++ b/app/jest.config.cjs @@ -4,12 +4,19 @@ module.exports = { preset: 'react-native', - moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'cjs', 'json', 'node'], transformIgnorePatterns: [ 'node_modules/(?!(react-native|@react-native|@react-navigation|@react-native-community|@segment/analytics-react-native|@openpassport|react-native-keychain|react-native-check-version|react-native-nfc-manager|react-native-passport-reader|react-native-gesture-handler|uuid|@stablelib|@react-native-google-signin|react-native-cloud-storage|@react-native-clipboard|@react-native-firebase|@selfxyz|@sentry|@anon-aadhaar|react-native-svg|react-native-svg-circle-country-flags)/)', ], setupFiles: ['/jest.setup.js'], - testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.[jt]sx?$', + testMatch: [ + '/**/__tests__/**/*.{js,jsx,ts,tsx,cjs}', + '/**/?(*.)+(spec|test).{js,jsx,ts,tsx,cjs}', + ], + testPathIgnorePatterns: [ + '/node_modules/', + '/scripts/tests/', // Node.js native test runner tests + ], moduleNameMapper: { '^@env$': '/tests/__setup__/@env.js', '\\.svg$': '/tests/__setup__/svgMock.js', diff --git a/app/scripts/version-manager.cjs b/app/scripts/version-manager.cjs index a3317d6bc..b8c9ec0cb 100755 --- a/app/scripts/version-manager.cjs +++ b/app/scripts/version-manager.cjs @@ -204,10 +204,33 @@ function bumpVersion(bumpType, platform = 'both') { * Apply version changes to files */ function applyVersions(version, iosBuild, androidBuild) { + // Validate version format (semver X.Y.Z) + if ( + !version || + typeof version !== 'string' || + !/^\d+\.\d+\.\d+$/.test(version) + ) { + throw new Error(`Invalid version format: ${version}. Expected X.Y.Z`); + } + + // Validate and coerce build numbers + const iosNum = Number(iosBuild); + const androidNum = Number(androidBuild); + + if (!Number.isInteger(iosNum) || iosNum < 1) { + throw new Error(`Invalid iOS build: ${iosBuild}. Must be positive integer`); + } + + if (!Number.isInteger(androidNum) || androidNum < 1) { + throw new Error( + `Invalid Android build: ${androidBuild}. Must be positive integer`, + ); + } + console.log(`📝 Applying versions to files...`); console.log(` Version: ${version}`); - console.log(` iOS Build: ${iosBuild}`); - console.log(` Android Build: ${androidBuild}`); + console.log(` iOS Build: ${iosNum}`); + console.log(` Android Build: ${androidNum}`); // Update package.json const pkg = readPackageJson(); @@ -217,8 +240,8 @@ function applyVersions(version, iosBuild, androidBuild) { // Update version.json const versionData = readVersionJson(); - versionData.ios.build = iosBuild; - versionData.android.build = androidBuild; + versionData.ios.build = iosNum; + versionData.android.build = androidNum; writeVersionJson(versionData); console.log(`✅ Updated version.json`); } diff --git a/app/scripts/version-manager.test.cjs b/app/scripts/version-manager.test.cjs new file mode 100644 index 000000000..8f09e60c2 --- /dev/null +++ b/app/scripts/version-manager.test.cjs @@ -0,0 +1,316 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +/** + * @jest-environment node + * + * SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. + * SPDX-License-Identifier: BUSL-1.1 + * NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + */ + +/** + * Unit tests for version-manager.cjs + * + * This file is only meant to be run with Jest. + */ + +const path = require('path'); + +// Mock file system operations - data +const mockPackageJson = { version: '1.2.3' }; +const mockVersionJson = { + ios: { build: 100, lastDeployed: '2024-01-01T00:00:00Z' }, + android: { build: 200, lastDeployed: '2024-01-01T00:00:00Z' }, +}; + +// Use manual mocking instead of jest.mock to avoid hoisting issues +const fs = require('fs'); + +// Store originals for restore +const originalReadFileSync = fs.readFileSync; +const originalWriteFileSync = fs.writeFileSync; +const originalExistsSync = fs.existsSync; +const originalAppendFileSync = fs.appendFileSync; + +// Setup mocks before importing the module +function setupMocks() { + fs.readFileSync = function (filePath, encoding) { + if (filePath.includes('package.json')) { + return JSON.stringify(mockPackageJson); + } + if (filePath.includes('version.json')) { + return JSON.stringify(mockVersionJson); + } + return originalReadFileSync(filePath, encoding); + }; + + fs.writeFileSync = function () {}; + fs.existsSync = function () { + return true; + }; + fs.appendFileSync = function () {}; +} + +function restoreMocks() { + fs.readFileSync = originalReadFileSync; + fs.writeFileSync = originalWriteFileSync; + fs.existsSync = originalExistsSync; + fs.appendFileSync = originalAppendFileSync; +} + +// Setup mocks before requiring the module +setupMocks(); + +// Import module after mocks are set up +const versionManager = require('./version-manager.cjs'); + +describe('version-manager', () => { + beforeEach(() => { + // Reset mock data + mockPackageJson.version = '1.2.3'; + mockVersionJson.ios.build = 100; + mockVersionJson.android.build = 200; + }); + + afterAll(() => { + restoreMocks(); + }); + + describe('getVersionInfo', () => { + it('should return current version information', () => { + const info = versionManager.getVersionInfo(); + expect(info.version).toBe('1.2.3'); + expect(info.iosBuild).toBe(100); + expect(info.androidBuild).toBe(200); + }); + }); + + describe('bumpVersion', () => { + it('should bump major version correctly', () => { + const result = versionManager.bumpVersion('major', 'both'); + expect(result.version).toBe('2.0.0'); + expect(result.iosBuild).toBe(101); + expect(result.androidBuild).toBe(201); + }); + + it('should bump minor version correctly', () => { + const result = versionManager.bumpVersion('minor', 'both'); + expect(result.version).toBe('1.3.0'); + expect(result.iosBuild).toBe(101); + expect(result.androidBuild).toBe(201); + }); + + it('should bump patch version correctly', () => { + const result = versionManager.bumpVersion('patch', 'both'); + expect(result.version).toBe('1.2.4'); + expect(result.iosBuild).toBe(101); + expect(result.androidBuild).toBe(201); + }); + + it('should bump build numbers only', () => { + const result = versionManager.bumpVersion('build', 'both'); + expect(result.version).toBe('1.2.3'); + expect(result.iosBuild).toBe(101); + expect(result.androidBuild).toBe(201); + }); + + it('should respect platform parameter (ios only)', () => { + const result = versionManager.bumpVersion('build', 'ios'); + expect(result.version).toBe('1.2.3'); + expect(result.iosBuild).toBe(101); + expect(result.androidBuild).toBe(200); // unchanged + }); + + it('should respect platform parameter (android only)', () => { + const result = versionManager.bumpVersion('build', 'android'); + expect(result.version).toBe('1.2.3'); + expect(result.iosBuild).toBe(100); // unchanged + expect(result.androidBuild).toBe(201); + }); + + it('should throw on invalid bump type', () => { + expect(() => versionManager.bumpVersion('invalid', 'both')).toThrow( + /Invalid bump type/, + ); + }); + + it('should throw on invalid platform', () => { + expect(() => versionManager.bumpVersion('build', 'invalid')).toThrow( + /Invalid platform/, + ); + }); + + it('should handle version with major bump resetting minor and patch', () => { + mockPackageJson.version = '2.5.8'; + const result = versionManager.bumpVersion('major', 'both'); + expect(result.version).toBe('3.0.0'); + }); + + it('should handle version with minor bump resetting patch', () => { + mockPackageJson.version = '2.5.8'; + const result = versionManager.bumpVersion('minor', 'both'); + expect(result.version).toBe('2.6.0'); + }); + }); + + describe('applyVersions', () => { + it('should reject invalid version format - not semver', () => { + expect(() => versionManager.applyVersions('invalid', 1, 1)).toThrow( + /Invalid version format/, + ); + }); + + it('should reject invalid version format - two parts', () => { + expect(() => versionManager.applyVersions('1.2', 1, 1)).toThrow( + /Invalid version format/, + ); + }); + + it('should reject invalid version format - four parts', () => { + expect(() => versionManager.applyVersions('1.2.3.4', 1, 1)).toThrow( + /Invalid version format/, + ); + }); + + it('should reject invalid version format - empty string', () => { + expect(() => versionManager.applyVersions('', 1, 1)).toThrow( + /Invalid version format/, + ); + }); + + it('should reject invalid version format - null', () => { + expect(() => versionManager.applyVersions(null, 1, 1)).toThrow( + /Invalid version format/, + ); + }); + + it('should reject invalid iOS build number - zero', () => { + expect(() => versionManager.applyVersions('1.2.3', 0, 1)).toThrow( + /Invalid iOS build/, + ); + }); + + it('should reject invalid iOS build number - negative', () => { + expect(() => versionManager.applyVersions('1.2.3', -1, 1)).toThrow( + /Invalid iOS build/, + ); + }); + + it('should reject invalid iOS build number - non-numeric string', () => { + expect(() => versionManager.applyVersions('1.2.3', 'abc', 1)).toThrow( + /Invalid iOS build/, + ); + }); + + it('should reject invalid iOS build number - float', () => { + expect(() => versionManager.applyVersions('1.2.3', 1.5, 1)).toThrow( + /Invalid iOS build/, + ); + }); + + it('should reject invalid Android build number - zero', () => { + expect(() => versionManager.applyVersions('1.2.3', 1, 0)).toThrow( + /Invalid Android build/, + ); + }); + + it('should reject invalid Android build number - negative', () => { + expect(() => versionManager.applyVersions('1.2.3', 1, -1)).toThrow( + /Invalid Android build/, + ); + }); + + it('should reject invalid Android build number - non-numeric string', () => { + expect(() => versionManager.applyVersions('1.2.3', 1, 'xyz')).toThrow( + /Invalid Android build/, + ); + }); + + it('should reject invalid Android build number - float', () => { + expect(() => versionManager.applyVersions('1.2.3', 1, 2.5)).toThrow( + /Invalid Android build/, + ); + }); + + it('should accept string build numbers that parse to integers', () => { + expect(() => + versionManager.applyVersions('1.2.3', '100', '200'), + ).not.toThrow(); + }); + + it('should accept large build numbers', () => { + expect(() => + versionManager.applyVersions('1.2.3', 99999, 88888), + ).not.toThrow(); + }); + + it('should write correct values to files', () => { + // Track write calls + const writeCalls = []; + fs.writeFileSync = function (filePath, content) { + writeCalls.push({ filePath, content }); + }; + + versionManager.applyVersions('2.0.0', 150, 250); + + // Verify writes occurred + expect(writeCalls.length).toBe(2); + + // Find and verify package.json write + const packageWrite = writeCalls.find(call => + call.filePath.includes('package.json'), + ); + expect(packageWrite).toBeDefined(); + const updatedPackage = JSON.parse(packageWrite.content); + expect(updatedPackage.version).toBe('2.0.0'); + + // Find and verify version.json write + const versionWrite = writeCalls.find(call => + call.filePath.includes('version.json'), + ); + expect(versionWrite).toBeDefined(); + const updatedVersion = JSON.parse(versionWrite.content); + expect(updatedVersion.ios.build).toBe(150); + expect(updatedVersion.android.build).toBe(250); + }); + }); + + describe('readPackageJson', () => { + it('should read and parse package.json', () => { + const pkg = versionManager.readPackageJson(); + expect(pkg.version).toBe('1.2.3'); + }); + + it('should throw error if file does not exist', () => { + const originalExists = fs.existsSync; + fs.existsSync = function () { + return false; + }; + expect(() => versionManager.readPackageJson()).toThrow( + /package.json not found/, + ); + fs.existsSync = originalExists; + }); + }); + + describe('readVersionJson', () => { + it('should read and parse version.json', () => { + const version = versionManager.readVersionJson(); + expect(version.ios.build).toBe(100); + expect(version.android.build).toBe(200); + }); + + it('should throw error if file does not exist', () => { + const originalExists = fs.existsSync; + fs.existsSync = function () { + return false; + }; + expect(() => versionManager.readVersionJson()).toThrow( + /version.json not found/, + ); + fs.existsSync = originalExists; + }); + }); +});