GitHub Actions¶
SimpleOTA publishes a set of GitHub Actions that sign, upload and deploy
firmware for you, so a release pipeline is a handful of lines instead of a
page of openssl and curl. The build step stays yours (Arduino CLI,
PlatformIO, idf.py, whatever you already use); the actions pick up from
the compiled binary.
| Action | What it does |
|---|---|
xanderwasserman/simpleOTA-actions@v1 |
Sign, upload and deploy in one step. |
xanderwasserman/simpleOTA-actions/upload@v1 |
Sign and upload only. Returns an artifact id. |
xanderwasserman/simpleOTA-actions/deploy@v1 |
Create a deployment from an artifact id. |
xanderwasserman/simpleOTA-actions/promote@v1 |
Ramp, pause, resume, cancel or complete a deployment. |
If you would rather not depend on a third-party action, the raw API calls are still documented in Doing it by hand at the bottom of this page. The actions are a convenience wrapper over exactly those calls.
Credentials¶
Add these in Settings → Secrets and variables → Actions of your firmware repo.
| Name | Kind | Value |
|---|---|---|
SIMPLEOTA_TOKEN |
Secret | An API-scoped project token. Issue one from the Project API tokens card on the project page. |
SIMPLEOTA_SIGNING_KEY |
Secret | Ed25519 private key PEM. Only for signed firmware. |
SIMPLEOTA_PROJECT_ID |
Variable | Your project UUID. |
Project id is a variable, not a secret
A project id is not sensitive, and GitHub will happily let you add it in
either tab. Add it as a secret by mistake and it still resolves through
${{ vars.SIMPLEOTA_PROJECT_ID }} as an empty string, which produces a
malformed URL and a confusing 405 from the API. If you get a 405 on
upload, check this first.
Your project id is shown next to the project name in the dashboard, with a copy button. You do not need to dig it out of the URL bar.
Ship on every merge¶
The simplest useful pipeline: build, then hand the binary to the release action. It signs (if you gave it a key), uploads, and creates a deployment.
name: release
on:
push:
branches: [main]
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# ... your build steps, producing ./build/firmware.ino.bin ...
- uses: xanderwasserman/simpleOTA-actions@v1
with:
api-token: ${{ secrets.SIMPLEOTA_TOKEN }}
project-id: ${{ vars.SIMPLEOTA_PROJECT_ID }}
signing-key: ${{ secrets.SIMPLEOTA_SIGNING_KEY }}
key-id: prod-2026
binary: build/firmware.ino.bin
version-label: ${{ github.sha }}
chip-family: esp32
board-id: esp32dev
Upload the application image only. For Arduino CLI that is the plain
<sketch>.ino.bin, not .merged.bin, .bootloader.bin or
.partitions.bin: those contain the bootloader and partition table and will
not fit the OTA app partition. See Firmware
artifacts.
Upload now, deploy after approval¶
Most teams do not want every merge reaching devices. Split the two steps and put a GitHub environment with a required reviewer between them: the artifact is uploaded and waiting, and a human decides when it ships.
jobs:
build:
runs-on: ubuntu-latest
outputs:
artifact-id: ${{ steps.upload.outputs.artifact-id }}
steps:
- uses: actions/checkout@v4
# ... build ...
- id: upload
uses: xanderwasserman/simpleOTA-actions/upload@v1
with:
api-token: ${{ secrets.SIMPLEOTA_TOKEN }}
project-id: ${{ vars.SIMPLEOTA_PROJECT_ID }}
signing-key: ${{ secrets.SIMPLEOTA_SIGNING_KEY }}
key-id: prod-2026
binary: build/firmware.ino.bin
version-label: ${{ github.sha }}
chip-family: esp32
ship:
needs: build
runs-on: ubuntu-latest
environment: production # add a required reviewer here
steps:
- uses: xanderwasserman/simpleOTA-actions/deploy@v1
with:
api-token: ${{ secrets.SIMPLEOTA_TOKEN }}
project-id: ${{ vars.SIMPLEOTA_PROJECT_ID }}
artifact-id: ${{ needs.build.outputs.artifact-id }}
channel-ids: ${{ vars.SIMPLEOTA_STABLE_CHANNEL_ID }}
Canary, then ramp¶
Deploy to a small slice first, then widen it once the fleet looks healthy. The ramp is a separate, manually triggered workflow.
- id: canary
uses: xanderwasserman/simpleOTA-actions/deploy@v1
with:
api-token: ${{ secrets.SIMPLEOTA_TOKEN }}
project-id: ${{ vars.SIMPLEOTA_PROJECT_ID }}
artifact-id: ${{ needs.build.outputs.artifact-id }}
channel-ids: ${{ vars.SIMPLEOTA_STABLE_CHANNEL_ID }}
rollout-strategy: canary
rollout-percentage: '5'
Later, against that deployment id:
- uses: xanderwasserman/simpleOTA-actions/promote@v1
with:
api-token: ${{ secrets.SIMPLEOTA_TOKEN }}
project-id: ${{ vars.SIMPLEOTA_PROJECT_ID }}
deployment-id: ${{ inputs.deployment_id }}
action: ramp
rollout-percentage: '50'
action also accepts start, pause, resume, cancel and complete.
See Promoting deployments and Pausing
deployments for what each one means for devices already
checking in.
Signed firmware¶
Signing is not a separate step. Pass signing-key and key-id to the
release or upload action and it signs the binary before upload, in the
runner, then discards the key.
security-mode defaults to auto, which means signed when a key is
present and basic when it is not. Set it explicitly to signed if you
want the job to fail rather than silently upload an unsigned build when the
secret is missing or empty.
The signature covers the final, shippable bytes. If your build also uses
ESP32 Secure Boot, the SimpleOTA signature must be applied after espsecure
appends its own block: see Secure Boot
notes. The
server verifies the signature at upload, so a wrong key or a stale signature
fails the job with a clear error rather than bricking a fleet.
Inputs and outputs¶
api-token and project-id are required by every action. Beyond those:
| Action | Also required | Notable optional inputs | Outputs |
|---|---|---|---|
| root (release) | binary, version-label, chip-family |
framework (arduino), board-id, partition-profile, hardware-revision-min / -max, release-notes, signing-key, key-id, security-mode (auto), channel (stable), rollout-percentage (100), base-url |
build-number, artifact-id, deployment-id |
upload |
binary, version-label, chip-family |
same as above, minus the deployment inputs | build-number, artifact-id |
deploy |
artifact-id |
rollout-strategy (immediate), rollout-percentage (100), channel-ids, group-ids, notes, start (true) |
deployment-id, state |
promote |
deployment-id |
action (ramp), rollout-percentage |
state |
channel-ids and group-ids take comma-separated UUIDs, and the deploy
action requires at least one of them. It refuses to run with neither,
because a deployment with no audience matches no devices and could never
ship. (The root release action is different: it takes a channel name,
defaulting to stable, and resolves it for you.)
Fetch the UUIDs from the API once and store the one you want as a repository variable:
curl -s -H "Authorization: Bearer $SIMPLEOTA_TOKEN" \
"https://simpleota.com/api/v1/projects/$SIMPLEOTA_PROJECT_ID/channels/" \
| jq -r '.[] | "\(.name)\t\(.id)"'
base-url defaults to https://simpleota.com and only needs setting if you
are pointed at a different instance.
Pinning¶
@v1 is a moving tag: it follows the latest backwards-compatible v1 release,
so you pick up fixes without editing workflows. Pin a full tag (@v1.0.1) or
a commit SHA if you would rather review every change yourself.
Doing it by hand¶
The actions are a wrapper. If you would rather not add the dependency, this is the same work as plain shell steps.
- name: Sign firmware (Ed25519)
env:
SIMPLEOTA_SIGNING_KEY: ${{ secrets.SIMPLEOTA_SIGNING_KEY }}
run: |
set -euo pipefail
printf '%s' "$SIMPLEOTA_SIGNING_KEY" > private.pem
openssl pkeyutl -sign -inkey private.pem -rawin -in firmware.bin \
| openssl base64 -A > firmware.sig.b64
rm -f private.pem
- name: Upload to SimpleOTA
env:
SIMPLEOTA_BASE_URL: https://simpleota.com
SIMPLEOTA_PROJECT_ID: ${{ vars.SIMPLEOTA_PROJECT_ID }}
SIMPLEOTA_TOKEN: ${{ secrets.SIMPLEOTA_TOKEN }}
VERSION_LABEL: ${{ github.ref_name }}
run: |
set -euo pipefail
MANIFEST_JSON=$(jq -n \
--arg version "$VERSION_LABEL" \
--arg sig "$(cat firmware.sig.b64)" \
'{
framework: "arduino",
version_label: $version,
chip_family: "esp32",
board_id: "esp32dev",
partition_profile: "default_4mb",
nvs_schema_version: 1,
security_mode: "signed",
signing_metadata: {
key_id: "prod-2026",
algorithm: "ed25519",
signature: $sig
}
}')
curl --fail-with-body \
-X POST "${SIMPLEOTA_BASE_URL}/api/v1/projects/${SIMPLEOTA_PROJECT_ID}/artifacts/" \
-H "Authorization: Bearer ${SIMPLEOTA_TOKEN}" \
-F "manifest=${MANIFEST_JSON}" \
-F "[email protected]"
Drop signing_metadata and set security_mode to basic for an unsigned
upload. Use jq rather than a heredoc to build the manifest: interpolating
a version string straight into JSON breaks the moment it contains a quote.
Creating the deployment is a second call, against the artifact id from the upload response:
curl --fail-with-body \
-X POST "${SIMPLEOTA_BASE_URL}/api/v1/projects/${SIMPLEOTA_PROJECT_ID}/deployments/" \
-H "Authorization: Bearer ${SIMPLEOTA_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"artifact\": \"${ARTIFACT_ID}\", \"rollout_strategy\": \"canary\", \"rollout_percentage\": 5}"
Full request and response shapes are in the Developer API reference.
Related¶
- GitLab CI for the equivalent pipeline elsewhere
- Signed firmware for generating and rotating keys
- Promoting deployments for the rollout model
- Manifest schema for every manifest field