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:
- Installs
pear-ciand fetches the committedci/snapshot.jsonfrom themainbranch. - Joins Hyperswarm and syncs the drive from a connected peer up to the lengths recorded in the snapshot.
- Mirrors your
targetdirectory into a Hyperdrive whose key is derived deterministically fromprimary-key+namespace, then waits until a connected peer has downloaded every new block. - Commits the updated
ci/snapshot.jsonback tomain(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'supgradefield and every push updates it. - The snapshot replaces persistent storage.
ci/snapshot.jsonrecords 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.
Before you begin
You need:
- A GitHub repository for your app, with a branch named
main. - The directory you want to publish committed to that repo—typically the
pear builddeployment directory, your app source if you stage the runnable project directly, or a mobile OTA payload'sdist/(see Build the OTA payload). - A 32-byte hex primary key (created below) stored as a repository secret.
- A namespace name (any string of letters, numbers, dots, hyphens, or underscores) that identifies this drive.
- 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 mainWithout 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: trueSet 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.
Wire up the pear:// link
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
pear-ciGitHub Action reference—every input, output, and the underlying CLI.- Holepunch GitHub Actions—the other shared actions, including
make-pear-app. - Build and sign desktop apps in CI—build and sign the artifacts
pear-cistages. - Deploy your application—the full manual flow with release lines, provision, and multisig.
- Ship your app—the type-along walkthrough of staging by hand.
- Configuration—the
upgradefield and otherpackage.jsonsettings. - Release pipeline—the conceptual picture of staging and OTA updates.
- Troubleshoot desktop releases—when an update does not reach peers.
Last updated on