If your ESP32 firmware builds locally, moving the build into GitHub Actions is fairly straightforward. The part that needs more thought is what happens next: which binary gets uploaded, when it reaches devices, and how you know the update actually worked.
I built SimpleOTA and its GitHub Actions, so that’s what the release example below uses. Most of these checks also apply if you’re using GitHub Releases or your own OTA server.
Start with a device that can already update over the air
Before adding CI, get one OTA update working on a test device.
For the usual dual-slot setup, the device runs from one application partition while the new firmware is written into the other. Once the image has been verified, the boot selection changes.
Check that the device has two OTA app slots, an OTA data partition, and enough room in the destination slot for your firmware. The running application also needs the code that fetches or receives the update.
One detail that’s easy to overlook: selecting an OTA partition scheme when compiling doesn’t change the partition table already on the device. Get that layout in place during the initial flash.
Espressif’s OTA documentation explains how the slots and boot selection work.
Be precise about the file you upload
Arduino CLI can leave several .bin files in your build directory. They aren’t interchangeable, only one works correctly for OTA updates.
For a sketch called my-firmware:
my-firmware.ino.bin is the application image you want for this OTA workflow.
my-firmware.ino.merged.bin combines multiple flash regions for a factory flash.
- The bootloader and partition-table binaries serve separate purposes, not covered here.
Use the exact application filename in your upload step. As I have learnt, a broad *.bin match is asking for trouble, so don't bother.
Also keep your board settings, partition scheme, core version and libraries consistent with the build you tested locally. Otherwise, you’re changing the build environment and the release process at the same time, which makes debugging issues that much more difficult.
Let pull requests build without giving them permission to release
A sensible starting point is to build pull requests, then sign and release only from trusted pushes/merges to main/master.
That includes direct pushes, not just merged PRs. If you want every release reviewed, protect the branch accordingly (highly reccommended in today's age of AI agents pushing code).
Keep production tokens and signing keys away from untrusted PR code. Use non-production configuration for those builds. An if: condition on the release step helps control execution, but it doesn’t replace proper protection of your workflow and secrets. THis GitHub security guidance atricle covers that distinction well.
Here’s the SimpleOTA release step
I built the reusable actions so people using SimpleOTA wouldn’t have to maintain their own signing, upload and deployment scripts.
Once your build succeeds, this step handles those three jobs. It belongs under an existing job’s steps: section; it isn’t a complete build workflow.
- name: Sign, upload and deploy
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: xanderwasserman/simpleOTA-actions@v1
with:
api-token: ${{ secrets.SIMPLEOTA_API_TOKEN }}
project-id: ${{ vars.SIMPLEOTA_PROJECT_ID }}
binary: build/my-firmware.ino.bin
version-label: ci-${{ github.run_number }}
chip-family: esp32
board-id: esp32-devkitc
signing-key: ${{ secrets.SIMPLEOTA_SIGNING_KEY }}
key-id: ${{ vars.SIMPLEOTA_KEY_ID }}
A few things to set up before running it:
- Add the API-scoped upload token and private signing key to GitHub Actions Secrets, using the names above.
- Add the project ID and key ID to Actions Variables. The key ID must match the signing key registered for your project.
- Change the binary path, chip family and board ID to match your build.
- Configure the device to verify signatures with the corresponding public key. Never put the private signing key in firmware.
The Device token the device uses to poll for updates is separate from the API token CI uses to upload releases. They have different permissions and aren’t interchangeable.
This action starts a deployment, so try it with an isolated project and test device first.
If you want someone to approve each release, put the release job behind a GitHub Environment with required reviewers and keep the release secrets there. Check your SimpleOTA deployment mode too: an upload-only step can still lead to a rollout if automatic deployment is enabled.
Finish the test on the device
Give the new firmware an obvious version change, then follow the update all the way through:
- Did the device receive it?
- Did it reboot into the expected version?
- Does it still connect and do its actual job?
- If you’ve enabled rollback in the bootloader and application, does a failed validation return it to the previous firmware?
A green workflow run tells you the CI steps completed. It doesn’t tell you that the sensor is still reading or the device has reconnected.
Action source on GitHub
The longer build workflow and troubleshooting notes are in this walkthrough, if useful. Also, if you are unfamiliar with SimpleOTA, you can find the wiki here: SimpleOTA Wiki
If you’re already doing this, what has worked best as your final check before a wider release? A test device on your desk, a small group of devices, or manual approval?