name: macOS Notarize description: 'Signs and notarizes a macOS application with Apple certificates' author: moersoy inputs: app-path: description: 'Path to the .app bundle to sign' required: true entitlements-file: description: 'Path to entitlements file to use for signing' required: false default: '' team-id: description: 'Apple Developer Team ID' required: true certificate-base64: description: 'Base64-encoded certificate (P12 file)' required: true certificate-password: description: 'Certificate password' required: true notarization-method: description: 'Method to use for notarization (api-key or app-password)' required: false default: 'api-key' app-password: description: 'App-specific password for Apple ID (required if using app-password method)' required: false default: '' apple-id: description: 'Apple ID email (required if using app-password method)' required: false default: '' notary-api-key-id: description: 'App Store Connect API Key ID (required if using api-key method)' required: false default: '' notary-api-key-issuer-id: description: 'App Store Connect API Key Issuer ID (required if using api-key method)' required: false default: '' notary-api-key-path: description: 'App Store Connect API Key file content (base64 encoded) (required if using api-key method)' required: false default: '' bundle-id: description: 'Bundle ID of the app' required: false default: '' fallback-to-adhoc: description: 'Fallback to ad-hoc signing if no certificate is available' required: false default: 'true' outputs: signed: description: 'Signing status (true, ad-hoc, none)' value: ${{ steps.set-outputs.outputs.signed }} notarized: description: 'Notarization status (true, false)' value: ${{ steps.set-outputs.outputs.notarized }} app-path: description: 'Path to the signed app bundle' value: ${{ steps.set-outputs.outputs.app-path }} zip-path: description: 'Path to the packaged .ZIP file' value: ${{ steps.set-outputs.outputs.zip-path }} package-path: description: 'Path to the packaged .DMG file' value: ${{ steps.set-outputs.outputs.package-path }} runs: using: "composite" steps: - name: Setup debug environment 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: ${{ inputs.app-path }}" debug_log "Team ID: ${{ inputs.team-id }}" debug_log "Notarization method: ${{ inputs.notarization-method }}" debug_log "Bundle ID: ${{ inputs.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=${{ inputs.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 "${{ inputs.bundle-id }}" ]]; then BUNDLE_ID="${{ inputs.bundle-id }}" else BUNDLE_ID=$(/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "${{ inputs.app-path }}/Contents/Info.plist") fi echo "BUNDLE_ID=$BUNDLE_ID" >> $GITHUB_ENV # Get app name from bundle path APP_NAME=$(basename "${{ inputs.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 if [[ "${{ inputs.notarization-method }}" == "api-key" ]]; then echo "Using API key method for notarization" # Create API key file API_KEY_FILE="$WORK_DIR/api_key.p8" echo "${{ inputs.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: ${{ inputs.notary-api-key-id }}" debug_log "API key issuer ID: ${{ inputs.notary-api-key-issuer-id }}" else echo "Using app-specific password method for notarization" debug_log "Apple ID: ${{ inputs.apple-id }}" fi 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 "${{ inputs.certificate-base64 }}" | base64 --decode > "$CERTIFICATE_PATH" # Add to keychain debug_log "Importing certificate into keychain" security import "$CERTIFICATE_PATH" -k "$KEYCHAIN_NAME" -P "${{ inputs.certificate-password }}" -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: ${{ inputs.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 [[ "${{ inputs.fallback-to-adhoc }}" == "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 # 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 if [[ "${{ inputs.notarization-method }}" == "api-key" ]]; then 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 "${{ inputs.notary-api-key-id }}" \ --issuer "${{ inputs.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 else debug_log "Notarizing with app-specific password method" # Submit for notarization debug_log "Submitting app for notarization..." xcrun altool --notarize-app \ --primary-bundle-id "$BUNDLE_ID" \ --username "${{ inputs.apple-id }}" \ --password "${{ inputs.app-password }}" \ --file "$ZIP_PATH" \ > "$WORK_DIR/notarization_output.txt" 2>&1 cat "$WORK_DIR/notarization_output.txt" | tee -a "$DEBUG_LOG_PATH" REQUEST_UUID=$(grep -o "RequestUUID = .*" "$WORK_DIR/notarization_output.txt" | cut -d ' ' -f3) if [[ -n "$REQUEST_UUID" ]]; then debug_log "Notarization request submitted, UUID: $REQUEST_UUID" # Wait for notarization to complete debug_log "Waiting for notarization to complete..." TIMEOUT=30 # 30 minutes timeout COUNT=0 NOTARIZATION_STATUS="in progress" while [[ "$NOTARIZATION_STATUS" == "in progress" && $COUNT -lt $TIMEOUT ]]; do sleep 60 xcrun altool --notarization-info "$REQUEST_UUID" \ --username "${{ inputs.apple-id }}" \ --password "${{ inputs.app-password }}" \ > "$WORK_DIR/notarization_info.txt" 2>&1 cat "$WORK_DIR/notarization_info.txt" | tee -a "$DEBUG_LOG_PATH" NOTARIZATION_STATUS=$(grep -o "Status: .*" "$WORK_DIR/notarization_info.txt" | cut -d ':' -f2 | xargs) debug_log "Notarization status: $NOTARIZATION_STATUS" COUNT=$((COUNT+1)) done if [[ "$NOTARIZATION_STATUS" == "success" ]]; then debug_log "Notarization successful" echo "NOTARIZATION_RESULT=true" >> $GITHUB_ENV else debug_log "Notarization failed or timed out: $NOTARIZATION_STATUS" if [[ -f "$WORK_DIR/notarization_info.txt" ]]; then cat "$WORK_DIR/notarization_info.txt" fi echo "NOTARIZATION_RESULT=false" >> $GITHUB_ENV fi else debug_log "Notarization submission failed, no UUID returned" cat "$WORK_DIR/notarization_output.txt" echo "NOTARIZATION_RESULT=false" >> $GITHUB_ENV fi 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" # Create helper script QUARANTINE_SCRIPT="$WORK_DIR/remove_quarantine.sh" cat > "$QUARANTINE_SCRIPT" << 'EOF' #!/bin/bash # Removes the quarantine attribute from app and all its contents echo "Removing quarantine attribute from all files..." find "$1" -exec xattr -d com.apple.quarantine {} \; 2>/dev/null || true echo "Quarantine attributes removed" EOF chmod +x "$QUARANTINE_SCRIPT" # Remove quarantine attribute "$QUARANTINE_SCRIPT" "$APP_PATH" debug_log "Quarantine attribute removal completed" 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 if [[ "${{ inputs.notarization-method }}" == "api-key" ]]; then debug_log "Notarizing DMG with API key method" xcrun notarytool submit "$DMG_PATH" \ --key "$API_KEY_FILE" \ --key-id "${{ inputs.notary-api-key-id }}" \ --issuer "${{ inputs.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 else debug_log "Notarizing DMG with app-specific password method" xcrun altool --notarize-app \ --primary-bundle-id "$BUNDLE_ID.dmg" \ --username "${{ inputs.apple-id }}" \ --password "${{ inputs.app-password }}" \ --file "$DMG_PATH" \ > "$WORK_DIR/dmg_notarization_output.txt" 2>&1 cat "$WORK_DIR/dmg_notarization_output.txt" | tee -a "$DEBUG_LOG_PATH" DMG_REQUEST_UUID=$(grep -o "RequestUUID = .*" "$WORK_DIR/dmg_notarization_output.txt" | cut -d ' ' -f3) if [[ -n "$DMG_REQUEST_UUID" ]]; then debug_log "DMG notarization request submitted, UUID: $DMG_REQUEST_UUID" # Wait for notarization to complete debug_log "Waiting for DMG notarization to complete..." TIMEOUT=10 # 10 minutes timeout for DMG COUNT=0 DMG_NOTARIZATION_STATUS="in progress" while [[ "$DMG_NOTARIZATION_STATUS" == "in progress" && $COUNT -lt $TIMEOUT ]]; do sleep 60 xcrun altool --notarization-info "$DMG_REQUEST_UUID" \ --username "${{ inputs.apple-id }}" \ --password "${{ inputs.app-password }}" \ > "$WORK_DIR/dmg_notarization_info.txt" 2>&1 cat "$WORK_DIR/dmg_notarization_info.txt" | tee -a "$DEBUG_LOG_PATH" DMG_NOTARIZATION_STATUS=$(grep -o "Status: .*" "$WORK_DIR/dmg_notarization_info.txt" | cut -d ':' -f2 | xargs) debug_log "DMG notarization status: $DMG_NOTARIZATION_STATUS" COUNT=$((COUNT+1)) done if [[ "$DMG_NOTARIZATION_STATUS" == "success" ]]; 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: $DMG_NOTARIZATION_STATUS" if [[ -f "$WORK_DIR/dmg_notarization_info.txt" ]]; then cat "$WORK_DIR/dmg_notarization_info.txt" fi fi else debug_log "DMG notarization submission failed, no UUID returned" cat "$WORK_DIR/dmg_notarization_output.txt" fi 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 shell: bash - name: Set outputs id: set-outputs run: | debug_log "Setting action outputs" # Pass through environment variables to outputs if [[ "$SIGNING_RESULT" == "true" ]]; then echo "signed=true" >> $GITHUB_OUTPUT elif [[ "$SIGNING_RESULT" == "ad-hoc" ]]; then echo "signed=ad-hoc" >> $GITHUB_OUTPUT else echo "signed=none" >> $GITHUB_OUTPUT fi if [[ "$NOTARIZATION_RESULT" == "true" ]]; then echo "notarized=true" >> $GITHUB_OUTPUT else echo "notarized=false" >> $GITHUB_OUTPUT fi echo "app-path=$APP_PATH" >> $GITHUB_OUTPUT echo "zip-path=$ZIP_PATH" >> $GITHUB_OUTPUT if [[ "$DMG_CREATED" == "true" ]]; then echo "package-path=$DMG_PATH" >> $GITHUB_OUTPUT else echo "package-path=$ZIP_PATH" >> $GITHUB_OUTPUT fi debug_log "Action completed" 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