supply uploads every locale folder under fastlane/metadata/android in one
edit. Play rejects the F-Droid-only ja locale, which failed the whole edit in
the 0.14.0 release ("ja - Invalid request"), so no screenshots were replaced.
Stage only the en-US images, add a standalone workflow to rerun the upload for
the current release, and document the App Store associatedErrors gotcha.
57 KiB
Release
All workspaces share one version and release together.
Two steps
A release has exactly two steps. The agent does the first, the user authorizes the second.
Preparation (reversible — agent does this):
- format, lint, typecheck all green
- resolve the release base, get the release inventory approved when one applies, and confirm CI for the resulting release source is green
- classify the diff from the previous stable to the release source as patch or minor, then show the target version and rationale to the user
- draft the changelog, show it to the user, wait for review
- run the pre-release sanity check, surface findings to the user
Go-ahead (user says "go ahead"):
- commit the approved release inputs locally on the release branch
- run the release, which publishes npm and pushes the release branch and tag
- create the release heartbeat immediately and babysit it to completion
- reconcile
mainwith what shipped
Rules that apply to both steps:
- Last-minute changes always need approval. Every time.
- No code changes bundled into the changelog commit or the release commit. Code shims live in their own commit, reviewed on their own merits.
- A sanity-check finding is information, not a directive. The agent surfaces it; the user decides.
- Invoking a release skill is intent to start the flow, not blanket authorization to publish.
- An approval the user already gave covers what it names, such as an explicit PR selection or authorization to release autonomously. Do not ask for it again.
- If the user asks for a release preview, show the prospective changelog/release contents and answer questions, but do not commit, tag, publish, or run release commands until they explicitly authorize the release.
Main stays open
Merge normal PRs into main at any time, including while a beta soaks or a release is being
finalized. Each release builds in a short-lived release workspace, and its published tag records
the shipped source.
Choosing a path
The user decides when the changes on main warrant a release and how much risk it takes:
- Direct stable release for a low-risk batch of polish and bug fixes. Stable desktop updates already ramp over 36 hours (see Staged rollout), and store review often takes about a day, with no guaranteed timing.
- Beta flow, the default for changes that need testing. Release candidates on the
betachannel. Each beta folds into the series' single changelog entry, publishes npm only on the explicitbetadist-tag, and stays behind the Stable/Beta switch on/download.
A beta follows these defaults, which the user can explicitly override:
- Soak a beta at least 24 hours before promotion. Soak longer for risky or breaking changes, or while feedback is open. Changes added in a later beta get their own soak; earlier soak time does not count for them.
- A follow-up beta carries regressions, fixes, and polish for what that beta delivers. Unrelated
work on
mainand pre-existing bugs wait for a later release unless the user explicitly selects them. - Promotion ships the tested beta's product content unchanged and does not pull from
main. Adding a product change needs the user's explicit selection and a new beta, which soaks again. - During a soak, ship urgent stable regressions and compatibility or provider model updates as a stable hotfix from the latest stable tag. The beta keeps its own pace.
npm dist-tags are independent pointers:
- A beta release publishes with
--tag betaand moves onlybeta;latestremains on the newest stable. - A stable release publishes to
latest. Movebetato it as well when the stable version is newer than the versionbetapoints to, which covers promotions and direct stable releases, so@getpaseo/cli@betausers never sit behind stable. A stable hotfix on an older line leavesbetaon the active newer beta.
Release base and source
The release base is the tag or commit a release workspace starts from. The release source is the base plus any approved cherry-picks, and every source, diff, and CI check uses it.
| Release | Base | Changes on top |
|---|---|---|
| Starting release: first beta of a line, or direct stable | origin/main, captured once |
None |
| Follow-up beta | The selected published beta tag, normally the series' latest | Approved commits from main |
| Promotion | The beta tag being promoted | None by default |
| Stable hotfix | The latest appropriate stable tag | Approved commits from main |
An explicit user instruction can select another base.
- Fetch
originwith tags and resolve the base to one commit. - Create the release workspace through Paseo MCP on a new branch from the base, named
release/vX.Y.Zorrelease/vX.Y.Z-beta.Nfor the version being cut. Run the release from this workspace:release:pushpushesHEADto the current branch. - Cherry-pick the approved commits in their
mainorder withgit cherry-pick -x, so each commit records themaincommit it came from. Include the prerequisites the inventory listed. Resolve conflicts within the approved changes and report how. A resolution that needs new behavior is a scope addition: surface it for selection. - Confirm CI for the release source. When the source is a commit whose CI already passed (the
captured
origin/main, or the beta tag being promoted), use that run and watch pending CI to completion. Otherwise push the release branch and rungh workflow run ci.yml --ref <branch>, because CI push runs target onlymain. - Keep the changelog, any explicitly requested ACP catalog update, lockfile preparation, and the
version commit local until the go-ahead. The release command pushes the release branch and tag
as one batch, and the tag push starts the release workflows. Then run
gh workflow run ci.yml --ref <branch>so CI covers the complete release commit. - Reconcile
main.
A stable hotfix can take the version a patch beta line would promote to, such as 0.13.3
shipping while 0.13.3-beta.2 soaks. Never republish or reuse a shipped version. Continue the line
as the next unused patch beta, 0.13.4-beta.1, from the tested beta tag. No version:all:* mode
computes that from a prerelease, so follow the manual steps with
npm version 0.13.4-beta.1 --include-workspace-root --message "chore(release): cut %s" and
npm run release:publish:beta. Record the tested beta tag as its source so its soak still counts
for the unchanged content.
Release inventory
When the user asks for a follow-up beta or a promotion, list what main has that the base does
not, then get the batch approved. There is no release label or queue.
-
Find the commits on
origin/mainwhose patches are not in the base tag:git log --cherry-pick --right-only --no-merges --format='%h %s' <base-tag>...origin/mainPatch equivalence recognizes commits already cherry-picked into the tag, so trust the tag content over PR state or branch names.
-
Present a short include/defer list. Link each PR and give one plain reason. List separately the prerequisites an included PR depends on and anything that adds scope beyond what the beta delivers. For each fix that shipped in a stable hotfix after the beta's base, confirm the beta preserves its behavior, directly or through a superseding change. List the missing ones for explicit selection; adding one to a promotion needs a new beta.
-
Wait for the user to approve the batch, unless an explicit selection or autonomous authorization they already gave covers it.
For a promotion, every item defaults to defer.
Reconcile main
After the release push, open a PR into main that brings in what shipped and keeps newer
development on main:
- The shipped
CHANGELOG.mdandCHANGELOG.zh-CN.mdentries, exactly as tagged. A beta or promotion entry replaces the series entry onmain. A stable hotfix on an older line goes below any newer entries. - The shipped version in every
package.jsonandpackage-lock.json, unlessmainalready carries a newer version, plus the F-Droid changelog files a stable release generates. - No claims about unshipped work: a shipped entry describes only the tagged content, and notes
mainholds for changes that have not shipped stay out of it.
Delete the release branch once the PR merges. The tag is the record of shipped source; Paseo
maintains no release track besides main.
ACP catalog updates
ACP catalog work enters a release through an explicit user request:
- Check ACP drift — run
npm run acp:version-drift:check. When drift exists, runnpm run acp:version-drift:update, verify the catalog, and include the update in the local release-preparation commits. - Update ACP — run
npm run acp:version-drift:update, verify the catalog, and include the update in the local release-preparation commits.
The release authorization covers the requested ACP commit. It ships in the same release push as the changelog and version commit.
Release version decision
Every fresh release starts by classifying the full diff from the previous stable to the resolved release source. The highest-impact change determines the version:
- Minor — a user would experience the release as a significant upgrade. This includes substantial new workflows, providers, forges, platforms, integrations, or meaningful expansions of existing capabilities. Foundational internal work also qualifies when it materially changes reliability, performance, compatibility, deployment, or operation; diff size alone does not.
- Patch — fixes, polish, small enhancements, and reliability or performance improvements within existing capabilities. Follow-up corrections to a minor release are patches.
The release agent selects patch or minor during preparation and presents the target version with the changelog for approval. Agents never select a major version autonomously. A major release requires an explicit user instruction and approval; Paseo remains on major version zero until that deliberate decision.
Version bumps are never used to retry a failed build. Retry the existing version as described in Fixing a failed release build.
Standard release (stable)
Before running any stable release command:
- Make sure the release source passed CI, the approved release inputs are committed locally on the release branch, and the working tree is clean.
- Run
npm run format,npm run lint, andnpm run typecheckand commit any resulting changes BEFORE you start anyrelease:*command.release:checkrunsnpm install --workspaces --include-workspace-rootas part ofrelease:prepare, which can mutatepackage-lock.json(e.g. churning"dev": truemarkers on optional deps). The next step,version:all:*, runsnpm versionwhich aborts when the working tree is dirty. If this happens mid-flight you have to commit the lockfile churn before retrying — and the pre-commit format hook will reject a lockfile-only commit because oxfmt internally skipspackage-lock.jsonwhile lefthook's glob still matches it. Avoid the whole mess by running format/lint/typecheck first, thenrelease:prepareonce on its own to absorb any lockfile churn into a normal commit, then start the release. - Do not use a release command as a substitute for checking whether the current commit is actually ready.
# Run exactly one, matching the approved decision:
npm run release:patch
npm run release:minor
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag. The tag push triggers Desktop Release, Android APK Release, Docker, and Release Notes Sync on GitHub Actions. The workflows create the GitHub Release as a draft while builds and release-note sync run. EAS picks up the same tag via the EAS GitHub app and starts the iOS + Android store builds in parallel (see "Mobile builds (EAS)" below) — there is no mobile-release workflow under .github/workflows.
After the stable release succeeds, check npm view @getpaseo/cli dist-tags. When the new stable
is newer than beta, move beta to it for every published package. This changes dist-tags only;
do not republish the packages:
PASEO_VERSION=$(node -p "require('./package.json').version")
for package in highlight relay protocol client plugin server cli; do
npm dist-tag add "@getpaseo/$package@$PASEO_VERSION" beta
done
Verify latest resolves to PASEO_VERSION, and beta resolves to PASEO_VERSION or the
active newer beta, before considering the stable release complete.
The Docker workflow builds images from the checked-out source tree on pull requests and on main as non-publishing checks. Stable vX.Y.Z tag pushes publish ghcr.io/getpaseo/paseo:X.Y.Z and ghcr.io/getpaseo/paseo:latest; beta vX.Y.Z-beta.N tag pushes publish only ghcr.io/getpaseo/paseo:X.Y.Z-beta.N and never move latest.
The production relay is the Elixir service in getpaseo/paseo-relay, with its own deployment process. Paseo releases and pushes to this repository do not deploy it. The Cloudflare relay code and workflow in this repository are legacy and are not used in production.
Stable means stable. If the user says "stable" or "ship stable", do not ask whether they want a beta first. They picked stable; treat it as a direct stable release. Only run the beta flow when the user explicitly says "beta".
Manual step-by-step
npm run typecheck # Verify the exact commit you intend to release
npm run release:check # Typecheck, build, dry-run pack
# Run exactly one approved version command:
npm run version:all:patch
npm run version:all:minor
npm run release:publish # Publish to npm
npm run release:push # Push HEAD + tag (triggers CI workflows)
# Then move npm's beta dist-tag to this stable version when it is newer than beta (see above).
Beta flow
npm run release:beta:patch # Start the next patch beta line
npm run release:beta:minor # Start the next minor beta line
# ... test desktop and APK prerelease assets from GitHub Releases ...
npm run release:beta:next # Optional: cut X.Y.Z-beta.2, beta.3, ...
npm run release:promote # Promote X.Y.Z-beta.N to stable X.Y.Z
- Beta tags are published GitHub prereleases like
v0.1.41-beta.1 - Betas publish npm packages with
--tag beta, sonpm install @getpaseo/cli@betaopts in while plainnpm install @getpaseo/clistays onlatest - Betas publish desktop assets and APKs for testing. They also build iOS, upload it to TestFlight, add it to the
Paseo Betaexternal group, and submit it for Beta App Review. They do not submit mobile builds to the production stores. release:promotecreates a fresh stable tag likev0.1.41; the final release never reuses the beta tag- Desktop assets now come from the Electron package at
packages/desktop - Require the Linux artifact CI checks with both restricted and usable user namespaces to pass before publication; see packaged desktop smoke. Keep the installed-package and AppImage checks together.
- Beta releases use Electron's
betaupdate channel. Users on the stable channel only receive stable releases; users on the beta channel receive beta releases and the final stable release when it is published. - A beta series has one changelog entry. Each beta folds into it and renames its heading to the new
## X.Y.Z-beta.N, whichRelease Notes Syncmirrors into that prerelease body. Promotion renames it to the stable heading. See the Changelog policy section.
Use the beta path when you need to:
- smoke a build yourself before promoting it to everyone
- test a build manually in a Linux or Windows VM
- send a build to a user who is hitting a specific problem
- iterate on
beta.1,beta.2,beta.3, and so on before deciding to ship broadly
Staged rollout (stable channel)
Stable desktop releases go out via a linear time-based rollout for automatic update checks: 0% admitted when the updater manifests appear, 100% admitted 36 hours later, linear ramp in between. Manual checks bypass the rollout so a user can install immediately when they click Check. Beta releases bypass the rollout entirely — beta users always receive updates immediately.
The rollout is driven by a rolloutHours field stamped into the GitHub Release manifests (latest-mac.yml, latest-linux.yml, latest.yml) by the finalize-rollout job in desktop-release.yml.
Desktop release builds now publish in two phases:
- The GitHub Release stays a draft while platform build jobs upload the installers/packages (
.dmg,.zip,.exe,.AppImage, etc.). - The final job merges and stamps every channel manifest, uploads them with the final
releaseDateandrolloutHours, then publishes the GitHub Release.
Drafts do not appear in GitHub's releases feed. Updater clients continue to see the previous complete release until all three manifests are available. If a desktop build or manifest upload fails, the new release stays a draft.
Default behavior
npm run release:patch or npm run release:minor → tag push → 36h ramp. No extra action needed.
The rollout_hours input on desktop-release.yml is only read on workflow_dispatch — tag-push runs always default to 36. To get any other rollout duration on a fresh release, use the post-publish flip below.
Instant-admit release (rollout_hours=0 from publish)
For a fresh release that should admit everyone immediately (low-risk change, doc-only, hotfix, or just a release you want out fast), cut the release normally and queue the rollout flip immediately after:
# 1. Cut and publish (default 36h ramp from tag push).
npm run release:patch
# 2. Immediately queue the flip — runs as soon as finalize-rollout completes.
gh workflow run desktop-rollout.yml \
-f tag=v0.1.64 \
-f rollout_hours=0
Why this is gap-free: desktop-release.yml's finalize-rollout job and desktop-rollout.yml share the concurrency group desktop-rollout-<tag>. Dispatching desktop-rollout.yml while the tag-push pipeline is still running queues it safely behind finalize-rollout. The first public manifests already carry rolloutHours=36, then desktop-rollout.yml flips them to rolloutHours=0 shortly afterward. The renderer polls every 30 minutes, so active stable users pick up the new manifest on their next check.
Run the dispatch right after release:patch or release:minor returns. Don't wait for the tag-push CI to finish.
Adjusting an already-published release
To change the rollout duration on a release that's already shipped — e.g. flip a hotfix to instant admit, or slow a release down — use the dedicated desktop-rollout.yml workflow. It edits the manifests in place on the GitHub release without rebuilding anything. It only rewrites rolloutHours; releaseDate is preserved, so the rollout clock keeps ticking from the original publish time.
Hotfix (instant admit) on an already-shipped release:
gh workflow run desktop-rollout.yml \
-f tag=v0.1.42 \
-f rollout_hours=0
rollout_hours=0 admits 100% of stable users on their next update check (within ~30 min for active clients).
Slow a rollout down (e.g. extend total duration to 72h since the original release):
gh workflow run desktop-rollout.yml \
-f tag=v0.1.42 \
-f rollout_hours=72
rollout_hours is total duration since the original release date, not "extend by N more hours from now." If v0.1.42 was published 2h ago and you set rollout_hours=72, the ramp finishes 70h from now.
The dispatch is idempotent and shares the desktop-rollout-<tag> concurrency group with desktop-release.yml's finalize-rollout job, so it serializes safely against an in-flight tag-push pipeline targeting the same release.
Custom ramp on a manually-dispatched build
desktop-release.yml accepts rollout_hours only on workflow_dispatch, which is the path used to rebuild an existing tag (retry a failed release, force a rebuild on a different ref). When you go that route, you can stamp a non-default ramp directly:
gh workflow run desktop-release.yml \
-f tag=v0.1.43 \
-f rollout_hours=6
This does not apply to fresh releases cut via npm run release:patch or npm run release:minor — those paths always tag-push and stamp 36. For a fresh release with a custom ramp, cut normally and then dispatch desktop-rollout.yml (same pattern as the instant-admit flow above, with your chosen rollout_hours).
Releasing during an active rollout
If you ship N+1 while N is still ramping, N+1 starts a fresh rollout from its own publish timestamp. N's rollout effectively ends — the newer manifest supersedes it. Rollout-aware clients revalidate the manifest for up to five seconds before installing a downloaded update on quit. If N+1 has replaced N but the client is not admitted to N+1 yet, it skips the downloaded N and waits rather than installing two updates in succession. If revalidation times out, the app exits without installing the cached update.
If N+1 is a hotfix for a bug in N, dispatch desktop-rollout.yml -f tag=v0.1.<N+1> -f rollout_hours=0 after N+1 publishes so the users who already got N reach the fix fast.
macOS system floor
The desktop app requires macOS 13 or newer. Keep both release guards when the floor changes:
packages/desktop/electron-builder.ymlwrites the macOS version toLSMinimumSystemVersionfor new installs.scripts/merge-mac-manifest.mjswrites the matching Darwin kernel version tominimumSystemVersionin the update manifest. Existing clients check this before downloading an update.
macOS 13 maps to Darwin 22. The two values use different version domains; do not copy the macOS version into the update manifest.
Limitations
- No pause / kill switch. To stop new admissions, ship a superseding release. Clients revalidate on quit and will not install the superseded download, but a client that already completed installation cannot be recalled; ship a hotfix
+1patch. - No rollback.
allowDowngrade = false. Bad release = ship a hotfix. - Bootstrap caveat. Clients running a build older than the rollout feature ignore
rolloutHoursand admit immediately. Rollout protection only applies to clients running the rollout-aware version or later. - Up to ~30 min automatic admission latency. Renderer polls every 30 minutes, so a stable user may take up to that long to be evaluated against the rollout window. Clicking Check is manual and bypasses rollout admission.
Mobile builds (EAS)
iOS and Android store builds are not in .github/workflows. They are triggered by the EAS GitHub app the moment the v* tag is pushed:
- Android (Play Store) — EAS builds with profile
productionand auto-submits to the Play Store viaeas submit(EAS-managed credentials). A Fastlane lane then replaces the store screenshots. - iOS (TestFlight + App Store) — EAS builds with profile
production, uploads once through atestflightjob, distributes the build to thePaseo Betaexternal group with Beta App Review submission, and a Fastlane lane uploads the store screenshots and submits that same build for App Store review. - Android APK (GitHub Release asset) — separate, via
.github/workflows/android-apk-release.yml. This is the only Android-related workflow that lives in this repo.
EAS uses the local app version source. packages/app/app.config.js derives the native version from the package version. Android versionCode is major * 1_000_000 + minor * 1_000 + patch. iOS reserves 1,000 build slots per app version: beta N uses slot N, and stable uses slot 999. For example, 0.2.6-beta.2 appears in App Store Connect as version 0.2.6 build 2006002; stable uses build 2006999. Rebuilding the same tag produces the same native build number; if a store has already accepted a binary and you need a different binary, cut the next beta or patch instead of relying on EAS remote auto-increment.
Stable tags run Release Mobile; beta tags run Release iOS Beta. Both distribute to the persistent Paseo Beta external group and submit for Beta App Review, so testers receive stable releases as well as betas. Testers, the group, and required TestFlight test information are managed once in App Store Connect.
Stable iOS releases use one binary for two Apple review paths: Beta App Review for external TestFlight distribution and App Store review. Apple says the first submitted build requires a full TestFlight review; later builds for the same version might not. Do not assume a stable patch skips review.
There is no mobile-release workflow under .github/workflows. The EAS GitHub app reads the workflows under packages/app/.eas/workflows and handles tag triggering directly.
Store screenshots
Each stable release publishes the screenshots committed under assets/store-screenshots/output/ (see its README for how they are made). To change the store screenshots, re-render and commit them before you tag.
- App Store:
submit_reviewuploadsoutput/apple/andoutput/apple-ipad/to the version it submits, replacing that version'sen-USscreenshots. Apple ties screenshots to an app version, so a version that is already live keeps the ones it shipped with.Resubmit iOS for App Store reviewuploads them again.- When
submit_reviewfails withappStoreVersions … is not in valid state, the reason is in the API error'smeta.associatedErrors, which fastlane does not print. Read it with aPOST /v1/reviewSubmissionItemsagainst the version. A required age-rating question that Apple added, such associalMedia, fails this way; answer it under App Information > Age Rating in App Store Connect, then resubmit.
- When
- Google Play:
upload_android_screenshotsrunsfastlane android upload_screenshotsaftersubmit_android. It replaces only theen-USphone and 10-inch tablet screenshots, never the listing text or changelogs, and skips images whose checksum already matches. The lane stages onlyen-US: supply uploads every locale folder in one edit, and Play rejects F-Droid locales such asja, which fails the whole edit.Upload Google Play screenshotsreruns the step for the current release without a rebuild. - F-Droid: reads the same files as Google Play.
fastlane/metadata/android/en-US/images/phoneScreenshots/andtenInchScreenshots/hold symlinks to the Google renders.render.mjsrewrites them on every render, so added or renamed panels reach Google Play and F-Droid as well.
Fastlane cannot use the Play key Expo stores for eas submit. The Play lane reads its own copy of the same service-account key from the GOOGLE_PLAY_JSON_KEY file secret in the EAS production environment, and that service account needs the Play Console permission Manage store presence.
Watching mobile builds from the terminal
Use the EAS CLI from packages/app/:
cd packages/app
# Recent builds (newest first). Pipe to jq for status only.
npx eas build:list --limit 8 --non-interactive --json | jq '.[] | {platform, status, appVersion, gitCommitHash}'
# Recent EAS workflow runs. This is the source of truth for submit/review jobs.
npx eas workflow:runs --json | jq '.[] | {status, workflowName, trigger, gitCommitHash, startedAt, finishedAt}'
# Filter by platform.
npx eas build:list --platform ios --limit 5 --non-interactive --json
npx eas build:list --platform android --limit 5 --non-interactive --json
# Inspect a specific build.
npx eas build:view <build-id>
# Inspect the full release workflow, including distribute_ios, submit_android,
# and submit_ios_for_review.
npx eas workflow:view <workflow-run-id> --json
# Read failed submit/review job logs.
npx eas workflow:logs <workflow-job-id> --all-steps --non-interactive
# Stream logs for a build.
npx eas build:view <build-id> --json | jq '.logFiles[]'
A build's gitCommitHash must match the release tag commit. status walks through NEW → IN_QUEUE → IN_PROGRESS → FINISHED (or ERRORED/CANCELED). The EAS workflow run's gitCommitHash and trigger must also match the release tag.
Once a build is FINISHED, EAS still has release-critical work to do: Android must submit to the Play Store, and iOS must distribute to the external TestFlight group with Beta App Review submission and submit the same build for App Store review. The release is not done until all platforms are on their way through the stores.
For the Release Mobile EAS workflow, these jobs must pass:
build_ios— iOS binary builtdistribute_ios— iOS binary uploaded once, added toPaseo Beta, and submitted for Beta App Reviewsubmit_ios_for_review— iOS build submitted for App Store review via Fastlanebuild_android— Android store binary builtsubmit_android— Android binary submitted to the Play Storeupload_android_screenshots— Google Play screenshots replaced from the repo
Do not treat build_ios: SUCCESS or distribute_ios: SUCCESS as a completed iOS release. submit_ios_for_review: FAILURE means the iOS release is blocked even if the build is visible in TestFlight.
To confirm the submission landed, inspect the EAS workflow with npx eas workflow:view <workflow-run-id> --json. App Store Connect (review state for the matching version/build) and the Play Console track are the final ground truth.
Release completion and heartbeat
A release is in progress after npm publication and tag push. Report it as shipped only after every applicable build, publication, asset, manifest, and store submission passes the completion checklist.
Immediately after every beta, stable, or promotion tag push, create a heartbeat
that resumes the release in the current conversation. Create it automatically
with create_heartbeat. The heartbeat owns the release until it either reaches
the completion checklist or finds a failure that needs new user authority.
Each heartbeat checks the release tag commit, all GitHub Actions runs for the
release branch and tag, npm dist-tags, the GitHub Release body and assets,
desktop updater manifests, the published Docker image, and the applicable EAS
workflow. Inspect the GitHub Release itself and confirm that the macOS, Linux,
Windows, and Android APK assets are present along with the channel manifests
(latest-mac.yml, latest-linux.yml, and latest.yml for stable;
beta-mac.yml, beta-linux.yml, and beta.yml for beta).
For stable releases, also confirm every required mobile build, external TestFlight distribution, Beta App Review submission, and store submission job for the release commit. For betas, confirm the beta EAS workflow completed its TestFlight distribution and Beta App Review path. Delete the heartbeat only after every applicable checklist item passes, then report the release as shipped.
Pattern:
// mcp__paseo__create_heartbeat arguments
{
"name": "vX.Y.Z release babysit heartbeat",
"cron": "*/10 * * * *",
"timezone": "UTC",
"maxRuns": 120,
"expiresIn": "24h",
"prompt": "Resume the vX.Y.Z release babysit for commit <sha>. Check npm tags; every GitHub Actions run for the release branch and tag; the published GitHub Release body, expected desktop/APK assets, and channel manifests; the Docker image; and the matching EAS workflow. Completion requires every applicable checklist item. For stable, require build_ios, distribute_ios (Paseo Beta external distribution and Beta App Review submission), submit_ios_for_review, build_android, and submit_android to succeed. For beta, require the beta TestFlight distribution and Beta App Review path. If work is pending, wait for the next heartbeat. If a failure can be retried safely for the same version, follow the failed-release procedure; otherwise report the blocker. When every applicable completion-checklist item passes, delete THIS heartbeat, report shipped, and stop.",
}
Run an immediate status check after creating the heartbeat. The heartbeat handles later transitions and stops itself when the release is complete.
Release notes on GitHub
The GitHub Release body is populated automatically by the Release Notes Sync workflow (.github/workflows/release-notes-sync.yml). It triggers on every v* tag push and on any push to main that touches CHANGELOG.md, then runs scripts/sync-release-notes-from-changelog.mjs to mirror the matching changelog entry into the release body. You don't need to write release notes on GitHub manually — keep CHANGELOG.md correct and the workflow will sync it. To force a re-sync, dispatch the workflow with the tag input.
Website behavior
- The website download page defaults to GitHub's latest published stable release.
- A published beta prerelease is offered behind the Stable/Beta switch on
/download(?channel=beta), never as the default. The switch only appears while the newest prerelease leads stable on its core version, so promotingX.Y.Z-beta.NtoX.Y.Zretires the beta channel from the page until the next beta line opens. - Homebrew, the Play Store, the App Store, and
app.paseo.shhave no beta. The Beta view drops those rows, and the whole Web section, rather than showing an inert "stable only" placeholder. When a surface gains a beta path — say a public TestFlight link — add its row back inpackages/website/src/routes/download.tsx. - The default download target only moves when you publish the final stable release tag like
v0.1.41. - The public
/changelogpage rendersCHANGELOG.mdas-is, so the in-flight-beta.Nentry shows there once it lands onmain— that's intended, it's where beta users check what's coming. Only the default download target stays pinned to the latest stable; the download links read GitHub's releases API, not the changelog, so a-beta.Nheading on top never affects them. - The download page's "What's new" link deep-links the minor group anchor (
/changelog#release-0.3), not the exact entry: the beta entry is renamed on each beta and on promotion, so the minor group remains the durable target. A version with no entry in the bundled changelog — a tag whose changelog commit hasn't redeployed the site yet — links the plain/changeloginstead of a dead anchor. - The website itself is deployed by
Deploy Website(Cloudflare Workers), which redeploys on therelease: publishedevent emitted when a stable draft is published and on pushes tomainthat touchCHANGELOG.mdorpackages/website/**. Its job condition excludes beta prereleases.
Fixing a failed release build
NEVER bump the version to fix a build problem. New versions are reserved for meaningful product changes (features, fixes, improvements). Build/CI failures are fixed on the current version.
Do not rely on workflow_dispatch for tagged code fixes. The workflow_dispatch trigger runs the workflow file from the default branch but checks out the code at the tag ref (ref: ${{ inputs.tag }}). That means fixes committed to main won't change the tagged source tree being built. workflow_dispatch only helps when the fix lives in the workflow file itself.
For Docker-only retries, do not push or force-push a v* release tag.
v* tag pushes rebuild desktop assets, the Android APK, Docker, release notes,
and EAS mobile release builds. Use the Docker workflow dispatch instead:
gh workflow run docker.yml \
--ref main \
-f paseo_version=X.Y.Z-beta.N \
-f publish=true
This replaces ghcr.io/getpaseo/paseo:X.Y.Z-beta.N in place without touching
desktop, APK, or EAS release builders. The Docker exception is safe because the
dispatch runs from --ref main and uses the explicit paseo_version; it does
not check out or move the v* release tag.
To retry a failed non-Docker release workflow, push a retry tag on the commit
you want to build. Reusing the same tag name is expected: move it with
git tag -f ... and push it with --force so the workflow rebuilds the commit
you actually want.
A failed desktop build leaves the GitHub Release as a draft. finalize-rollout
uploads manifests from successful platforms before it fails. A later
single-platform retry reuses those manifests, stamps the complete set with one
release date, and publishes the draft. Use desktop-vX.Y.Z when more than one
platform failed. A workflow_dispatch rebuild with publishing enabled follows
the same path against the existing draft.
Prefer a tag push over workflow_dispatch when rebuilding desktop or APK
release assets. Prefer Docker workflow dispatch when rebuilding only the Docker
image.
The retry tag patterns below still work and remain the supported way to rebuild specific release targets:
# Desktop (all platforms)
git tag -f desktop-v0.1.28 HEAD && git push origin desktop-v0.1.28 --force
# Desktop (single platform)
git tag -f desktop-macos-v0.1.28 HEAD && git push origin desktop-macos-v0.1.28 --force
git tag -f desktop-linux-v0.1.28 HEAD && git push origin desktop-linux-v0.1.28 --force
git tag -f desktop-windows-v0.1.28 HEAD && git push origin desktop-windows-v0.1.28 --force
# Android APK
git tag -f android-v0.1.28 HEAD && git push origin android-v0.1.28 --force
# Beta
git tag -f v0.1.29-beta.2 HEAD && git push origin v0.1.29-beta.2 --force
This ensures the checkout ref matches the code you want built, with the fix included.
vX.Y.ZorvX.Y.Z-beta.Nrebuilds the full tagged releasedesktop-vX.Y.Zrebuilds desktop for all desktop platforms onlydesktop-macos-vX.Y.Z,desktop-linux-vX.Y.Z, anddesktop-windows-vX.Y.Zrebuild only that desktop platformandroid-vX.Y.Zrebuilds the Android APK release only
If you decide to publish a release without working desktop builds, inspect its assets first, then publish it manually:
RELEASE_LOOKUP=$(node scripts/github-release.mjs --repo getpaseo/paseo --tag vX.Y.Z)
gh release view "$RELEASE_LOOKUP" --json isDraft,isPrerelease,assets
gh release edit "$RELEASE_LOOKUP" --tag vX.Y.Z --draft=false
# Keep a beta marked as a prerelease:
RELEASE_LOOKUP=$(node scripts/github-release.mjs --repo getpaseo/paseo --tag vX.Y.Z-beta.N)
gh release edit "$RELEASE_LOOKUP" --tag vX.Y.Z-beta.N --draft=false --prerelease
This bypasses the updater-manifest guarantee. Use it only when the release is intentionally unavailable to desktop updater clients.
Notes
version:all:*bumps root + syncs workspace versions and@getpaseo/*dependency versions- The npm
versionlifecycle regenerates F-Droid changelog files fromCHANGELOG.mdfor stable releases only (npm run fdroid:changelogs) and stages them, so the release tag carries them. Betas are a no-op. A stable run aborts the release ifCHANGELOG.mdhas no entry for the version being cut — commit the changelog entry first. See docs/android.md for why these files are generated per ABI. release:preparerefreshes workspacenode_moduleslinks to prevent stale typesnpm run dev:desktopandnpm run build:desktoptarget the Electron desktop package inpackages/desktop- If
release:publishpartially fails, re-run it — npm skips already-published versions - If
release:publish:betapartially fails, re-run it — npm skips already-published versions and keeps prereleases offlatestbecause every publish uses--tag beta - The website uses GitHub's latest published release API for download links, so published beta prereleases do not replace the stable download target.
Changelog format
Release notes depend on the changelog heading format. The heading must be strictly followed:
## X.Y.Z - YYYY-MM-DD
## X.Y.Z-beta.N - YYYY-MM-DD
No prefix (v), no extra text. Release Notes Sync matches the ## X.Y.Z (or ## X.Y.Z-beta.N) line for the pushed tag to extract the version. A malformed heading breaks the release-notes sync for that tag.
CHANGELOG.md on main is also what the app's What's new sheet fetches and renders, so the file is a shipped product surface, not just a release input. ## starts a release and ### starts a section; the app reads section titles from the document, so renaming or adding one needs no app change. Everything under a section is rendered as Markdown: prose, lists, links, inline code, fenced code, block quotes, tables, and images. Raw HTML does not render — the shared Markdown parser runs with html: false, so a <video>, <iframe> or <embed> tag reaches the reader as visible markup. Keep media out of the changelog, or link to it. A GitHub callout renders as a block quote with its [!NOTE] marker still in the text. A release entry is what a user reads on a phone the moment they are offered the update — write it for them.
Changelog policy
CHANGELOG.mdholds stable releases and at most one entry for the current beta series.- The first beta of a version inserts a top entry like
## 0.1.60-beta.1 - YYYY-MM-DD. - Each subsequent beta rewrites that entry in place: the heading becomes the new beta number and date, and the body describes the full change from the previous stable release to this beta. Never add a second beta entry.
- The entry is a list of changes, not of PRs. When a later beta reverts, reworks, or follows up on something an earlier beta added, rewrite the bullet to the current behavior or delete it. A stable user never saw the earlier beta behavior, so fixes to it are not "Fixed" bullets.
- Anything that already shipped to stable through a patch release on an older line stays out of the beta entry.
- Stable promotion renames the entry to
## 0.1.60 - YYYY-MM-DDafter a final review.
Older prerelease bodies on GitHub keep the notes they were synced with; Release Notes Sync only writes the release whose tag matches the top entry.
Changelog translations
CHANGELOG.zh-CN.md holds the Chinese notes for paseo.sh/zh-CN/changelog. Only the website reads it; the app's What's new sheet, GitHub release bodies and F-Droid read CHANGELOG.md.
- The release agent writes the Chinese entry in the changelog commit, after the English entry is approved. Every beta that rewrites the English entry rewrites the Chinese one too, and promotion renames both.
packages/website/e2e/i18n.spec.tsfails CI on the release commit when any entry has no current translation. - A translated entry uses the English heading unchanged:
## X.Y.Z - YYYY-MM-DD. The website uses it only while the version and date both match the English entry. Otherwise it shows that release whole in English under a notice. Delete Chinese entries whose English entry was renamed. - Keep the English sections, bullets and links one to one. Section titles: Added 新增, Improved 改进, Changed 变更, Fixed 修复, Security 安全, Breaking 破坏性变更, Plugins 插件, Notes 说明. Wording follows website-i18n.md.
Changelog ownership
- The agent running the release writes the changelog entry — beta or stable. The release context and final wording stay with that agent.
- Commit history is only an index of the changes. Never draft the changelog from commit subjects or diffs alone. For every PR in the release range, read the full PR description and every issue it links to before deciding what changed, why users care, or how changes should be grouped. Use the implementation only to verify the resulting understanding.
- Every entry, beta or stable, is drafted from the previous stable release to the release source. For a later beta, read the PRs since the previous beta tag, then rework the existing entry so each bullet still describes current behavior. Review the result against the changelog policy below, show it to the user, and wait for approval before committing it.
Changelog wording
The changelog is shown on the Paseo homepage. Each bullet is a compact factual record of product behavior that changed.
- Name the exact change. Prefer
Added <capability>,Removed <behavior>,Changed <behavior>, orFixed <failure> when <condition>. - Keep the scope exact. A conditional bug is not a general reliability problem. Do not broaden one failure into claims that Paseo is now faster, smoother, responsive, or reliable.
- Use concrete product and runtime terms. Git polling, persisted cache, provider catalog,
and WebSocket reconnects can identify the affected behavior. Component names, internal
modules, code symbols, and implementation techniques cannot: omit
WorkingIndicator,reconcileAndEmitWorkspaceUpdates, remounts, memoization, and controlled inputs. - State the consequence only when the change itself is unclear. Keep the condition that makes the consequence true. Do not replace a precise change with a broad benefit claim.
- Do not invent context. Mention an upgrade, platform, workload, or user action only when the PR or linked issue establishes that scope.
| Avoid | Write |
|---|---|
| Paseo stays responsive with many idle Git workspaces | Removed periodic Git polling for idle workspaces |
| Incompatible saved app data no longer crashes after upgrades | Fixed crash when persisted cache was incompatible |
| Splitting layouts no longer remounts the active agent | Fixed scroll position resetting when splitting a pane |
| Mobile model selector is faster and more straightforward | Added search to the mobile model selector |
Test each bullet against the source PR and issue: can a reviewer point to the exact behavior that changed, the failure that was fixed, or the capability that was added? If the bullet only claims a general improvement, rewrite it with the concrete change.
- Use the entry's release scope. Include changes within the matching range in Changelog scope.
- Collapse internal iterations within that scope. Present a feature added and fixed in one range as working.
- Cut low-signal entries. "Toolbar buttons have consistent sizing" is too granular. Combine small polish items or drop them.
Changelog conciseness
Every bullet must be scannable at a glance. The changelog is not release documentation — it's a list.
- One sentence per bullet, max. If a bullet contains two sentences, the second one is doing work that belongs in product docs, not the changelog. Cut it.
- No trailing periods. Bullets are list items, not prose. Drop the period at the end of every bullet, including the period inside any bolded lead-in.
**Configurable terminal scrollback**not**Configurable terminal scrollback.**. - One line per bullet. If a bullet wraps to three lines in a narrow column, it's too long.
- Split bullets that pack multiple distinct changes. If a bullet uses "and", "plus", a comma list, or an em-dash to chain several independent improvements, break them into separate bullets — even when they share a theme or author. One bullet = one user-facing change.
- Trim qualifying clauses. Drop "with a hint shown when…", "matching the CLI's behaviour", "across common install shapes". If the detail doesn't change whether a user cares, cut it.
- Stop after identifying the change. Do not explain LAN/WAN topology, TLS handshakes, IPC, or other architecture in a changelog bullet. Put necessary background in product docs.
- Attribution follows the split. When you split a dense bullet, move each PR/author to the bullet it belongs to. Never duplicate the same PR across multiple bullets.
Changelog attribution
Every changelog bullet must credit contributors and link to the PR(s) that delivered the change. This is not one-PR-per-line — a single bullet describes a user-facing change and may reference multiple PRs.
Format: append ([#123](https://github.com/getpaseo/paseo/pull/123) by [@user](https://github.com/user)) at the end of each bullet. For changes spanning multiple PRs or contributors:
- Voice mode now works on tablets with proper microphone permissions. ([#210](https://github.com/getpaseo/paseo/pull/210), [#215](https://github.com/getpaseo/paseo/pull/215) by [@alice](https://github.com/alice), [@bob](https://github.com/bob))
Rules:
-
Always link the PR number as
[#N](https://github.com/getpaseo/paseo/pull/N). -
Always link the contributor's GitHub profile as
[@user](https://github.com/user). -
One bullet = one user-facing change, regardless of how many PRs went into it. Group related PRs on the same bullet.
-
De-duplicate contributors. If the same person authored multiple PRs in one bullet, list them once.
-
Only credit external contributors. Skip attribution for @boudra. The changelog credits community contributions — core team work is the default.
-
Credit the commit author, not the PR opener. A maintainer often opens a PR that lands work authored by someone else (cherry-pick, rebase of a contributor's branch, manual extraction from a stacked PR). The squash commit preserves the original commit's author, but
gh pr view N --json authorreturns the PR opener — using that field will silently mis-credit the work to the maintainer (and then the "skip @boudra" rule drops the attribution entirely). Always resolve attribution from commit authors.Use this command to get the GitHub logins for each PR:
gh pr view N --json commits --jq '[.commits[].authors[].login] | unique | .[]'This returns every distinct GitHub login that authored or co-authored a commit in the PR. Use those logins for attribution. Fall back to
gh pr view N --json authoronly if the commits command returns nothing (which should not happen for merged PRs).When listing PR numbers,
git log --cherry-pick --right-only --no-merges --format='%H %s' v<previous>...<release-source-sha> | grep -E '\(#[0-9]+\)$'pulls the PR number out of squash commit subjects and skips commits the previous stable already shipped as cherry-picks.
Changelog ordering
Entries within each section (Added, Improved, Fixed) are ordered by user impact:
- User-facing features and changes first — things users will notice, want to try, or that change their workflow.
- Quality-of-life improvements — polish, performance, smoother interactions.
- Internal/infra changes last — only include if they have a tangible user benefit (e.g. "faster startup" is user-facing even if the fix was internal).
Pre-release sanity check
Before cutting a stable release, the release agent reviews the diff as a last line of defence against shipping bugs. Skip this for betas — the beta itself is the smoke test, and gating each beta on a code review defeats the point of using betas as fast release candidates.
Review the diff between the latest release tag and the resolved release source. Focus on:
- Breaking changes — especially in the WebSocket protocol, agent lifecycle, and any server↔client contract.
- Backward compatibility — the important direction is old app clients talking to newly updated daemons. Users update desktop and daemon first, then keep running the old app for a while. Flag anything that breaks old clients against new daemons or requires both sides to update in lockstep.
- Regressions — anything that looks like it could break existing functionality.
Use git diff <latest-release-tag>..<release-source-sha> as the review input. This is a deep sanity check, not a full code review. If anything looks risky, investigate before proceeding and surface the finding to the user.
Changelog scope
Changelog scope follows the release being described:
Every entry covers previous stable release → release source: first beta, later beta, direct stable release, and promotion alike. The beta entry always reads as the stable entry would if the beta were promoted today.
Completion checklist
Beta release
- The release base and changes match the release base table and any approved inventory, and CI for the release source is green
- Every PR in the release range has been opened, and its full description and every linked issue have been read before drafting the changelog
- Fold this beta into the series' single
CHANGELOG.mdentry (heading## X.Y.Z-beta.N - YYYY-MM-DD, previous stable → release source), review it against the changelog policy, get approval, and commit it with itsCHANGELOG.zh-CN.mdtranslation before cutting the release - The diff from the previous stable to the resolved release source is classified as patch or minor, with the target version and rationale approved
- The changelog, ACP, lockfile, and version commits stayed local until the approved release command pushed the release branch and tag
npm run release:beta:patch,npm run release:beta:minor, ornpm run release:beta:nextcompletes successfully- Every GitHub Actions run for the complete release commit and tag is green
- npm shows the version under the
betadist-tag, notlatest - The GitHub prerelease was published only after the three beta manifests were uploaded, and it has the changelog body and every expected macOS, Linux, Windows, and Android APK asset
- GitHub
Desktop Releaseworkflow for thev*-beta.Ntag is green - The GitHub prerelease contains
beta-mac.yml,beta-linux.yml, andbeta.yml - GitHub
Android APK Releaseworkflow for the same tag is green - GitHub
Dockerworkflow is green and the versioned beta image is published without movinglatest - GitHub
Release Notes Syncmirrored the beta entry into the prerelease body - EAS
Release iOS Betacompleted its build, TestFlight distribution, external beta group, and Beta App Review path - The reconcile PR brought the shipped changelog entries and version into
main - The release heartbeat was created after the tag push and deleted only after every item above passed
Stable release (or promotion)
- Run the pre-release sanity check (see above) and address any findings
- The diff from the previous stable to the resolved release source is classified as patch or minor, with the target version and rationale approved
- The release base and changes match the release base table and any approved inventory, and CI for the release source is green
- A promotion ships the tested beta's product content after its soak, unless the user explicitly overrode either default
- Every PR in the release range has been opened, and its full description and every linked issue have been read before drafting the changelog
- Ensure the approved release inputs are committed locally and the git worktree is clean before running any release command
- Ensure local
npm run typecheckpasses on that exact commit before running any release command - Update
CHANGELOG.mdwith user-facing release notes (features, fixes — not refactors). Promotion renames the series'## X.Y.Z-beta.Nentry to## X.Y.Z - YYYY-MM-DDand re-checks it covers the full release. Commit the matchingCHANGELOG.zh-CN.mdentry with it - Verify the changelog heading follows strict
## X.Y.Z - YYYY-MM-DDformat - The changelog, ACP, lockfile, and version commits stayed local until the approved release command pushed the release branch and tag
npm run release:patch,npm run release:minor, ornpm run release:promotecompletes successfully- Every GitHub Actions run for the complete release commit and tag is green
latestresolves to the new stable, andbetaresolves to it too unless an active newer beta holdsbeta- The GitHub Release was published only after the three stable manifests were uploaded, and it has the changelog body and every expected macOS, Linux, Windows, and Android APK asset
- GitHub
Desktop Releaseworkflow for thev*tag is green - The GitHub Release contains
latest-mac.yml,latest-linux.yml, andlatest.yml latest-mac.ymlcontains the currentminimumSystemVersionguard- GitHub
Android APK Releaseworkflow for the same tag is green - GitHub
Dockerworkflow is green and both the versioned andlatestimages are published - GitHub
Release Notes Syncis green and the release body matches the stable changelog entry - EAS
Release Mobileworkflow for the same tag is green - EAS iOS
build_ioscompletes for the same tag - EAS iOS
distribute_iossucceeds, uploading the binary once, distributing it toPaseo Beta, and submitting it for Beta App Review - EAS iOS
submit_ios_for_reviewsucceeds, putting the build into App Store review - EAS Android
build_androidcompletes for the same tag - EAS Android
submit_androidsucceeds, putting the build on its Play Store track - The reconcile PR brought the shipped changelog entries and version into
main - The release heartbeat was created after the tag push and deleted only after every item above passed