Skip to content

GitHub Actions

The workflow below triggers on tag pushes matching v*, builds the firmware, then uploads the binary + manifest to SimpleOTA. The build step is yours (PlatformIO, idf.py, Arduino-CLI, or whatever you use); the upload step is what SimpleOTA needs.

Required secrets

Add these in Settings → Secrets and variables → Actions of the firmware repo (not the SimpleOTA repo):

Secret Value
SIMPLEOTA_BASE_URL e.g. https://simpleota.com
SIMPLEOTA_PROJECT_ID UUID of the project (visible in the dashboard URL).
SIMPLEOTA_TOKEN Project API token. Generate one in Project → Tokens.

Minimal pipeline

name: build-and-upload

on:
  push:
    tags: ['v*']

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      # ... your build steps that produce ./firmware.bin ...
      # Upload the APP image only. For arduino-cli that is the plain
      # <sketch>.ino.bin, NOT <sketch>.ino.merged.bin / .bootloader.bin /
      # .partitions.bin (those include the bootloader + partition table and
      # will not fit the OTA app partition). See dashboard/artifacts docs.

      - name: Upload to SimpleOTA
        env:
          SIMPLEOTA_BASE_URL:   ${{ secrets.SIMPLEOTA_BASE_URL }}
          SIMPLEOTA_PROJECT_ID: ${{ secrets.SIMPLEOTA_PROJECT_ID }}
          SIMPLEOTA_TOKEN:      ${{ secrets.SIMPLEOTA_TOKEN }}
        run: |
          MANIFEST_JSON=$(cat <<EOF
          {
            "framework": "arduino",            "version_label": "${{ github.ref_name }}",            "chip_family": "esp32",
            "board_id": "esp32dev",
            "partition_profile": "default_4mb",
            "nvs_schema_version": 1,
            "security_mode": "basic"
          }
          EOF
          )

          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]"

Signed uploads

For projects using signed firmware, CI signs the final binary with your project's private key and includes the signature in the manifest. Add one more secret:

Secret Value
SIMPLEOTA_SIGNING_KEY The private signing key PEM (shown once at key creation).

Then replace the upload step with:

      - name: Sign firmware (SimpleOTA Ed25519)
        env:
          SIMPLEOTA_SIGNING_KEY: ${{ secrets.SIMPLEOTA_SIGNING_KEY }}
        run: |
          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 (signed)
        env:
          SIMPLEOTA_BASE_URL:   ${{ secrets.SIMPLEOTA_BASE_URL }}
          SIMPLEOTA_PROJECT_ID: ${{ secrets.SIMPLEOTA_PROJECT_ID }}
          SIMPLEOTA_TOKEN:      ${{ secrets.SIMPLEOTA_TOKEN }}
        run: |
          MANIFEST_JSON=$(cat <<EOF
          {
            "framework": "arduino",
            "version_label": "${{ github.ref_name }}",
            "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": "$(cat firmware.sig.b64)"
            }
          }
          EOF
          )

          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]"

Replace prod-2026 with your project's key id. The signature must cover the final, shippable bytes: if your build also uses ESP32 Secure Boot, run the SimpleOTA signing step after espsecure appends its signature block (see Secure Boot notes). The server verifies the signature at upload; a wrong key or stale signature fails the job with a clear error.

Auto-creating a deployment

The example template stops at upload; promotion to a deployment is a separate, deliberate action. If you want one-click "upload + canary at 5%", follow the upload step with:

ARTIFACT_ID=$(... extract from previous 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}\",
    \"channels\": [\"beta\"],
    \"percentage\": 5
  }"

We deliberately leave promotion manual by default. See Promoting deployments for the recommended workflow.