Files
paseo/docs/release.md
Mohamed Boudra b6d49da4df Support F-Droid Android builds (#1768)
* fix(android): support source-based APK builds

Co-authored-by: Anton Lazarev <22821309+antonok-edm@users.noreply.github.com>

* fix(android): address fdroid review feedback

Co-authored-by: Anton Lazarev <22821309+antonok-edm@users.noreply.github.com>

* fix(android): complete the F-Droid build profile

* fix(app): isolate the F-Droid runtime flag

---------

Co-authored-by: Anton Lazarev <22821309+antonok-edm@users.noreply.github.com>
2026-07-13 17:18:19 +08:00

36 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 (local, reversible — agent does this):

  • format, lint, typecheck all green
  • ACP provider catalog drift checked with npm run acp:version-drift:check; if stale package-runner pins are intentional, say so explicitly, otherwise run npm run acp:version-drift:update and commit the updated catalog
  • draft the changelog, show it to the user, wait for review
  • run the pre-release sanity check, surface findings to the user
  • confirm CI is green

Go-ahead (user says "go ahead"):

  • commit the approved changelog
  • run the release

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.
  • 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.

Two paths

There are two supported ways to ship from main:

  1. Direct stable release: you are ready to ship the current main commit to everyone immediately.
  2. Beta flow: release candidates on the beta channel. Betas carry an in-place changelog entry (beta users check it), publish npm only on the explicit beta dist-tag, and never move the website download target off the latest stable.

Standard release (patch)

Before running any stable patch release command:

  • Make sure the intended release commit is already committed to main and the working tree is clean.
  • Run npm run format, npm run lint, and npm run typecheck and commit any resulting changes BEFORE you start any release:* command. release:check runs npm install --workspaces --include-workspace-root as part of release:prepare, which can mutate package-lock.json (e.g. churning "dev": true markers on optional deps). The next step, version:all:*, runs npm version which 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 skips package-lock.json while lefthook's glob still matches it. Avoid the whole mess by running format/lint/typecheck first, then release:prepare once on its own to absorb any lockfile churn into a normal commit, then start the release.
  • Do not use npm run release:patch as a substitute for checking whether the current commit is actually ready.
npm run release:patch

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. 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 release-mobile.yml in this repo.

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.

Relay deployment is manual-only while relay.paseo.sh bridges traffic to the Fly deployment. Releases and pushes to main do not deploy the Cloudflare relay worker. Deploy it explicitly with gh workflow run deploy-relay.yml only when the production bridge should change.

Releases are always patch. "Release paseo", "release stable", "ship stable", and similar always mean a patch bump from the previous stable. Never bump minor or major to trigger a build, ever — minor and major bumps are reserved for genuinely larger product cuts and require an explicit user instruction with the word "minor" or "major". If you find yourself reaching for release:minor to retrigger a failed build, you are doing the wrong thing — push a retry tag instead (see "Fixing a failed release build" below).

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
npm run version:all:patch    # Bump version, create commit + tag
npm run release:publish      # Publish to npm
npm run release:push         # Push HEAD + tag (triggers CI workflows)

Beta flow

npm run release:beta:patch       # Bump to X.Y.Z-beta.1, publish npm beta, push commit + tag
# ... 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, so npm install @getpaseo/cli@beta opts in while plain npm install @getpaseo/cli stays on latest
  • Betas publish desktop assets and APKs for testing, but they do not trigger the production web/mobile release flows
  • release:promote creates a fresh stable tag like v0.1.41; the final release never reuses the beta tag
  • Desktop assets now come from the Electron package at packages/desktop
  • Beta releases use Electron's beta update 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.
  • Betas carry a changelog entry. Beta users read release notes, so each beta updates an in-place CHANGELOG.md entry (## X.Y.Z-beta.N) that Release Notes Sync mirrors into the prerelease body on the tag push. The entry is intermediary: promotion overwrites it in place with the final stable entry, so no -beta.N heading is ever left behind. 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:

  • Platform build jobs upload the installers/packages (.dmg, .zip, .exe, .AppImage, etc.) to the GitHub release.
  • The final job merges/stamps the manifests and uploads all .yml files only after they already contain the final releaseDate and rolloutHours.

Updater clients only discover a release through those .yml manifests, so there is no silent 100% admission window before rollout metadata is present.

Default behavior

npm run release:patch → 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 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 — that path always tag-pushes and stamps 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.

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.

Limitations

  • No pause / kill switch. Once a stable user is admitted, they will install the update on next quit (autoInstallOnAppQuit = true). To stop new admissions, ship a superseding release. To "recall" already-admitted users, ship a hotfix +1 patch.
  • No rollback. allowDowngrade = false. Bad release = ship a hotfix.
  • Bootstrap caveat. Clients running a build older than the rollout feature ignore rolloutHours and 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 production and auto-submits to the Play Store via eas submit (EAS-managed credentials, no Fastlane).
  • iOS (TestFlight + App Store) — EAS builds with profile production, uploads to TestFlight, and a Fastlane lane submits the 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 Android versionCode and iOS buildNumber from the package version as major * 1_000_000 + minor * 1_000 + patch, ignoring prerelease metadata. 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 a new patch instead of relying on EAS remote auto-increment.

There is no release-mobile.yml in this repo. Earlier versions of these docs referenced one — that workflow was removed and the EAS GitHub app handles tag triggering directly.

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 submit_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 NEWIN_QUEUEIN_PROGRESSFINISHED (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 upload to TestFlight and submit the 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 built
  • submit_ios — iOS binary uploaded to App Store Connect/TestFlight
  • submit_ios_for_review — iOS build submitted for App Store review via Fastlane
  • build_android — Android store binary built
  • submit_android — Android binary submitted to the Play Store

Do not treat build_ios: SUCCESS or submit_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.

Babysitting mobile after a release

The user rarely opens the Expo dashboard. A failed EAS build or submit/review job can sit silently until users complain about a stale version. After every stable release, set up a long-delay babysit that re-checks GitHub Actions, EAS builds, and the EAS Release Mobile workflow for the release tag. If any build is ERRORED/CANCELED, any workflow is FAILURE, or any required submit/review job fails, surface it immediately. If all builds are FINISHED and all required submit/review jobs are SUCCESS, confirm and stop.

Use create_heartbeat, never create_schedule, for release babysitting. Babysitting fires back into the current conversation as a wake-up prompt. create_schedule starts a fresh agent the user has to find and read; create_heartbeat surfaces the build status inline in the conversation that owns the release, where it is impossible to miss. If you find yourself reaching for create_schedule for a release babysit, you are about to ship a status report into a void.

Pattern:

// mcp__paseo__create_heartbeat arguments
{
  "name": "vX.Y.Z release babysit heartbeat",
  "cron": "*/15 * * * *",
  "maxRuns": 8, // covers ~2h of build + store-submission window
  "prompt": "Heartbeat: check vX.Y.Z release. Run gh run list, eas build:list, eas workflow:runs, and eas workflow:view for the matching Release Mobile run. Report concisely. The release is not done until desktop/APK workflows are green, EAS builds are FINISHED, Android submit_android is SUCCESS, and iOS submit_ios + submit_ios_for_review are SUCCESS. Flag any ERRORED/FAILED/CANCELED/FAILURE loudly.",
}

Tight cadence on purpose. The first run fires immediately, giving a near-real-time status check before the conversation closes. Subsequent runs at 15-minute intervals catch transitions quickly: a failed EAS build or failed App Store review submission at +20m should not wait until +50m to surface. Keep the prompt short — the heartbeat is a status probe, not a research task — and have it bail out as soon as every platform is actually on its store path so the remaining runs do not generate noise.

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 points to GitHub's latest published stable release.
  • Published beta prereleases are public on GitHub Releases, but they do not become the website download target.
  • The download target only moves when you publish the final stable release tag like v0.1.41.
  • The public /changelog page renders CHANGELOG.md as-is, so the in-flight -beta.N entry shows there once it lands on main — that's intended, it's where beta users check what's coming. Only the download target stays pinned to the latest stable; the download links read GitHub's releases API, not the changelog, so a -beta.N heading on top never affects them.
  • The website itself is deployed by Deploy Website (Cloudflare Workers), which redeploys on release: published for non-prerelease releases and on pushes to main that touch CHANGELOG.md or packages/website/**.

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.

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 actual code on main with the fix included.

  • vX.Y.Z or vX.Y.Z-beta.N rebuilds the full tagged release
  • desktop-vX.Y.Z rebuilds desktop for all desktop platforms only
  • desktop-macos-vX.Y.Z, desktop-linux-vX.Y.Z, and desktop-windows-vX.Y.Z rebuild only that desktop platform
  • android-vX.Y.Z rebuilds the Android APK release only

Notes

  • version:all:* bumps root + syncs workspace versions and @getpaseo/* dependency versions
  • release:prepare refreshes workspace node_modules links to prevent stale types
  • npm run dev:desktop and npm run build:desktop target the Electron desktop package in packages/desktop
  • If release:publish partially fails, re-run it — npm skips already-published versions
  • If release:publish:beta partially fails, re-run it — npm skips already-published versions and keeps prereleases off latest because 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 policy

  • CHANGELOG.md includes stable releases and the current beta line.
  • The first beta of a version inserts a top entry like ## 0.1.60-beta.1 - YYYY-MM-DD.
  • Each subsequent beta updates that same top entry in place — bump the heading (0.1.60-beta.10.1.60-beta.2) and fold in whatever else landed.
  • Stable promotion updates that same entry in place one last time: heading to 0.1.60, date to the promotion day.
  • One entry per version line. The -beta.N heading is intermediary — overwrite it, never append. Don't leave stale -beta.N entries behind and don't create a duplicate entry per beta.
  • It always covers the full diff from the previous stable tag, regardless of how many betas were cut in between.

Changelog ownership

  • The agent running the release writes the changelog entry — beta or stable. Do not hand the changelog to another model or agent. The release agent has the release context and owns the final wording.
  • Draft the entry from the previous-stable-to-HEAD diff, review it against the changelog policy below, show it to the user, and wait for approval before committing it. Each beta refreshes the same entry; promotion refreshes it one last time from the full previous-stable-to-HEAD diff.

Changelog voice

The changelog is shown on the Paseo homepage. Write it for end users, not developers.

  • Frame everything from the user's perspective. Describe what changed in the app, not what changed in the code. Users care that "workspaces load instantly" — not that a component no longer remounts.

  • Never mention component names, internal modules, or implementation details. No WorkingIndicator, no accumulatedUsage, no reconcileAndEmitWorkspaceUpdates. Also no "virtualized lists", no "remount", no "memoization", no "debounced", no "fuzzy ranking", no "controlled input", no "uncontrolled input" — these are implementation words masquerading as user-facing copy.

  • Concrete WRONG → RIGHT examples (real mistakes from past releases):

    Wrong (implementation-facing) Right (user-facing)
    Switching layouts no longer remounts the active agent Splitting a pane no longer loses your scroll position
    Model, mode, and thinking pickers — searchable virtualized lists with fuzzy ranking Mobile model selector is faster and more straightforward
    Text inputs in mobile sheets no longer flicker while typing fast Typing in mobile sheets no longer flickers
    Compact web sheets no longer crash when swiped to dismiss Sheets on mobile web no longer crash when swiped to dismiss
    Reduced re-renders in the agent list Agent list scrolls smoothly
    Added debouncing to the search input Search results no longer lag behind typing

    Test: would a non-developer reader recognise what changed when using the app? If they'd need an engineer to translate ("what's a remount?"), the bullet is still implementation-facing — rewrite it as the symptom the user experiences.

  • Collapse internal iterations. If a feature was added and then fixed within the same release, just list the feature as working. Users never saw the broken version.

  • Only list changes relative to the previous stable release. The diff is v(previous)..HEAD. If something was introduced and fixed between those two tags, it never shipped — don't mention the fix.

    • Common trap: when drafting from git log, every commit looks like a separate bullet — including the "fix X" commits that landed on top of a brand-new feature in the same release window. Before listing a Fixed entry, check whether the thing being fixed was itself added in this same release. If so, drop the fix and fold it into the feature bullet.
    • Example: if the release adds an in-app browser and also contains a commit "fix: browser pane keyboard handling no longer steals shortcuts", do not list the keyboard fix under Fixed. The browser is shipping for the first time, so users will only ever see the working version. The Added entry covers it.
  • 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.
  • Lead with what the user can do, not the mechanism. The reader cares about the capability, not how it works under the hood. Do not explain LAN vs WAN, TLS handshakes, IPC, the daemon-relay topology, or any internal concept the user has not asked about. "Self-hosted relays can use a different TLS setting for the public endpoint" — not "Self-hosted relays support a separate TLS setting for the public endpoint, so the daemon can reach the relay over the LAN while the phone reaches it over the public secure address." If a feature genuinely needs background to be understood, it belongs in product docs, with a one-line teaser in the changelog.
  • Lead with the outcome. "Windows: agents launch reliably from npm .cmd shims…" is better than "Windows: agents launch reliably across common install shapes. Claude, Codex, and OpenCode now start correctly…".
  • 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 author returns 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 author only if the commits command returns nothing (which should not happen for merged PRs).

    When listing PR numbers, git log --format='%H %s' v<previous>..HEAD | grep -E '\(#[0-9]+\)$' pulls the PR number out of squash commit subjects.

Changelog ordering

Entries within each section (Added, Improved, Fixed) are ordered by user impact:

  1. User-facing features and changes first — things users will notice, want to try, or that change their workflow.
  2. Quality-of-life improvements — polish, performance, smoother interactions.
  3. 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 HEAD. Focus on:

  1. Breaking changes — especially in the WebSocket protocol, agent lifecycle, and any server↔client contract.
  2. 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.
  3. Regressions — anything that looks like it could break existing functionality.

Use git diff <latest-release-tag>..HEAD 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

The changelog always covers previous-stable-to-HEAD, beta and stable alike:

  • Beta release: the entry covers previous stable tag → HEAD. Update the current in-place beta entry; don't start a fresh one per beta.
  • Stable promotion: the same entry is promoted in place. It still captures the full delta from the previous stable release, not just what changed since the last beta.

Betas are checkpoints along the way; the entry is the single record for the jump from one stable version to the next, and beta users read it in the meantime.

Completion checklist

Beta release

  • Working tree is clean and the intended commit is on main
  • Update the in-place beta entry in CHANGELOG.md (heading ## X.Y.Z-beta.N - YYYY-MM-DD), review it against the changelog policy, get approval, and commit it before cutting the release
  • npm run release:beta:patch (or :next) completes successfully
  • npm shows the version under the beta dist-tag, not latest
  • GitHub Desktop Release workflow for the v*-beta.N tag is green
  • GitHub Android APK Release workflow for the same tag is green
  • GitHub Release Notes Sync mirrored the beta entry into the prerelease body

Stable release (or promotion)

  • Run the pre-release sanity check (see above) and address any findings
  • Ensure the intended release commit is already committed and the git worktree is clean before running any release:* patch/promote command
  • Ensure local npm run typecheck passes on that exact commit before running any release:* patch/promote command
  • Update CHANGELOG.md with user-facing release notes (features, fixes — not refactors). When promoting from beta, overwrite the existing ## X.Y.Z-beta.N heading in place (heading → X.Y.Z, date → promotion day) — do not add a new entry on top of the beta one
  • Verify the changelog heading follows strict ## X.Y.Z - YYYY-MM-DD format
  • npm run release:patch or npm run release:promote completes successfully
  • GitHub Desktop Release workflow for the v* tag is green
  • GitHub Android APK Release workflow for the same tag is green
  • EAS Release Mobile workflow for the same tag is green
  • EAS iOS build_ios completes for the same tag
  • EAS iOS submit_ios succeeds, uploading the build to App Store Connect/TestFlight
  • EAS iOS submit_ios_for_review succeeds, putting the build into App Store review
  • EAS Android build_android completes for the same tag
  • EAS Android submit_android succeeds, putting the build on its Play Store track