Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions .github/workflows/catalogue-lint.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
name: catalogue-lint

# Gates changes to the app-store catalogue (catalogue/catalogue.json):
#
# * Stateful-app release freeze. Nodes upgrade installed apps hourly with the
# pilotctl they already run, and a pilotctl without the app-state fix
# deletes an app's saved state (wallet EVM key + data.db, smol secrets,
# per-app identities) when it applies an update. Any update to an app listed
# in catalogue/stateful-apps.json, or whose bundle manifest grants fs.write
# or key.sign, fails until it is approved: an approved_bumps entry in
# catalogue/stateful-apps.json, or the PR label
# `catalogue:stateful-bump-approved` (re-runs on label changes).
# * Bundle checks for every added/changed entry: sha pins, manifest id and
# version, binary pin, and that each binary runs on the platform it is
# published for (a legacy single bundle must not ship a native binary).
#
# See catalogue/README.md ("Stateful apps: release freeze").

on:
pull_request:
types: [opened, synchronize, reopened, labeled, unlabeled]
paths:
- 'catalogue/**'
- '.github/workflows/catalogue-lint.yml'

permissions:
contents: read

jobs:
lint:
name: catalogue lint
runs-on: ubuntu-latest
timeout-minutes: 20
env:
GOWORK: 'off'
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0

- uses: actions/setup-go@v7
with:
go-version-file: go.mod

- name: Unit tests (catalogue/lint)
working-directory: catalogue/lint
run: go test -count=1 ./...

- name: Lint catalogue changes against the PR base
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
STATEFUL_BUMP_APPROVED: ${{ contains(github.event.pull_request.labels.*.name, 'catalogue:stateful-bump-approved') }}
run: |
set -euo pipefail
merge_base="$(git merge-base "$BASE_SHA" HEAD)"
base_json="$RUNNER_TEMP/base-catalogue.json"
# A base without a catalogue (first introduction) lints every entry as new.
git show "$merge_base:catalogue/catalogue.json" > "$base_json" 2>/dev/null || : > "$base_json"
args=(--base "$base_json" --head "$GITHUB_WORKSPACE/catalogue/catalogue.json" --policy "$GITHUB_WORKSPACE/catalogue/stateful-apps.json")
if [ "$STATEFUL_BUMP_APPROVED" = "true" ]; then
echo "::notice::PR carries catalogue:stateful-bump-approved; stateful-app updates are reported as warnings"
args+=(--allow-stateful-bumps)
fi
cd catalogue/lint
go run . "${args[@]}"
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,33 @@ Detailed per-release notes are on the
or an installer re-run — bypassed the disabled flag and re-injected skills a
user had turned off. The opt-out is now a hard gate on every write path; only
the read-only `pilotctl skills` status still previews. (skillinject)
- **App-store installs and upgrades keep an app's saved state.**
`pilotctl appstore install --force` and `appstore upgrade` (which the updater
runs hourly as `upgrade --all`) deleted everything an app kept in its own
directory, such as the wallet's EVM key (`identity-evm.json`) and `data.db`,
smol's `secrets.json` and per-app identities. Every file that is not part of
the bundle is now carried into the new install, and the replaced install is
kept as a backup in `app-backups/<id>/` beside the install root
(`$PILOT_APPSTORE_BACKUP_ROOT` overrides). The newest 3 routine backups of
each kind are kept; a backup that may be the only copy of state is never
pruned. `uninstall` lists the backups that remain.
- `install <id>` on an installed app is now a no-op (exit 0) that points to
`upgrade`. Before, it failed with `conflict`. `conflict` now means
`--version` or a local bundle names another version without `--force`.
- New `--reset-state` (implies `--force`) reinstalls without the old state,
with a warning. The backup is still kept.
- Installs of one app are serialized. A second one waits up to 5 minutes,
then fails with `timeout`.
- `upgrade --all` tries every app and exits 1 at the end with
`upgrade_failed`, naming the apps that failed.
- Install JSON adds `already_installed`, `hint`, `preserved_state`,
`state_reset`, `state_not_carried`, `backup_dir` and `backup_warning`.
Uninstall JSON adds `backups`.
- The check that refuses a bundle whose binary cannot run on this host now
also covers universal Mach-O and PE images. The refusal names the app,
version and host platform, and says nothing was installed.
- The catalogue lint blocks releases of stateful apps until nodes run a
pilotctl with this fix.

## [1.12.8] - 2026-07-16

Expand Down
108 changes: 108 additions & 0 deletions catalogue/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,114 @@ verifies the signature against the embedded catalogue public key before
trusting any entry. An unsigned, missing-signature, or tampered catalogue
is refused (fail-closed).

### A published update reaches every node within the hour

Nodes with auto-update on run `pilotctl appstore upgrade --all` every hour.
Anything that changes what a node would install triggers it: a new `version`,
or a new bundle sha under the same version (a republish, which newer pilotctl
detects via the `.bundle-sha256` it records). Each node runs the upgrade with
**its own installed pilotctl**, so the upgrade behaves the way the oldest
pilotctl in the fleet does.

## Stateful apps: release freeze (CI lint)

Apps keep their state inside their install dir (`$APP` = `~/.pilot/apps/<id>/`):
the wallet's `identity-evm.json` (its EVM private key) and `data.db`, smol's
`secrets.json`, each metered app's `identity.json`, the `cap-state.jsonl`
spend-cap ledger and `supervisor.log`. A pilotctl **without** the app-state
fix (it landed with the "appstore: keep app state across install --force and
upgrade" change) replaces that dir on every `install --force` and every
`upgrade` and deletes it, keys included. A catalogue update for a stateful app
therefore wipes that app's state on every node still running an older
pilotctl, within the hour, with no prompt.

So every PR that touches `catalogue/` runs the **catalogue-lint** job
(`.github/workflows/catalogue-lint.yml`, code in `catalogue/lint/`). It
compares the PR's catalogue with its base and **fails** when an update (new
version or same-version republish) targets a stateful app:

- an app listed in `catalogue/stateful-apps.json` (`stateful_apps`: wallet,
smol, agentphone, bowmark, orthogonal), or
- any app whose old or new bundle manifest grants `fs.write` or `key.sign`
(it writes files into `$APP`, or signs with its own identity key). A bundle
that cannot be downloaded to check counts as stateful.

**Hold the release** until the fleet runs the fixed pilotctl. To ship one
anyway (the fleet has caught up, or the release is urgent and the risk is
accepted), approve that exact version, one of two ways:

1. **Approval file (preferred, stays in history):** add an entry to
`approved_bumps` in `catalogue/stateful-apps.json` in the same PR:
```json
{"id": "io.pilot.wallet", "version": "0.3.4",
"reason": "fleet runs the fixed pilotctl (registry version query, 2026-10-01)",
"approved_by": "<maintainer>"}
```
All four fields are required; an approval only covers that id + version.
2. **PR label:** a maintainer applies `catalogue:stateful-bump-approved`. The
job re-runs on label changes and reports the update as a warning.

Remove the freeze (empty `stateful_apps`, or delete the check) only once the
registry's node-version distribution shows the fleet on a pilotctl with the
fix.

The same job also checks, for every **added or changed** entry, each bundle it
publishes: the download matches `bundle_sha256`, the manifest's `id` and
`app_version` match the entry (a mismatched version makes every node reinstall
the app every hour), the binary matches the manifest's pin, and the binary runs
on the platform it is published under. An entry **without** a `bundles` map is
installed by every platform, so it must not ship a native (ELF, Mach-O, PE)
binary at all; publish per-platform `bundles` instead. Scripts and portable
adapters are fine in a single bundle. pilotctl enforces the same at install
time: a binary built for another platform is refused with `platform_mismatch`
and nothing is installed.

Run it locally:

```bash
git show origin/main:catalogue/catalogue.json > /tmp/base.json
(cd catalogue/lint && GOWORK=off go run . --base /tmp/base.json --head ../catalogue.json)
```

### What install and upgrade do with app state (fixed pilotctl)

- `pilotctl appstore install <id>` on an installed app changes nothing and
points at `pilotctl appstore upgrade <id>`. With `--version X` for a version
other than the installed one (or a local bundle of another version) it fails
with `conflict` unless `--force` is given, and with `version_unavailable`
when the catalogue does not offer X.
- `install --force` and `upgrade` carry everything in `$APP` that the new
bundle does not ship into the new install, except control files
(`manifest.json`, `install.json`, `install.sh`, `.sideloaded`, `.suspended`,
`.resume`, `.bundle-sha256`, next-steps caches) and sockets. Files are
hard-linked, so writes the still-running app makes to them in place are
kept; read-only dirs carry like any other; an entry this user cannot link
or read (say, a root-owned file) is moved across instead. After the swap
the old dir is checked again: a file the app replaced (write + rename) or
created there during the install is taken into the new install, unless the
new install's copy changed since (the newer write wins). A write the old
process makes after that through a path relative to its working directory
(not through `$APP`) still lands in the backup, until the supervisor
restarts it on the new version (within ~30s). The old dir stays at
`<id>.previous` until the new one verifies.
- Installs, upgrades and uninstalls of one app take a lock
(`<install root>/.<id>.lock`), so the hourly `upgrade --all` and an agent's
`install` never interleave.
- The replaced dir is kept as a backup in `app-backups/<id>/` beside the
install root (`~/.pilot/app-backups`, or `$PILOT_APPSTORE_BACKUP_ROOT`,
which may be on another filesystem: it is then copied). If that location is
unusable, the backup goes to the default location, then to
`<install root>/.app-backups/<id>/`, and pilotctl warns. Each backup has a
`.pilot-backup.json` saying what kind it is. Routine backups are rotated
(the newest 3 upgrades and the newest 3 same-version reinstalls per app);
a backup that holds the only copy of state (`--reset-state`, state that
could not be carried, a crash leftover) is never removed automatically.
`uninstall` leaves backups and lists every one of them.
- `install --reset-state` (implies `--force`) is the explicit way to start an
app empty. It warns loudly and still keeps the backup.
- `upgrade --all` goes on to the next app when one fails, and exits 1 at the
end naming the apps that were not upgraded.

## Catalogue signing key

The catalogue is signed with a dedicated ed25519 key, separate from any
Expand Down
3 changes: 3 additions & 0 deletions catalogue/lint/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module github.com/pilot-protocol/pilotprotocol/catalogue/lint

go 1.25
Loading
Loading