How we work on PDT
PDT is built inside PDT: the maintainer’s agent sessions run on their stable instance (port 7420), each change lives in its own worktree, and the maintainer tests it in a dev instance of PDT started with Run. This page is the process: branches, checks, pull requests, CI, the merge queue, the changelog and releases. How the code works is in the developer guide (architecture.md, invariants.md, testing.md).
Contributing from outside (no maintainer PDT, no PDT tickets): CONTRIBUTING.md is your
version of this page. The parts below that use the stable PDT on port 7420, its tickets, gh pr merge, labels,
releases or the stable data folder are for maintainers; use a plain branch (or git worktree add) instead, and name
your changelog fragment after your issue or PR (GH-45.md).
The loop
- Pick the work from the project’s tickets in the stable PDT (Tickets → Backlog / Board;
GET /api/tickets). Designs for larger work go on the ticket (its description or comments), never in the repository. - Make a worktree for a new branch (below). Never commit on
mainin the main checkout. - Build it test-first (TDD for core logic), add a changelog fragment, and run the checks (below).
- Push and open a PR against
main. CI runs (below, “CI”). - The user tests it: Run →
pdton the branch’s worktree (below, “Testing a branch”). Feedback comes back in the session; fix the branch and restart the run. - Merge when the user says so:
gh pr merge <n> --auto, with no merge-method flag (the queue sets it;--squashonly prints “The merge strategy for main is set by the merge queue”) and no--delete-branch(it can’t delete a branch whose worktree exists). Once the PR’s checks pass it joins main’s merge queue (below), which squash-merges it: the PR title becomes the commit subject onmain, so write it as one. Nobody watches CI in a shell, and the branch never needs updating with main. - Merging ships nothing: a merge to
mainruns no CI and publishes no release (below, “Releases”). - Clean up (below, “After merge”) and add tickets for follow-ups. Work that takes several PRs is a parent ticket with one part per PR; each part has its own branch, worktree and PR, and Start next part on the parent starts the next.
Worktrees
Worktrees live at .worktrees/<branch> inside the repo (/ in a branch name becomes -); /.worktrees/ is in
.git/info/exclude. Ticket Start names branches pdt-<KEY>-<n>-<slug> (no /: some tools break on it); other
branches are short and kebab-case.
Maintainers create them through the stable PDT, so its sessions group under the branch and Run uses that
checkout (without a PDT, git worktree add .worktrees/<branch> -b <branch> origin/main or just a branch):
git -C <repo> fetch origin && git -C <repo> merge --ff-only origin/main # keep the main checkout cleancurl -s 127.0.0.1:7420/api/projects # the project id (repoPath = <repo>)curl -s -X POST 127.0.0.1:7420/api/projects/<project id>/worktrees \ -H 'content-type: application/json' -d '{"branch":"<branch>"}' # optional "base": branch or commitThe branch starts at the project’s base branch (main); cd into the returned path. A fresh worktree needs
pnpm install --frozen-lockfile once, and its first cargo build is slow (empty target/). A folder made with plain
git worktree add is unknown to PDT, and Run on that branch fails with worktree_path_taken: remove it and recreate
it as above, or start a dev instance by hand.
Checks before pushing
scripts/check.sh runs what CI runs, cheap checks first, and stops at the first failure:
scripts/check.sh # everything: changelog and version, the docs check and website, lint, web, cargo testscripts/check.sh --docs # a docs-only change: the changelog, the docs check, the website's tests and buildscripts/check.sh --no-rust # skip fmt, clippy and cargo test; --no-web and --no-site skip those groupsscripts/check.sh --only lint # just some groups (changelog, site, lint, web, test); CI's lint job does thiscargo testregeneratesweb/src/api/genand the website’s plugin list (site/src/data): commit them, or the check fails as CI’s would.- When
desktop/changed, also runcargo clippy -p pdt-desktop --all-targets -- -D warningsandcargo test -p pdt-desktop(afterpnpm --dir web build). - The docs check fails on internal references in the user docs, and on any file under
docs/in asuperpowers,specsorplansfolder (AGENTS.md “Writing docs”).
Watching CI is usually unnecessary (auto-merge waits for it). When you need a result, watch in the background,
and use the Actions run: the gh token is fine-grained, which can’t read check runs, so gh pr checks fails.
gh run list --branch <branch> --limit 1 --json databaseId,status,headSha # the run for the latest pushgh run watch <run id> --interval 30 --exit-status # exits non-zero if CI failedA new push cancels the old run and starts a new one, so look it up again after each push.
Changelog
Release notes are written for people who use PDT, by whoever makes the change: every PR adds a fragment,
.changes/unreleased/<TICKET>.md (more for the same ticket: <TICKET>-<anything>.md):
---kind: fixed---The board no longer jumps back to the top when a ticket moves.kindisadded,changed,fixed,breakingorinternal(left out of the release notes: refactors, tooling). The text is one or two sentences (or a short list) on what changed for the user, with no tickets, crates or file names unless the user sees them.scripts/changelog.sh checkchecks the fragments. CI’schangelogjob fails a PR without one unless it has theno-changeloglabel (for PRs that only change docs, CI or tests; adding it re-runs CI) or says[no-changelog]in its description. The description is read when a push or a label starts the run, so after writing it there, push again (or re-run the job). It is for contributors from a fork, who can’t set labels.- At release,
scripts/changelog.sh <version>adds a## <version> (<date>)section toCHANGELOG.md(Breaking, Added, Changed, Fixed), prints the notes, and moves the fragments to.changes/<version>/.scripts/changelog.sh notes <version>prints a version’s section (the release’s notes);previewprints the unreleased ones.
CI
.github/workflows/ci.yml; the Rust checks per OS in .github/workflows/rust.yml; shared setup (toolchain, cache,
nextest, pnpm, Windows Defender exclusions) in .github/actions/setup, bundle steps in .github/actions/bundle and
.github/actions/bundle-macos (which joins the two macOS builds into the universal app).
Linux does every check that doesn’t depend on the OS, in parallel jobs; macOS and Windows run only what they can break
on their own (clippy for cfg code, the Rust tests). Every job runs on GitHub’s hosted runners unless a repo variable
names another (below, “Runners”).
| Check | Does | Pull requests | Merge queue |
|---|---|---|---|
| lint (linux) | version sync, fmt, clippy (pdtd also without integration features) | a Rust change | unless docs only |
| test (linux) | nextest at the stand-in version 9.9.9 (pdtd also without integration features); generated files are committed | a Rust change | unless docs only |
| web (linux) | web typecheck and build; vitest in 4 shards (PDT_WEB_SHARDS) | a web change | unless docs only |
| build (macos-latest) | clippy, nextest, desktop crate clippy and test | macos label, or a .github/ change | unless docs only |
| build (windows-latest) | clippy, nextest | windows label, or a Rust change to windows/, *.ps1, *.cmd, .github/ | same |
| site | the install scripts’ lint, the website’s tests (the docs check included) and build | a change to site/, docs/, the pnpm lockfile or workspace, ci.yml or pages.yml | same |
| secrets | gitleaks over the commits the PR (or the queued group) adds | always | always |
| changelog | a fragment or the no-changelog label; the scripts’ tests | unless docs only | skipped |
| bundle (…) | the macOS app (each CPU built in its own job, bundle (macos, <target>), then joined into the universal app, signed and checked with lipo -archs in bundle (macos-latest, app)), the Windows installer, the Linux tarball and AppImage, as workflow artifacts (GitHub’s runners always: signing) | the release PR only | never |
- Docs-only PRs (every changed file under
docs/orsite/, or a*.md) run onlychanges,secretsandsite. The guides pdtd builds in withinclude_str!(docs/agent-guide.md,docs/run-config.md,docs/prompts/,docs/user/automation/run-configuration.md;crates/pdt-api/src/guides.rs) count as code, since Rust tests check them, and so do the install scripts insite/public. If other code starts reading a file there, add it tobuiltinin thechangesjob. - Per job on a PR: a Rust change is any code change outside
web/; a web change touchesweb/,package.json, the pnpm lockfile or workspace,scripts/check.shor.github/. A PR that changes onlyweb/skips the Rust jobs, and one that leaves those alone skips the web jobs. The merge queue runs both whatever changed (a few Rust tests readweb/, such as the brand check). - Tests never depend on the checked-in version:
test (linux)sets it to 9.9.9 in its own checkout, so a test that hard-codes0.x.yfails on its own PR. Compare withpdt_core::BUILD_VERSIONorenv!("CARGO_PKG_VERSION"). - Main runs no checks:
ci.ymlhas nopushtrigger (the queue has just checked that tree). On main,pages.ymlredeploys the website whensite/**ordocs/**changed, andcache.ymlrebuilds the build caches when the dependencies change (below, “Caching”). - macOS waits for the queue because macOS minutes cost the most (on Namespace, 10× Linux); a macOS-only break
shows when the PR enters the queue. Label a PR
macosto see it earlier. - Windows runs only when asked: a Windows-only break in a PR without the
windowslabel or Windows files is not caught before it merges. Label any path, process or file-system changewindows. - Bundles by hand:
gh workflow run ci --ref <branch>(or Actions → ci → Run workflow, “Build bundles”) runs every check and builds every bundle at<version>-local, kept as artifacts for 5 days and published nowhere: use it to try a packaging change. - The secret scan (
secrets) runs gitleaks’ default rules over the PR’s new commits, with the config in.gitleaks.toml. Run it locally withgitleaks git --log-opts=origin/main..HEAD .(brew install gitleaks). A finding that is no secret (a recorded id in a test fixture) goes in.gitleaks.tomlas an[[allowlists]]entry withtargetRules(the finding’s rule),pathsand aregexesmatch for the value,condition = "AND": withouttargetRules, apathsentry skips the whole file for every rule. A real secret that reached a commit is leaked even once removed: revoke it, then rewrite the branch. - Job names are the “Main” ruleset’s required checks. A job skipped by its
ifstill reports, and a skipped check passes. Renaming a job means editing the ruleset first.test (linux),build (macos-latest)andbuild (windows-latest)only report the result of their OS’srust (…)jobs (rust.yml); they fail if those orchangesfailed.
Build once, test in partitions
For each OS, rust.yml runs:
- build: compiles every test binary once into a nextest archive (
cargo nextest archive), uploaded as an artifact for a day, with cargo’s--timingsreport (cargo-timings-<os>-<tests>, a week) showing what compiled when. On Linux and Windows (split) two build jobs run side by side, build (unit) (--lib --bins) and build (integration) (--test '*'), an archive each: the build is CPU-bound on a 4-CPU runner, so halving each job’s work shortens it by minutes. Both select the same packages, so the dependencies build with the same features and come from the same cache; only the workspace’s shared crates (pdt-core up to pdt-api) compile in both. macOS builds once (build (all)): its minutes cost the most. - minimal (Linux): pdtd’s tests without its integration features, a build of its own beside the others.
- test (1…N): each downloads the archives, extracts them into its checkout (so
target/is where the build had it andenv!("CARGO_BIN_EXE_…")paths hold) and runs one slice of each archive’s tests (--partition slice:i/N), the second archive even when the first fails. They need no Rust toolchain. On Linux each checks the generated files its tests wrote. - clippy (macOS and Windows; Linux lints in
lint (linux)): beside the build, so it adds no wall time; on macOS also the desktop crate’s clippy and tests. Windows keeps full clippy: itscfg(windows)code is in most crates, and a separate job costs no waiting.
N is 3 on Linux, 8 on Windows and 2 on macOS; the repo variables PDT_LINUX_PARTITIONS, PDT_MACOS_PARTITIONS and
PDT_WINDOWS_PARTITIONS change it, as a JSON list (gh variable set PDT_WINDOWS_PARTITIONS --body '[1,2,3,4,5]').
The builds are lean: no debug info (CARGO_PROFILE_DEV_DEBUG=0, CARGO_PROFILE_DEV_STRIP=debuginfo), no
incremental state (CARGO_INCREMENTAL=0), and Windows links with rust-lld instead of MSVC’s link.exe. Linux
already links with Rust’s bundled lld. The test builds compile the dependencies (not the workspace’s crates) at
opt-level=1 (DEPS_CONFIG in rust.yml, part of the cache key): the tests are CPU-bound and take about a quarter
less CPU. Local builds keep opt-level=0.
Caching
- Only main saves caches; every other run only restores. PRs and queue runs can read only their own branch’s caches and main’s, so a save anywhere else would be read by no one and fill the repo’s 10 GB.
- The bundles (
.github/actions/bundle) keep their own Rust caches, one per bundle and CPU (bundle-nsis,bundle-linuxon each Linux CPU,bundle-app-aarch64-apple-darwin,bundle-app-x86_64-apple-darwin): release builds share nothing with the checks’ debug builds. Nightly releases save them (they run on main), along with the pnpm store of each runner’s OS and CPU; the release PR and a stable release’s tag run (in a pull request’s scope, where a save would reach no one) restore the newest nightly’s.release.ymlsets the sameCARGO_*variables asci.yml, because they are part of the keys. - On GitHub’s runners
setuprestoresSwatinem/rust-cache, one cache per OS and build (linux-lint,linux-build,linux-minimal,macos-clippy,macos-build,windows-clippy,windows-build). Each holds the cargo registry and the dependencies’ build output (no debug info, not the workspace’s crates), keyed onCargo.lock, the manifests, the toolchain and the OS. When the key misses it restores the newest cache for the same OS and build, and rebuilds only what changed. - The pnpm store (
.github/actions/pnpm, used by every job that needs Node) is cached per OS, keyed onpnpm-lock.yaml, saved on main only. cache.ymlsaves them: it runs the same builds on main whenCargo.lock, aCargo.toml,pnpm-lock.yaml, the toolchain or the CI files change, weekly (GitHub drops caches unused for 7 days), and by hand (Actions → cache → Run workflow). Then.github/scripts/prune-caches.shkeeps only main’s newest cache of each kind and deletes the rest; each nightly runs it too, after saving the bundles’ caches. Past 10 GB GitHub evicts the least recently used caches, which are the bundles’ (read once a night), so pruning is what keeps them. Run it with--dry-runto see what it would delete.- sccache isn’t used: rust-cache with keys per OS and build already restores every dependency. Only the workspace’s own crates compile on each run, and a shared compiler cache would need storage outside GitHub’s 10 GB (a bucket such as Cloudflare R2, with its credentials as secrets); it would mostly speed up the bundles’ release builds of the workspace’s crates.
- Namespace runners keep their cache volume instead (
target/, the cargo registry, the pnpm store, saved when a job exits cleanly);cache.ymlskips an OS whose runner variable names a Namespace profile. - Windows:
setupexcludes the checkout, the temp folders and the cargo and rustup homes from Defender’s scanning, which otherwise slows the build and the tests several times over.
The merge queue
The “Main” ruleset merges through a merge queue (squash, one PR per group). A PR joins it once its checks pass and
auto-merge is on; GitHub makes gh-readonly-queue/main/pr-<n>-<sha> (main plus the PR) and runs ci.yml on it
(merge_group). The changes job picks jobs as for a PR: files from the group’s base and head (over 300 runs
everything), labels from PR <n> in the branch name, which is why the group size stays 1. The PR merges when the
required checks pass; one that conflicts with main or fails leaves the queue: fix the branch (merge main in for a
conflict), push, and merge again.
Runners
- GitHub’s hosted runners (
ubuntu-latest,macos-latest,windows-latest) run every job by default: free and many at once for a public repo. - Repo variables
PDT_LINUX_RUNNER,PDT_MACOS_RUNNERandPDT_WINDOWS_RUNNERswitch an OS’s checks (lint, web, the Rust jobs) to another runner with no code change: a Namespace profile (gh variable set PDT_LINUX_RUNNER --body namespace-profile-linux-8,PDT_MACOS_RUNNERnamespace-profile-macos-6) or a larger GitHub runner.gh variable deletegoes back to GitHub’s.setuppicks the cache to match (a runner namednsc-runner-*is Namespace’s). - Namespace (its GitHub App is installed on the repo): each profile has a cache volume. Its Linux image has no
SHELLand no zsh, sosetupwithzsh: trueinstalls zsh for pdtd’s terminal tests. - The bundles and the small
changes,changelog,siteand reporting jobs always run on GitHub’s runners. - A pull request from a fork runs every check on GitHub’s hosted runners whatever the variables say (the
changesjob picks each OS’s runner): untrusted code never spends Namespace minutes or writes to a cache volume later runs restore. In the merge queue it runs on the configured runners, after a maintainer has reviewed and queued it. .github/workflows/bench.yml(by hand only) times the same work on a list of runners, to compare hosts.- The Mac mini’s old self-hosted runner is retired; if it is still listed under Settings → Actions → Runners, remove it, and never make the repo public while it is registered.
Outside pull requests
- Approval: Settings → Actions → General → “Approval for running fork pull request workflows from contributors” must be Require approval for all external contributors, so no fork’s PR runs CI until a maintainer approves that run (GitHub’s default asks only first-time contributors).
- Secrets: no workflow uses
pull_request_target, and every workflow’s token iscontents: readunless a job widens it. Fork PRs get no secrets; the bundles, the only jobs that sign, run only for this repository’sreleasebranch. - Labels: a contributor can’t set
no-changelog,windowsormacos. They write[no-changelog]in the PR’s description instead (above, “Changelog”); a maintainer addswindowsormacoswhen the change needs those checks. - On a contributor’s fork,
pages.yml,cache.ymlandaudit.yml’s daily run skip (github.event.repository.fork);ci.ymlchecks their PRs here;release.ymlstops with a message whenPDT_RELEASE_TOKENis missing.
Dependabot
.github/dependabot.yml checks the Cargo workspace, the pnpm workspace (web, desktop, site) and GitHub Actions
every Monday: one grouped PR of minor and patch updates per ecosystem, and one PR per major update, at most five open
each. rust-toolchain.toml is bumped by hand.
- Its PRs carry the
dependenciesandno-changeloglabels anddeps:commit messages. - They are outside PRs: PDT’s external-PR workflow has an agent review each, and the user approves and merges it through the queue. A major update that needs code changes is fixed on its branch or in a ticket of its own.
Advisories
audit.yml checks the dependencies against published advisories: cargo deny check advisories over Cargo.lock
(vulnerable, unsound, unmaintained and yanked crates) and pnpm audit --audit-level high over pnpm-lock.yaml. It
runs daily on main, by hand, and on a PR that changes a lockfile or deny.toml. It is not a required check: a new
advisory is about the dependencies, not the PR that meets it.
- Run it locally with
cargo deny check advisories(cargo install --locked cargo-deny) andpnpm audit --audit-level high. - A failed daily run is new work: update the crate (
cargo update -p <crate>) or package, or, when there is no fix (an unmaintained crate deep in tauri’s or keyring’s Linux stack), add its id to[advisories] ignoreindeny.tomlwith the reason.
Releases
Releases are deliberate: merges don’t ship. Stable releases happen when the user asks; nightlies every night.
- Versions are SemVer. The source of truth is the workspace
versioninCargo.toml, repeated indesktop/src-tauri/tauri.conf.json;scripts/version.sh checkfails when they differ. Only a release PR changes them. - Builds: a release build reports its version as is; every other build (local, Run, bundles by hand) reports
<version>-local, which never counts as newer than a release. - Updates never install an older version. The stable channel (the default) takes GitHub’s latest release; the nightly channel also takes nightly pre-releases, whichever is newer. The update banner shows the notes of every release in between.
Releasing
- Screenshots (when the UI changed since the last release):
node scripts/site-screenshots.mjs(site/README.md “Screenshots”), merged before starting. - Start it with PDT’s “Cut a release” workflow (below), or Actions → release → Run workflow on
mainwith a bump (patch,minor;majorfrom 1.0.0 on) or an exact version. Agents start it only through Cut a release. Starting it is the release: nobody reviews the release PR. To change the notes, edit the fragments onmainfirst. It refuses while another release PR is open. - The release PR:
.github/workflows/release.ymlsets the version (Cargo.toml,Cargo.lock,tauri.conf.json), compiles the fragments intoCHANGELOG.md, and opensRelease vX.Y.Zfrom thereleasebranch with the notes as its body. CI runs, the bundle jobs build signed and notarized release bundles (so packaging breaks show before a tag), and the workflow turns on auto-merge withPDT_RELEASE_TOKEN, so it merges itself once its checks pass. A failed check leaves it open: push a fix torelease(and turn auto-merge on again once the queue dropped it), or close it, which releases nothing. - Tag and publish: on merge, the workflow tags
vX.Y.Zon the merge commit, builds the bundles again from it (the queue squashed the PR’s commit away), and publishes the GitHub release withSHA256SUMSand the version’sCHANGELOG.mdsection. Re-running a failed job reuses the tag and release and replaces re-uploaded assets.
Setup: the PDT_RELEASE_TOKEN secret (fine-grained: Contents and Pull requests, read and write) and auto-merge
allowed in the repository’s settings; without the token the workflow stops before opening anything. Dry run: on
a fork with the same secrets, set the repo variable PDT_RELEASE_TAG_PREFIX (test-v): tags become
test-vX.Y.Z and releases pre-releases, which no PDT installs.
Cut a release (this project’s copy of PDT’s Cut a release template, Settings → Workflows) takes
{"type": "stable", "bump": "minor"}, {"type": "stable", "version": "1.0.0"} or {"type": "prerelease"} (a
nightly now); Run now asks the same in a form. This repository’s copy is filled in with: release workflow
release.yml on main, stable inputs {"bump": "{{trigger.bump}}", "version": "{{trigger.version}}"}, release PR
branch release, no stable tag pattern, other release Nightly with inputs {"channel": "nightly"} and tag pattern
v*-nightly.*. It tells the user when the release PR is open, puts a failed check in the Inbox, and says when the
release is published; while an earlier release PR is open it starts nothing and says so. While it waits for the
release it follows the release run (the pull_request run on release at the PR’s head, or the nightly’s dispatched
run on main) and puts a failed or cancelled one in the Inbox with its failed jobs; re-running the failed jobs reuses
the tag, and it still ends with “published”.
Nightly releases
A nightly puts the head of main on hosts that opted in, to try a change on real machines before it ships to
everyone.
- When: every night a scheduler outside GitHub dispatches the release workflow on
mainwithchannel: nightlyandscheduled: true(this repository’s is the maintainer’s “PDT nightly” workflow, 08:17 UTC; the workflow has no GitHubschedule:). Withscheduled, a head that is already released publishes nothing; a nightly tag at the head whose release never got published is built again. By hand:channel: nightlywithoutscheduled(always builds), or Cut a release with{"type": "prerelease"}. - What it does: no release PR, nothing committed. It tags the head of
mainvX.Y.Z-nightly.YYYYMMDD(X.Y.Zthe next version for the bump, a patch by default; a day’s second build.2;scripts/version.sh next-nightly), builds the same signed bundles with that version set in the build’s checkout, and publishes a GitHub pre-release withSHA256SUMSand a preview of the unreleased fragments. It then deletes nightlies beyond the newest 14 (scripts/version.sh prune-nightlies). - Who gets it: hosts with Settings → Updates → Update channel set to nightly. When
X.Y.Zships, it sorts above its nightlies, so nightly hosts move to it. - Migrations: a nightly can carry migrations no stable release has, and hosts never downgrade, so a host that installed one stays on nightlies until a newer stable exists. Merge only migrations you’re happy to keep.
- A failed or missing night: the maintainer’s “PDT nightly release” workflow puts a failed run in the Inbox
(template “Report failed workflow runs”), and “PDT missing nightly” (template “Report a missing scheduled
release”) says “Tonight’s nightly didn’t run” when
mainhas unreleased commits and no nightly for the day, with Start it.
Signing
Every macOS release is signed (scripts/sign-macos.sh) with one certificate from the MACOS_SIGNING_P12 and
MACOS_SIGNING_P12_PASSWORD secrets, and notarized (scripts/notarize-macos.sh) with an App Store Connect API key
(MACOS_NOTARY_KEY, base64 of the .p8; MACOS_NOTARY_KEY_ID; MACOS_NOTARY_ISSUER). A Developer ID
Application certificate carries a Team ID, so the keychain’s partition list trusts every release (teamid:<id>)
and the update token is read without a prompt once allowed. A self-signed one (scripts/make-signing-cert.sh, a
stopgap) has no Team ID: the keychain then trusts each build’s exact code hash, so every update asks again, and
Gatekeeper warns. Check a host with security dump-keychain -a: the item’s partition list should show teamid:.
Google sign-in
Release and nightly bundles build in PDT’s own Google OAuth clients (the GCP project pdt): the standard and agentic
Web clients, the file picker’s API key and the project’s number, from the PDT_GOOGLE_CLIENT_ID,
PDT_GOOGLE_CLIENT_SECRET, PDT_GOOGLE_AGENT_CLIENT_ID, PDT_GOOGLE_AGENT_CLIENT_SECRET, PDT_GOOGLE_PICKER_KEY and
PDT_GOOGLE_PROJECT_NUMBER repository secrets, passed to .github/actions/bundle (CI’s bundles only when they build a
release). Never commit them. A build without them (a local one, a PR’s bundle) offers only the user’s own OAuth client;
set the same variables in the environment to build them in locally. Both clients’ only redirect URI is
https://pdt.dev/oauth/google, and the consent screen links https://pdt.dev/privacy: both are on the website.
Moving the release repository
Hosts find the release repository by its numeric id (RELEASE_REPO_ID in crates/pdt-core/src/brand.rs), which
survives renames and transfers; the name (RELEASE_REPO) is the fallback. To move it: ship a release with
RELEASE_REPO set to the new owner/name, then rename or transfer it, and never create a repository under the old
name (that ends GitHub’s redirects). Settings → This host → Updates should then show a recent “Last checked” and no
error; a host that can’t find it can be pointed at the new one with the Release repository setting.
Testing a branch
Run → pdt (preferred). In the stable PDT, on the branch’s worktree, choose pdt. PDT reads .pdt/project.toml
from that branch, runs its setup (install, web build, cargo build -p pdtd), starts a dev pdtd on a port from the dev
range with the branch’s own data folder, and shows the link (also from the phone). Several branches can run side by
side; restart the run after pushing fixes. Test rust and Test web are the [[test]] entries.
.pdt/project.toml is this repo’s run configuration; its comments explain each setting.
By hand, one at a time per data folder (pdtd refuses a data folder another pdtd holds):
pnpm --dir web buildPDT_PROFILE=dev PDT_PORT=7421 PDT_DATA_DIR=/tmp/pdt-try PDT_WEB_DIR=$PWD/web/dist cargo run -p pdtdWithout PDT_PORT and PDT_DATA_DIR, PDT_PROFILE=dev uses port 7421 and the pdt-dev data folder next to the
installed PDT’s; PDT_SEED=demo fills an empty one with demo data.
For UI work, pnpm --dir web dev serves the UI with hot reload on 5173 and proxies /api to 7421. For an agent that
needs to look at the app, see testing.md.
Desktop tray / installer changes: cargo build -p pdtd -p pdt-host, then PDT_PROFILE=dev cargo run -p pdt-desktop. To check the bundle, scripts/release-stable.sh --build-only (Windows: -BuildOnly); never without it,
never run a built installer, never pdt-host install/uninstall.
Upgrade rehearsal (maintainers) before a release with migrations or plugin changes: scripts/rehearse-upgrade.sh runs this
checkout’s pdtd on port 7421 against a copy of the stable data folder (SQLite’s online backup, plus sessions/ and
pm/) with PDT_REHEARSAL=1: it migrates, installs the built-in workflows and personas and serves the API, but
resumes nothing, starts no loop, keeps secrets in memory and never acts on the copy’s repos or connections. It
reports on migrations, integrations, connections, workflows (validate and dry run), tool lists and the GitHub login.
--keep keeps the copy, --from picks another source, --report FILE saves the report, --port another port.
Exit 0 when every check passes, 1 when one fails, 2 when it could not run.
After merge
For a ticket’s branch, the ticket’s merge banner (Move to Done and remove the worktree) does it all once PDT sees
the merge: it stops the worktree’s sessions and runs, removes the worktree and the ticket’s workspace, moves the
ticket to Done and fast-forwards main (the local branch stays). The user clicks it; an agent in that worktree has
nothing to do.
By hand: check it merged (gh pr view <n> --json state is MERGED), stop the branch’s runs, cd out of the
worktree, then:
git -C <repo> fetch --prune origin && git -C <repo> merge --ff-only origin/maingit -C <repo> worktree remove <worktree path> # or remove it in the project's Settings, so PDT's list stays rightgit -C <repo> branch -D <branch> # -D: squash merges don't look merged to gitGitHub deletes the remote branch on merge. A worktree removed by hand shows as “folder missing” in PDT until it is removed there too.