name: Test macOS Build Action on: workflow_dispatch: # Manual trigger only for testing push: branches: [ozgur/build] jobs: test-macos-build: runs-on: macos steps: - name: Checkout repository uses: actions/checkout@v3 with: lfs: true fetch-depth: 0 # Enable debug logging - name: Enable Debug Logging run: | echo "ACTIONS_RUNNER_DEBUG=true" >> $GITHUB_ENV echo "ACTIONS_STEP_DEBUG=true" >> $GITHUB_ENV shell: bash # Setup environment for build - name: Setup environment run: | # Get the working directory path for absolute paths WORKSPACE_DIR="$(pwd)" echo "WORKSPACE_DIR=$WORKSPACE_DIR" >> "$GITHUB_ENV" echo "ENTITLEMENTS_FILE=LuckyWorld.entitlements" >> "$GITHUB_ENV" # Set CI environment variable to true for build script echo "CI=true" >> "$GITHUB_ENV" # Create directories for builds mkdir -p Builds/Mac mkdir -p PackagedReleases mkdir -p ArchivedApps echo "Environment setup complete" shell: bash # Restore cache for build dependencies - name: Restore Build Cache id: build-cache uses: actions/cache@v3 with: path: | DerivedDataCache Intermediate Saved/Autosaves Saved/Config .unreal key: ${{ runner.os }}-macbuild-${{ hashFiles('**/*.uproject') }}-${{ hashFiles('Config/**') }} restore-keys: | ${{ runner.os }}-macbuild-${{ hashFiles('**/*.uproject') }}- ${{ runner.os }}-macbuild- # Build for macOS - use your own build script - name: Build for macOS run: | if [ -f "./scripts/mac_build.sh" ]; then chmod +x ./scripts/mac_build.sh # Set CI environment variable explicitly before running export CI=true ./scripts/mac_build.sh # Check if the build succeeded by looking for the app APP_PATHS=$(find ./Builds -type d -name "*.app" 2>/dev/null || echo "") if [ -z "$APP_PATHS" ]; then APP_PATHS=$(find ./Saved/StagedBuilds -type d -name "*.app" 2>/dev/null || echo "") fi if [ -z "$APP_PATHS" ]; then echo "❌ ERROR: Build command appeared to succeed but no app bundle was found!" echo "This usually means the build failed but didn't properly return an error code." exit 1 fi else echo "ERROR: Build script not found at ./scripts/mac_build.sh" exit 1 fi shell: bash # Find the app bundle - name: Find app bundle run: | # Add error handling set +e # Don't exit immediately on error for this block echo "Build status check..." if [ ! -d "./Builds" ] && [ ! -d "./Saved/StagedBuilds" ]; then echo "❌ ERROR: Build directories do not exist. Build likely failed." exit 1 fi # First check Saved/StagedBuilds directory - where Unreal often places built apps echo "Checking Saved/StagedBuilds directory..." APP_PATHS=$(find ./Saved/StagedBuilds -type d -name "*.app" 2>/dev/null || echo "") # If not found, check Builds directory if [ -z "$APP_PATHS" ]; then echo "No app found in Saved/StagedBuilds, checking Builds directory..." APP_PATHS=$(find ./Builds -type d -name "*.app" 2>/dev/null || echo "") fi # If still not found, check the whole workspace if [ -z "$APP_PATHS" ]; then echo "No app found in Builds, checking entire workspace..." APP_PATHS=$(find . -type d -name "*.app" -not -path "*/\.*" 2>/dev/null || echo "") fi if [ -z "$APP_PATHS" ]; then echo "❌ ERROR: Could not find any app bundles!" echo "Listing all directories to help debug:" find . -type d -maxdepth 3 | sort exit 1 fi echo "Found potential app bundles:" echo "$APP_PATHS" # Use the first app path found (preferably the main app, not a child app) MAIN_APP_PATH=$(echo "$APP_PATHS" | grep -v "CrashReportClient" | head -1 || echo "$APP_PATHS" | head -1) echo "Using app bundle: $MAIN_APP_PATH" echo "APP_PATH=$MAIN_APP_PATH" >> "$GITHUB_ENV" # Make sure app exists - using local variable if [ ! -d "$MAIN_APP_PATH" ]; then echo "❌ ERROR: App bundle not found at $MAIN_APP_PATH!" exit 1 fi # Export APP_PATH for next steps to use echo "APP_PATH=$MAIN_APP_PATH" >> "$GITHUB_ENV" # Get bundle ID from Info.plist for reference (not modifying) if [ -f "$MAIN_APP_PATH/Contents/Info.plist" ]; then BUNDLE_ID=$(/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "$MAIN_APP_PATH/Contents/Info.plist") echo "Detected bundle ID: $BUNDLE_ID" echo "BUNDLE_ID=$BUNDLE_ID" >> "$GITHUB_ENV" fi shell: bash # Basic pre-notarization checks - name: Check for notarization issues run: | echo "🔍 Checking app for potential notarization issues..." APP_PATH="${{ env.APP_PATH }}" # Verify code signature already exists (from Unreal build) echo "Checking existing signature..." codesign -vvv "$APP_PATH" || echo "⚠️ App may not be properly signed by Unreal Engine" # Check for any ad-hoc signatures that would cause issues if codesign -dvv "$APP_PATH" 2>&1 | grep -q "adhoc"; then echo "⚠️ Warning: Ad-hoc signature detected. This will be replaced with a proper signature." fi # Verify entitlements file exists if [ ! -f "${{ env.ENTITLEMENTS_FILE }}" ]; then echo "⚠️ Entitlements file not found. Will use default entitlements." else echo "Found entitlements file: ${{ env.ENTITLEMENTS_FILE }}" fi shell: bash # Save cache for next workflow run - name: Save Build Cache if: always() uses: actions/cache/save@v3 with: path: | DerivedDataCache Intermediate Saved/Autosaves Saved/Config .unreal key: ${{ steps.build-cache.outputs.cache-primary-key }} # Create a debug log file for notarize action - name: Create debug log directory run: | mkdir -p debug_logs echo "DEBUG_LOG_PATH=$(pwd)/debug_logs/notarize_log.txt" >> $GITHUB_ENV shell: bash # Beginning of macos-notarize steps - name: Setup debug environment id: setup-debug-env run: | # Create debug directory if env variable is set if [[ -n "$DEBUG_LOG_PATH" ]]; then mkdir -p "$(dirname "$DEBUG_LOG_PATH")" touch "$DEBUG_LOG_PATH" echo "Debug logging enabled to: $DEBUG_LOG_PATH" | tee -a "$DEBUG_LOG_PATH" fi # Define a debug function debug_log() { echo "DEBUG: $1" if [[ -n "$DEBUG_LOG_PATH" ]]; then echo "$(date '+%Y-%m-%d %H:%M:%S') - $1" >> "$DEBUG_LOG_PATH" fi } # Export the function for use in subsequent steps export -f debug_log debug_log "Starting macOS notarize action" debug_log "App path: ${{ env.APP_PATH }}" debug_log "Team ID: ${{ secrets.APPLE_TEAM_ID }}" debug_log "Notarization method: api-key" debug_log "Bundle ID: ${{ env.BUNDLE_ID }}" shell: bash - name: Set up variables id: setup run: | # Debugging info debug_log "Setting up variables" # Generate unique name for keychain KEYCHAIN_NAME="build-keychain-$(uuidgen)" KEYCHAIN_PASSWORD="$(uuidgen)" echo "KEYCHAIN_NAME=$KEYCHAIN_NAME" >> $GITHUB_ENV echo "KEYCHAIN_PASSWORD=$KEYCHAIN_PASSWORD" >> $GITHUB_ENV # Set paths echo "APP_PATH=${{ env.APP_PATH }}" >> $GITHUB_ENV # Generate working directory for temp files WORK_DIR="$(mktemp -d)" echo "WORK_DIR=$WORK_DIR" >> $GITHUB_ENV # Set bundle id (from input or extract from app) if [[ -n "${{ env.BUNDLE_ID }}" ]]; then BUNDLE_ID="${{ env.BUNDLE_ID }}" else BUNDLE_ID=$(/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "${{ env.APP_PATH }}/Contents/Info.plist") fi echo "BUNDLE_ID=$BUNDLE_ID" >> $GITHUB_ENV # Get app name from bundle path APP_NAME=$(basename "${{ env.APP_PATH }}" .app) echo "APP_NAME=$APP_NAME" >> $GITHUB_ENV # Set output directory OUTPUT_DIR="$(pwd)/PackagedReleases" mkdir -p "$OUTPUT_DIR" echo "OUTPUT_DIR=$OUTPUT_DIR" >> $GITHUB_ENV # Set package paths ZIP_PATH="$OUTPUT_DIR/${APP_NAME}.zip" DMG_PATH="$OUTPUT_DIR/${APP_NAME}.dmg" echo "ZIP_PATH=$ZIP_PATH" >> $GITHUB_ENV echo "DMG_PATH=$DMG_PATH" >> $GITHUB_ENV # Set notarization variables based on method echo "Using API key method for notarization" # Create API key file API_KEY_FILE="$WORK_DIR/api_key.p8" echo "${{ secrets.NOTARY_API_KEY_PATH }}" | base64 --decode > "$API_KEY_FILE" echo "API_KEY_FILE=$API_KEY_FILE" >> $GITHUB_ENV # Verify API key file exists if [[ ! -f "$API_KEY_FILE" ]]; then debug_log "ERROR: API key file could not be created" exit 1 fi debug_log "API key file created at: $API_KEY_FILE" debug_log "API key ID: ${{ secrets.NOTARY_API_KEY_ID }}" debug_log "API key issuer ID: ${{ secrets.NOTARY_API_KEY_ISSUER_ID }}" shell: bash - name: Setup keychain id: setup-keychain run: | debug_log "Setting up keychain" # Create temporary keychain security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME" security default-keychain -s "$KEYCHAIN_NAME" security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME" security set-keychain-settings -t 3600 -u "$KEYCHAIN_NAME" # Create certificate file CERTIFICATE_PATH="$WORK_DIR/certificate.p12" echo "${{ secrets.MACOS_CERTIFICATE }}" | base64 --decode > "$CERTIFICATE_PATH" # Add to keychain debug_log "Importing certificate into keychain" security import "$CERTIFICATE_PATH" -k "$KEYCHAIN_NAME" -P "${{ secrets.MACOS_CERTIFICATE_PWD }}" -T /usr/bin/codesign # Allow codesign to access keychain items security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME" # Verify certificate was imported security find-identity -v "$KEYCHAIN_NAME" | grep "Developer ID Application" IDENTITY_RESULT=$? if [ $IDENTITY_RESULT -eq 0 ]; then debug_log "Certificate imported successfully" SIGNING_IDENTITY="Developer ID Application: ${{ secrets.APPLE_TEAM_ID }}" echo "SIGNING_IDENTITY=$SIGNING_IDENTITY" >> $GITHUB_ENV echo "CERTIFICATE_AVAILABLE=true" >> $GITHUB_ENV else debug_log "WARNING: No Developer ID Application certificate found" if [[ "false" == "true" ]]; then debug_log "Falling back to ad-hoc signing" echo "CERTIFICATE_AVAILABLE=adhoc" >> $GITHUB_ENV else debug_log "Not falling back to ad-hoc signing as specified" echo "CERTIFICATE_AVAILABLE=false" >> $GITHUB_ENV fi fi shell: bash - name: Sign application id: sign-app run: | debug_log "Starting application signing process" # Check if certificate is available if [[ "$CERTIFICATE_AVAILABLE" == "false" ]]; then debug_log "No certificate available and fallback disabled. Skipping signing." echo "SIGNING_RESULT=none" >> $GITHUB_ENV exit 0 fi # Make sure entitlements file is defined and reset variable name ENTITLEMENTS_PATH="${{ env.ENTITLEMENTS_FILE }}" # Sign the app if [[ "$CERTIFICATE_AVAILABLE" == "true" ]]; then debug_log "Signing with Developer ID certificate" # First remove existing signatures debug_log "Removing existing signatures..." codesign --remove-signature "$APP_PATH" || true # Sign all dynamic libraries and frameworks debug_log "Signing embedded binaries and frameworks..." find "$APP_PATH/Contents/MacOS" -type f -name "*.dylib" -exec codesign --force --timestamp --options runtime --entitlements "$ENTITLEMENTS_PATH" --sign "$SIGNING_IDENTITY" {} \; find "$APP_PATH/Contents/Frameworks" -type f -depth 1 -exec codesign --force --timestamp --options runtime --entitlements "$ENTITLEMENTS_PATH" --sign "$SIGNING_IDENTITY" {} \; find "$APP_PATH/Contents/Frameworks" -name "*.framework" -exec codesign --force --timestamp --options runtime --entitlements "$ENTITLEMENTS_PATH" --sign "$SIGNING_IDENTITY" {} \; # Sign all executables debug_log "Signing executables..." find "$APP_PATH/Contents/MacOS" -type f -exec codesign --force --timestamp --options runtime --entitlements "$ENTITLEMENTS_PATH" --sign "$SIGNING_IDENTITY" {} \; # Sign app bundle debug_log "Signing main app bundle..." codesign --force --timestamp --options runtime --entitlements "$ENTITLEMENTS_PATH" --sign "$SIGNING_IDENTITY" "$APP_PATH" SIGN_RESULT=$? if [ $SIGN_RESULT -eq 0 ]; then debug_log "App signed successfully with Developer ID" echo "SIGNING_RESULT=true" >> $GITHUB_ENV else debug_log "App signing failed with Developer ID" echo "SIGNING_RESULT=false" >> $GITHUB_ENV exit 1 fi elif [[ "$CERTIFICATE_AVAILABLE" == "adhoc" ]]; then debug_log "Signing with ad-hoc identity (not suitable for distribution)" # Remove existing signatures codesign --remove-signature "$APP_PATH" || true # Sign with ad-hoc identity codesign --force --timestamp --options runtime --entitlements "$ENTITLEMENTS_PATH" --sign - "$APP_PATH" SIGN_RESULT=$? if [ $SIGN_RESULT -eq 0 ]; then debug_log "App signed successfully with ad-hoc identity" echo "SIGNING_RESULT=ad-hoc" >> $GITHUB_ENV else debug_log "App signing failed with ad-hoc identity" echo "SIGNING_RESULT=false" >> $GITHUB_ENV exit 1 fi else debug_log "Unexpected certificate state. Skipping signing." echo "SIGNING_RESULT=none" >> $GITHUB_ENV fi # Verify signing debug_log "Verifying app signature..." codesign -dvv "$APP_PATH" shell: bash - name: Verify notarization and stapling id: verify-notarization if: env.SIGNING_RESULT == 'true' run: | debug_log "Verifying app signature and code requirements before notarization" # Verify code signature codesign --verify --verbose "$APP_PATH" if [ $? -ne 0 ]; then debug_log "Error: App signature verification failed" # Don't exit, just log the error else debug_log "App signature verification passed" fi # Check app for code requirements codesign --display --requirements "$APP_PATH" if [ $? -ne 0 ]; then debug_log "Error: App doesn't meet requirements" # Don't exit, just log the error else debug_log "App meets code requirements" fi shell: bash - name: Notarize application id: notarize-app if: env.SIGNING_RESULT == 'true' run: | debug_log "Starting notarization process" # Create ZIP for notarization debug_log "Creating ZIP archive for notarization" ditto -c -k --keepParent "$APP_PATH" "$ZIP_PATH" if [ $? -ne 0 ]; then debug_log "Error creating ZIP archive" echo "NOTARIZATION_RESULT=false" >> $GITHUB_ENV exit 1 fi # Notarize the app using API key method debug_log "Notarizing with API key method" # Submit for notarization debug_log "Submitting app for notarization..." xcrun notarytool submit "$ZIP_PATH" \ --key "$API_KEY_FILE" \ --key-id "${{ secrets.NOTARY_API_KEY_ID }}" \ --issuer "${{ secrets.NOTARY_API_KEY_ISSUER_ID }}" \ --wait > "$WORK_DIR/notarization_output.txt" 2>&1 cat "$WORK_DIR/notarization_output.txt" | tee -a "$DEBUG_LOG_PATH" REQUEST_STATUS=$(grep -o "status: .*" "$WORK_DIR/notarization_output.txt" | cut -d ' ' -f2) if [[ "$REQUEST_STATUS" == "Accepted" ]]; then debug_log "Notarization successful" echo "NOTARIZATION_RESULT=true" >> $GITHUB_ENV else debug_log "Notarization failed or timed out" cat "$WORK_DIR/notarization_output.txt" echo "NOTARIZATION_RESULT=false" >> $GITHUB_ENV fi shell: bash - name: Staple notarization ticket id: staple-ticket if: env.SIGNING_RESULT == 'true' && env.NOTARIZATION_RESULT == 'true' run: | debug_log "Stapling notarization ticket to app" # Staple the ticket xcrun stapler staple "$APP_PATH" STAPLE_RESULT=$? if [ $STAPLE_RESULT -eq 0 ]; then debug_log "Notarization ticket stapled successfully" echo "STAPLING_RESULT=true" >> $GITHUB_ENV # Verify stapling debug_log "Verifying notarization stapling" xcrun stapler validate "$APP_PATH" if [ $? -eq 0 ]; then debug_log "Stapling validation successful" else debug_log "Stapling validation failed, but continuing" fi else debug_log "Stapling failed" echo "STAPLING_RESULT=false" >> $GITHUB_ENV fi shell: bash - name: Remove quarantine attribute id: remove-quarantine if: env.SIGNING_RESULT != 'none' run: | debug_log "Removing quarantine attribute from app" # Directly run the command without creating a script file debug_log "Removing quarantine attribute from all files..." find "$APP_PATH" -exec xattr -d com.apple.quarantine {} \; 2>/dev/null || true debug_log "Quarantine attributes removed" shell: bash - name: Package signed app id: package-app run: | debug_log "Packaging the signed app" # Check if we should use create-dmg if available if command -v create-dmg &> /dev/null; then debug_log "Using create-dmg for DMG creation" # Create a temporary directory for DMG contents DMG_TEMP_DIR="$WORK_DIR/dmg-contents" mkdir -p "$DMG_TEMP_DIR" # Copy the app to the temporary directory cp -R "$APP_PATH" "$DMG_TEMP_DIR/" # Create instructions text file echo "Drag the application to the Applications folder to install it." > "$DMG_TEMP_DIR/README.txt" # Create symlink to Applications folder ln -s /Applications "$DMG_TEMP_DIR/Applications" # Use create-dmg to create a more beautiful DMG create-dmg \ --volname "$APP_NAME" \ --window-pos 200 120 \ --window-size 800 400 \ --icon-size 100 \ --app-drop-link 600 185 \ --icon "$APP_NAME.app" 200 185 \ --hide-extension "$APP_NAME.app" \ --add-file "README.txt" 400 185 \ --no-internet-enable \ "$DMG_PATH" \ "$DMG_TEMP_DIR" DMG_CREATE_RESULT=$? elif command -v hdiutil &> /dev/null; then debug_log "Using hdiutil for DMG creation" # Create DMG using hdiutil hdiutil create -volname "$APP_NAME" -srcfolder "$APP_PATH" -ov -format UDZO "$DMG_PATH" DMG_CREATE_RESULT=$? else debug_log "Neither create-dmg nor hdiutil available. Cannot create DMG." DMG_CREATE_RESULT=1 fi # Check DMG creation result if [ $DMG_CREATE_RESULT -eq 0 ]; then debug_log "DMG package created successfully at: $DMG_PATH" echo "DMG_CREATED=true" >> $GITHUB_ENV else debug_log "DMG creation failed" echo "DMG_CREATED=false" >> $GITHUB_ENV fi # If we have a properly signed app, sign the DMG as well if [[ "$SIGNING_RESULT" == "true" && "$DMG_CREATED" == "true" ]]; then debug_log "Signing DMG with Developer ID certificate" codesign --force --timestamp --sign "$SIGNING_IDENTITY" "$DMG_PATH" if [ $? -eq 0 ]; then debug_log "DMG signed successfully" else debug_log "DMG signing failed" fi # If app was notarized, also notarize the DMG if [[ "$NOTARIZATION_RESULT" == "true" ]]; then debug_log "Notarizing DMG..." # Notarize the DMG using API key method debug_log "Notarizing DMG with API key method" xcrun notarytool submit "$DMG_PATH" \ --key "$API_KEY_FILE" \ --key-id "${{ secrets.NOTARY_API_KEY_ID }}" \ --issuer "${{ secrets.NOTARY_API_KEY_ISSUER_ID }}" \ --wait > "$WORK_DIR/dmg_notarization_output.txt" 2>&1 cat "$WORK_DIR/dmg_notarization_output.txt" | tee -a "$DEBUG_LOG_PATH" DMG_REQUEST_STATUS=$(grep -o "status: .*" "$WORK_DIR/dmg_notarization_output.txt" | cut -d ' ' -f2) if [[ "$DMG_REQUEST_STATUS" == "Accepted" ]]; then debug_log "DMG notarization successful" # Staple DMG debug_log "Stapling notarization ticket to DMG" xcrun stapler staple "$DMG_PATH" if [ $? -eq 0 ]; then debug_log "DMG stapling successful" else debug_log "DMG stapling failed" fi else debug_log "DMG notarization failed or timed out" cat "$WORK_DIR/dmg_notarization_output.txt" fi fi fi # Final verification of all distribution artifacts debug_log "Verifying final distribution artifacts" # Check ZIP file if [[ -f "$ZIP_PATH" ]]; then ZIP_SIZE=$(du -h "$ZIP_PATH" | cut -f1) debug_log "ZIP package size: $ZIP_SIZE" # Verify ZIP integrity unzip -t "$ZIP_PATH" > /dev/null if [ $? -eq 0 ]; then debug_log "ZIP package integrity verified" else debug_log "ZIP package may be corrupted" fi fi # Check DMG file if [[ -f "$DMG_PATH" ]]; then DMG_SIZE=$(du -h "$DMG_PATH" | cut -f1) debug_log "DMG package size: $DMG_SIZE" # Verify DMG signature if signed if [[ "$SIGNING_RESULT" == "true" ]]; then codesign -vvv "$DMG_PATH" 2>&1 | tee -a "$DEBUG_LOG_PATH" || debug_log "DMG signature verification failed" fi fi # Set output variables if [[ "$SIGNING_RESULT" == "true" ]]; then echo "SIGNED_STATUS=true" >> $GITHUB_ENV elif [[ "$SIGNING_RESULT" == "ad-hoc" ]]; then echo "SIGNED_STATUS=ad-hoc" >> $GITHUB_ENV else echo "SIGNED_STATUS=none" >> $GITHUB_ENV fi if [[ "$NOTARIZATION_RESULT" == "true" ]]; then echo "NOTARIZED_STATUS=true" >> $GITHUB_ENV else echo "NOTARIZED_STATUS=false" >> $GITHUB_ENV fi if [[ "$DMG_CREATED" == "true" ]]; then echo "PACKAGE_PATH=$DMG_PATH" >> $GITHUB_ENV else echo "PACKAGE_PATH=$ZIP_PATH" >> $GITHUB_ENV fi shell: bash - name: Clean up if: always() run: | debug_log "Cleaning up" # Clean up keychain if [[ -n "$KEYCHAIN_NAME" ]]; then security delete-keychain "$KEYCHAIN_NAME" || true debug_log "Keychain deleted" fi # Clean up temporary files if [[ -d "$WORK_DIR" ]]; then rm -rf "$WORK_DIR" || true debug_log "Temporary files deleted" fi debug_log "Cleanup completed" shell: bash # Upload debug logs if available - name: Upload Debug Logs uses: actions/upload-artifact@v3 if: always() with: name: notarize-debug-logs path: debug_logs retention-days: 7 # Upload only the DMG file as main distribution artifact - name: Upload Mac Distribution DMG uses: actions/upload-artifact@v3 if: env.NOTARIZED_STATUS == 'true' && env.SIGNED_STATUS != 'none' with: name: LuckyWorld-Mac-Distribution path: ${{ env.PACKAGE_PATH }} retention-days: 30 # Report results - name: Report Results run: | echo "🔐 App signing: ${{ env.SIGNED_STATUS }}" echo "🔏 App notarization: ${{ env.NOTARIZED_STATUS }}" if [ "${{ env.SIGNED_STATUS }}" != "none" ]; then echo "✅ Packaging completed successfully!" echo "Final package: ${{ env.PACKAGE_PATH }}" else echo "⚠️ App was not signed - check the logs for details" fi shell: bash