Pear Docs

Publish with GitHub Actions

Auto-publish and update a Pear app from CI with the pear-ci GitHub Action—push to your repo and a stable pear:// link is staged for you.

The simplest way to publish and update a Pear app is to let CI do it. The pear-ci GitHub Action stages a directory from your repository into a Hyperdrive on every push, so shipping a new version is just a git push. There is no local pear touch or pear stage to run by hand. You do need one peer that stays online: every run syncs the drive from a peer before it stages, and waits for a peer to download the new release after, so keep pear seed running on an always-on machine.

This is the goal-oriented recipe. If you need the full-control flow—release lines, provision, and multisig—use Deploy your application instead. The two are not mutually exclusive: CI is the fast path for development and staging links, while the manual flow gives you production gating.

Need the pear CLI? Install it from install.pears.com, or prefix any command below with npx. See Install & upgrade for details.

How it works

The action wraps the pear-ci tool. On each run it:

  1. Installs pear-ci and fetches the committed ci/snapshot.json from the main branch.
  2. Joins Hyperswarm and syncs the drive from a connected peer up to the lengths recorded in the snapshot.
  3. Mirrors your target directory into a Hyperdrive whose key is derived deterministically from primary-key + namespace, then waits until a connected peer has downloaded every new block.
  4. Commits the updated ci/snapshot.json back to main (or opens a PR if the push is rejected).

Three properties shape how this works in CI:

  • The pear:// link is stable. Because the drive key comes from the secret primary key plus the namespace, every run stages to the same link. You set that link once as your app's upgrade field and every push updates it.
  • The snapshot replaces persistent storage. ci/snapshot.json records each core's length, so the next run only syncs what changed instead of re-uploading the whole drive or persisting a full Corestore between jobs.
  • A peer has to hold the drive. A fresh runner has no copy of the drive, so it depends on a peer twice: before staging, to sync up to the lengths the snapshot records, and after staging, to take the new release. Neither wait has a time limit, so a run with no peer online waits until the job times out. An always-on seeder fills that role.
git push to main pear-ci action fetch ci/snapshot.json sync drive from seeder stage target into Hyperdrive wait for seeder to download release commit updated snapshot.json stable pear:// link

Before you begin

You need:

  1. A GitHub repository for your app, with a branch named main.
  2. The directory you want to publish committed to that repo—typically the pear build deployment directory, your app source if you stage the runnable project directly, or a mobile OTA payload's dist/ (see Build the OTA payload).
  3. A 32-byte hex primary key (created below) stored as a repository secret.
  4. A namespace name (any string of letters, numbers, dots, hyphens, or underscores) that identifies this drive.
  5. An always-on machine with Pear installed, to seed the drive (see Keep a seeder online).

Generate and store the primary key

Generate a random 32-byte key:

openssl rand -hex 32
# 9f2c... (64 hex characters)

Add it to your repository as a secret named PEAR_PRIMARY_KEY (Settings → Secrets and variables → Actions → New repository secret).

The primary key is the write key for your drive—anyone who has it can publish updates that your users will receive over the air. Keep it in a repository (or organization) secret, never commit it, and rotate it if it leaks. Rotating the key changes the derived pear:// link, so you would need to update the upgrade field and re-onboard users.

Commit an empty snapshot

The action reads ci/snapshot.json from a branch named main before it stages, and commits the updated file back to main after. The action hard-codes the branch name. Before the first run, commit the file with an empty list:

mkdir -p ci
echo '[]' > ci/snapshot.json
git add ci/snapshot.json
git commit -m "Add the pear-ci snapshot"
git push origin main

Without this file, the action's Fetch snapshot from main step fails with gh: Not Found (HTTP 404).

Commit [] only once, before the first release. After that, the action keeps the file up to date, so never reset it to []. A run with an empty snapshot against a drive that already has releases writes a second history under the same key: pear-ci 0.1.2 logs [hypercore] conflict detected, still exits with code 0, and the seeder's view of the drive drops to length 0. pear-ci 0.1.2 can't recover from this state.

Add the workflow

Create .github/workflows/pear-ci.yml:

name: Publish with pear-ci

on:
  push:
    branches: [main]

# pear-ci commits the updated snapshot back to main
permissions:
  contents: write
  pull-requests: write

concurrency:
  group: pear-ci
  cancel-in-progress: false

jobs:
  stage:
    runs-on: ubuntu-latest
    # Ends a run that waits for a peer. Keep it well above your largest upload.
    timeout-minutes: 60
    steps:
      - uses: actions/checkout@v4
      - uses: holepunchto/actions/pear-ci@v1
        with:
          primary-key: ${{ secrets.PEAR_PRIMARY_KEY }}
          namespace: my-app
          target: dist
          dry-run: true

Set namespace to your chosen name and target to the repo-relative path of the directory you want to publish. The concurrency group prevents two runs from racing to commit ci/snapshot.json at the same time. The workflow starts with dry-run: true, and you switch it to false once a seeder is online.

timeout-minutes ends a run that waits for a peer that never comes. Without it, GitHub lets the job run for 360 minutes.

Set timeout-minutes well over the time your largest release takes to upload, and don't cancel a run once it prints starting stage for key. If a timeout or a cancel stops a run partway through its upload, the seeder records the release's new length but misses blocks that only the stopped runner had. pear-ci 0.1.2 can't recover from this state: later runs fail with Core <key> length (<n>) is higher than length in snapshot.json, and a snapshot edited to the new length leaves the run waiting at waiting synced remote peers until it times out.

Commit the workflow and push it to main. That push starts the first run.

For the full list of inputs and outputs, see the pear-ci GitHub Action reference.

Dry-run first

With dry-run: true, the action computes and logs the file-by-file diff without writing anything to the drive or committing a snapshot—the same safety check as pear stage --dry-run. On an empty drive, a dry run needs no peer.

Read the diff in the Actions log. Look for the files you expect, no surprise additions (.DS_Store, editor swap files, secrets), and sensible byte counts.

The dry run prints the drive key it stages to:

starting stage for key <drive-key>

That key is your app's pear:// link. Set it as the upgrade field so installed builds poll it for updates:

npm pkg set upgrade=pear://<drive-key>

This is the same upgrade link that the manual deploy flow sets—the only difference is that CI now stages to it for you. Because the key is deterministic, you only do this once.

Keep a seeder online

Before the first real run, start a peer that holds the drive. On a machine that stays online, seed the link and leave it running:

pear seed pear://<drive-key>

The seeder downloads each release while a run stages it. Every later run, dry runs included, first syncs the drive from it up to the lengths in ci/snapshot.json, so keep it running for as long as you publish from CI. For higher availability, seed the link on more than one machine (see Seeding with pear seed).

Then set dry-run: false in the workflow, commit, and push. In the job log, Stage should end with all remote peers synced, and Commit snapshot to main should commit the updated ci/snapshot.json. Every later push to main then stages and publishes automatically.

If no peer with the drive is online, the log stops at one of these lines until the job times out:

  • syncing <key> to length <n>: the run is waiting to sync the drive before it stages.
  • waiting synced remote peers for key <drive-key>: the run has staged and is waiting for a peer to download the release. It commits nothing.

Start the seeder, then run the job again.

Producing the build to stage

pear-ci stages whatever is in target—the action itself has no idea whether that directory came from a desktop or a mobile build. To build and sign a desktop directory on CI first, pair it with make-pear-app: one job produces signed, notarized distributables (see Build and sign desktop apps in CI), and pear-ci stages the assembled deployment directory to your stable pear:// link.

For a mobile OTA payload, point target at dist—the directory npm run update assembles—instead of a desktop deployment directory. There is no equivalent of make-pear-app for mobile signing yet, so the build step itself (bundling and pear build) still has to run somewhere before pear-ci sees dist; if you add that as its own CI job, remember the worker bundle is gitignored and needs an explicit rebuild step in CI too, not just before a local eas build.

See also

Last updated on

Was this helpful?

On this page