The Short Answer: Fixing the Arduino 2.3.6 Postbuild Error
When you are compiling custom firmware for an ESP32 and need to merge binaries, generate OTA payloads, or run custom Python scripts after compilation, the Arduino IDE 2.3.x build system (powered by arduino-cli under the hood) enforces strict path resolution and sandboxing. If your custom hook fails, you are likely hitting a path or executable alias issue introduced in the 2.x architecture.
Error running postbuild recipe: exec: "python": executable file not found in $PATHor
Error during build: fork/exec /scripts/postbuild.py: permission denied
The first three things to check when it fails:
- Executable Alias: Arduino IDE 2.3.x does not automatically map
pythontopython3on macOS/Linux. Yourplatform.txtmust explicitly call the resolved tool path. - Path Variables: Hardcoding
python3 postbuild.pyfails in the CLI sandbox. You must use the IDE’s internal variable:{runtime.tools.python3.path}. - File Permissions: If calling a shell script directly, ensure it has execute permissions (
chmod +x postbuild.sh) before triggering the build.
Target Board, Parts List, and Pin Mapping
To verify that our postbuild hook correctly processes the final .bin file and that the hardware flashes successfully, we will use a standard ESP32 development board with a basic status indicator. This proves the build pipeline and the physical silicon are both functioning.
Parts List
- Microcontroller: ESP32-WROOM-32 DevKit V1 (30-pin variant, CP2102 USB-UART bridge)
- Indicator: 5mm Red LED
- Current Limiting: 220Ω through-hole resistor (1/4W)
- Wiring: 22 AWG solid core jumper wires
- Software: Arduino IDE 2.3.x (with ESP32 Core v3.0.x installed via Board Manager)
Pin Mapping Table
| ESP32 Pin | Component | Destination / Notes |
|---|---|---|
| GPIO 2 | 220Ω Resistor | Connects to LED Anode (Long leg). GPIO 2 is the native boot-strapping pin and has an onboard LED on most DevKit V1s. |
| GND | LED Cathode | Connects to LED Cathode (Short leg). Use any available GND pin on the 30-pin header. |
| 5V / VIN | USB Power | Powered via the micro-USB port; do not backfeed 5V into the VIN pin while USB is connected. |
The Exact Error String & Ranked Causes
The migration from the legacy Java-based Arduino IDE 1.8.x to the modern 2.x architecture fundamentally changed how platform.txt recipes are executed. The build system now uses arduino-cli, which isolates toolchains and strictly enforces declared paths.
Here is the ranked decision path for diagnosing the exec: "python": executable file not found or permission denied errors:
| Rank | Root Cause | Diagnostic Check | The Fix |
|---|---|---|---|
| 1 | Hardcoded OS Executable | Open your core’s platform.txt and search for recipe.hooks.postbuild. Does it say python or python3? |
Replace with {runtime.tools.python3.path}/python3 (or the Windows equivalent .exe). |
| 2 | Missing Execution Rights | Run ls -l postbuild.py in your terminal. Is the ’x’ bit missing? |
Run chmod +x postbuild.py or wrap the call in python3 {build.path}/postbuild.py. |
| 3 | Sandbox Path Resolution | Does the script try to read a file using a relative path like ./firmware.bin? |
Use absolute IDE variables: {build.path}/{build.project_name}.bin. |
| 4 | Core Version Mismatch | Are you using an ESP32 core older than v2.0.0 on IDE 2.3.x? | Update the ESP32 board package to v3.0.x via the Board Manager. |
Step-by-Step Fix: Configuring the Postbuild Hook
Let’s implement a postbuild hook that automatically merges the ESP32 bootloader, partition table, and application binary into a single merged_ota.bin file at 0x0 offset, ready for web-based OTA updates. This is a common requirement for commercial IoT deployments.
Step 1: Locate your platform.txt
On Linux/macOS, navigate to ~/.arduino15/packages/esp32/hardware/esp32/3.0.x/. On Windows, check C:\Users\[User]\AppData\Local\Arduino15\packages\esp32\hardware\esp32\3.0.x\.
Step 2: Inject the Postbuild Recipe
Open platform.txt in a text editor. Scroll to the bottom and add the following hook. Notice the use of the {runtime.tools.python3.path} variable to satisfy the IDE 2.3.x sandbox requirements.
## Custom Postbuild Hook for ESP32 Bin Merging
recipe.hooks.postbuild.1.pattern="{runtime.tools.python3.path}" "{runtime.platform.path}"/tools/postbuild_merge.py "{build.path}" "{build.project_name}" "{runtime.platform.path}"/tools
recipe.hooks.postbuild.1.pattern.windows="{runtime.tools.python3.path}" "{runtime.platform.path}\tools\postbuild_merge.py" "{build.path}" "{build.project_name}" "{runtime.platform.path}\tools"
Step 3: Create the Python Script
Create postbuild_merge.py in the core’s tools directory. This script leverages Espressif’s official esptool to merge the binaries.
import sys, os, subprocess
build_path = sys.argv[1]
project_name = sys.argv[2]
tools_path = sys.argv[3]
# ESP32 standard memory map offsets
bootloader = os.path.join(build_path, "bootloader.bin")
partitions = os.path.join(build_path, "partitions.bin")
app_bin = os.path.join(build_path, f"{project_name}.bin")
output_bin = os.path.join(build_path, "merged_ota.bin")
cmd = [
sys.executable, "-m", "esptool", "--chip", "esp32", "merge_bin",
"-o", output_bin, "--flash_mode", "dio", "--flash_size", "4MB",
"0x1000", bootloader, "0x8000", partitions, "0x10000", app_bin
]
try:
subprocess.run(cmd, check=True)
print(f"[POSTBUILD] Successfully merged to {output_bin}")
except subprocess.CalledProcessError as e:
print(f"[POSTBUILD ERROR] esptool failed: {e}")
sys.exit(1)
Complete Compilable Test Code (ESP32 Watchdog)
Below is the firmware you will compile to test the pipeline. It targets the ESP32-WROOM-32 DevKit V1. It includes hardware watchdog initialization and explicit error handling to ensure the board doesn’t brownout or hang silently during boot—a common issue when power delivery is marginal on cheap USB cables.
/*
* Target Board: ESP32-WROOM-32 DevKit V1 (30-pin)
* Purpose: Postbuild Pipeline Verification & Hardware Watchdog Test
* Arduino IDE: 2.3.x | ESP32 Core: 3.0.x
*/
#include <Arduino.h>
#include <esp_task_wdt.h>
// --- PIN DEFINITIONS ---
#define STATUS_LED_PIN 2 // Native boot-strapping pin, usually has onboard LED
#define WDT_TIMEOUT_SEC 5 // Watchdog timeout in seconds
void setup() {
// Initialize Serial with a timeout to prevent hanging if USB isn't connected
Serial.begin(115200);
unsigned long serialTimeout = millis() + 2000;
while (!Serial && millis() < serialTimeout) {
delay(10);
}
Serial.println("[BOOT] ESP32 Postbuild Verification Starting...");
// Configure Status LED
pinMode(STATUS_LED_PIN, OUTPUT);
// Initialize Task Watchdog Timer (TWDT)
esp_err_t wdt_err = esp_task_wdt_init(WDT_TIMEOUT_SEC, true); // panic on timeout
if (wdt_err != ESP_OK) {
Serial.printf("[ERROR] Failed to init WDT: %d\n", wdt_err);
// Fallback: Blink rapidly to indicate WDT failure
while(1) {
digitalWrite(STATUS_LED_PIN, !digitalRead(STATUS_LED_PIN));
delay(50);
}
}
esp_task_wdt_add(NULL); // Subscribe current task to TWDT
Serial.println("[OK] WDT Initialized. Entering main loop.");
Serial.println("[INFO] If postbuild hook ran, check build folder for merged_ota.bin");
}
void loop() {
// Reset the watchdog timer to prevent reboot
esp_task_wdt_reset();
// Normal application logic
digitalWrite(STATUS_LED_PIN, HIGH);
delay(500);
esp_task_wdt_reset(); // Reset again before long operations
digitalWrite(STATUS_LED_PIN, LOW);
delay(500);
}
Decision Path: Which Postbuild Strategy to Choose?
Not every project requires merging binaries. Use this decision tree to determine the exact tool and strategy for your postbuild hook, terminating in a concrete recommendation for your specific scenario.
Postbuild Strategy Decision Matrix
| Your Goal | Required Tool | Complexity | Concrete Pick / Action |
|---|---|---|---|
Generate single OTA .bin |
Python + esptool |
Medium | Use the Python script provided above. It handles the 0x1000/0x8000/0x10000 offsets automatically. |
| Build LittleFS/SPIFFS image | mklittlefs |
High | Do not write a custom hook. Install the ESP32 Sketch Data Upload plugin or use PlatformIO. |
| Rename output with Git Hash | Bash/Batch | Low | Use a shell wrapper: cp {build.path}/{build.project_name}.bin {build.path}/fw_$(git rev-parse --short HEAD).bin |
| Inject build time into code | C++ Preprocessor | Low | Skip postbuild entirely. Use __DATE__ and __TIME__ macros in your C++ setup(). |
Default Recommendation: If you are building commercial IoT hardware on the ESP32 and need to deploy via a custom web server, always use the Python esptool merge_bin method. It guarantees the bootloader and partition table are correctly aligned for over-the-air updates without requiring the end-user to flash via USB.
Extending and Simplifying Your Build Pipeline
Once your Arduino 2.3.6 postbuild hook is functioning locally, you will eventually want to move away from the GUI to ensure reproducible builds across your team.
How to Simplify the Local Build
The Arduino IDE GUI caches old platform.txt files aggressively. If you edit the hook and it doesn’t trigger, do not restart your computer. Instead, delete the ~/.arduino15/packages/esp32/tools/ cache directory, or simply switch to the Arduino CLI for your terminal builds. The CLI respects platform.txt changes instantly upon the next arduino-cli compile command.
How to Extend to CI/CD (GitHub Actions)
To extend this pipeline to a production environment, strip out the IDE-specific variables and hardcode the paths in a GitHub Actions workflow. Use the official arduino/compile-sketches action, and add a secondary step that runs your Python merging script against the compiled artifacts. This removes the reliance on the local developer’s $PATH environment entirely, eliminating the executable file not found error permanently.
partitions.csv file. If you are using a custom partition scheme (like huge_app.csv), the application binary offset shifts from 0x10000 to 0x20000. Passing the wrong offset to esptool merge_bin will result in a board that flashes successfully but immediately boot-loops with a flash read err, 1000 panic in the serial monitor.






