Skip to content

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.

        with:
          signing-key: ${{ secrets.SIMPLEOTA_SIGNING_KEY }}
          key-id:      prod-2026

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.