Automate / CI/CD automation

CI/CD Automation — ship OTA releases from GitHub Actions

PatchForge never builds your app — your CI builds the bundle, then hands it to PatchForge via the /api/v1 automation API (PAT-authenticated), the @zimbstech/patchforge-cli, or the patchforge/release-action composite Action. This guide covers all three, end to end.

Prerequisites

  • A PatchForge organization with an application and channel already created (see your PatchForge dashboard)
  • A GitHub repository with permission to add repository secrets
  • Your React Native app already builds a JS bundle locally (Metro / RN CLI)

Step 1 — Mint a Personal Access Token (PAT)

In the dashboard: Settings → API Keys → "Create API Key" → copy the plaintext token immediately (pf_... — it is shown once and never recoverable). Store it as a GitHub Actions repository secret, e.g. PATCHFORGE_TOKEN.

V1 PATs grant full-organization access (no per-app/per-permission scoping yet — V2).


Step 2 — Building your bundle

Your CI is responsible for producing the artifact — PatchForge only stores, signs, and distributes bytes it's given. Two artifact formats are supported:

  • Zip (recommended, SDK ≥ 0.2.7): a .zip containing index.<platform>.bundle at its root plus the Metro --assets-dest output. Ships images/fonts OTA and enables server-side differential patches between zip releases (devices download KBs instead of the full bundle).
  • Raw bundle (legacy): the single index.android.bundle / main.jsbundle file, uploaded verbatim. Works on every SDK version; no OTA assets, no patches.

Ship zip releases only to channels whose installed binaries carry SDK ≥ 0.2.7 — an older SDK would activate the zip as raw JS and crash (its own crash-loop recovery then reverts it, but the update never applies).

Build the zip artifact with Metro, e.g. for Android:

mkdir -p build/ota
npx react-native bundle \
  --platform android \
  --dev false \
  --entry-file index.js \
  --bundle-output build/ota/index.android.bundle \
  --assets-dest build/ota
(cd build/ota && zip -qr ../ota-bundle.zip .)

For iOS, swap --platform ios and --bundle-output build/ota/index.ios.bundle.

Pass the resulting build/ota-bundle.zip (or the raw .bundle file for legacy channels) to --bundle (CLI) or the bundle input (Action) in the next step.


Step 3 — Workflow YAML

Uses the patchforge/release-action composite Action (see actions/release/README.md). Branch→channel mapping is just a GitHub Actions if:/github.ref_name check — PatchForge has no built-in branch mapping, your workflow owns it:

name: OTA Release
on:
  push:
    branches: [main, staging]

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Build bundle
        run: |
          # your existing `react-native bundle` / metro build step
          echo "build the bundle to ./build/index.android.bundle here"
      - name: Determine channel
        id: channel
        run: |
          if [ "${{ github.ref_name }}" = "main" ]; then echo "name=Production" >> "$GITHUB_OUTPUT";
          else echo "name=Staging" >> "$GITHUB_OUTPUT"; fi
      - uses: ZimbsTech/patchforge/actions/release@main
        with:
          app: my-app
          channel: ${{ steps.channel.outputs.name }}
          bundle: ./build/index.android.bundle
          store-version: 1.4.0
          bundle-version: 1.4.0.${{ github.run_number }}
          api-token: ${{ secrets.PATCHFORGE_TOKEN }}
          server-url: https://patchforge.example.com

Step 4 — Or call the CLI directly (no Action)

npx @zimbstech/patchforge-cli@latest release \
  --app my-app --channel Production \
  --bundle ./build/index.android.bundle \
  --store-version 1.4.0 --bundle-version "1.4.0.$GITHUB_RUN_NUMBER" \
  --server https://patchforge.example.com \
  --api-token "$PATCHFORGE_TOKEN"

CLI reference

FlagRequiredDescription
--app <slug>yesApplication slug
--channel <name>yesChannel name
--bundle <path>yesPath to the built bundle
--store-version <v>yesStore/app version
--bundle-version <v>yesBundle version
--build-number <n>noBuild number
--release-type <type>noStandard | Hotfix | Mandatory
--notes <text>noRelease notes
--git-sha <sha>noCommit SHA
--source-ref <ref>noBranch/tag ref
--auto-deploy <bool>noOverrides the server default (see below)
--rollout <percent>noRollout percentage (1–100)
--wait-timeout <sec>noMax seconds to wait for processing (default 600)
--server <url> / PATCHFORGE_SERVERyesServer base URL
--api-token <token> / PATCHFORGE_TOKENyesYour PAT

Exit codes: 0 success (including an already-released duplicate), 1 failed release or HTTP error, 2 bad arguments.


Auto-deploy defaults

The server computes resolvedAutoDeploy on every create response: on by default for any channel whose environment type is not Production, off (manual, dashboard-only) for Production — unless you pass --auto-deploy true|false to override. The CLI polls the release to Ready, then deploys automatically only when resolvedAutoDeploy is true.


Idempotency and re-runs

Releases are unique per (application, channel, bundleVersion). Re-running a workflow with the same --bundle-version does not fail your build: the server returns 409 with the existing release, and the CLI treats an existing Ready release as success (exit 0) — it never re-uploads or re-deploys. Use a version scheme that changes per run (e.g. 1.4.0.${{ github.run_number }} — bundle versions must be x.y.z or x.y.z.w) if you want every push to ship a new bundle version.


Rate limits

Per PAT: create 10 req/60s, status polling 120 req/60s, deploy 30 req/60s. Exceeding a limit returns 429; the CLI treats this as a failed HTTP call (exit 1) — retries are your workflow's responsibility.