docs: the versioned install — upgrade and uninstall as first-class flows
README's install/upgrade/uninstall sections rewritten for the versioned layout (side-by-side installs, 'box use' flips, 'box uninstall' replaces the rm -rf prose); CHANGELOG Unreleased entries; the plan doc. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
82f7f9d51a
commit
34bf39be86
3 changed files with 271 additions and 23 deletions
58
CHANGELOG.md
58
CHANGELOG.md
|
|
@ -5,6 +5,64 @@ which records not just what changed but what each drill run proved.
|
||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Versioned installs** (#66's stance, made livable) — install.sh now lands
|
||||||
|
each version side by side at `<root>/versions/<v>` (its own `VERSION` +
|
||||||
|
`INSTALLED_FROM`), with a `current` symlink tracking the default and
|
||||||
|
`$BINDIR/box` riding the chain, the way plenty of CLIs manage theirs. New
|
||||||
|
verbs: `box versions` (lists installs, marks the current default and the
|
||||||
|
running tree), `box use <version>` (flips the default, converges the PATH
|
||||||
|
symlinks, and *asserts the effective result* — `current` must resolve to
|
||||||
|
the asked-for version and the chain's `box --version` must answer it).
|
||||||
|
Re-running the installer with an installed version is a converging no-op
|
||||||
|
(`BOX_REINSTALL=1` replaces that version's tree); a **new** version installs
|
||||||
|
side-by-side and flips `current` only when no boxes exist — under existing
|
||||||
|
boxes the flip is refused loudly, naming the boxes (#66: never change
|
||||||
|
versions under a user's boxes; `box use` keeps the same refusal). A
|
||||||
|
pre-0.7.0 **flat tree is migrated in place** (two renames, the operator's
|
||||||
|
tree preserved bit for bit), so upgrading from 0.6.0 is seamless; a stale
|
||||||
|
or dangling `$BINDIR/box` is healed instead of wedging the install; and the
|
||||||
|
installer warns when the *other* tier's install (/opt/box vs ~/.local)
|
||||||
|
coexists, since PATH order decides which wins.
|
||||||
|
- **A real uninstall** — `box uninstall [<version>] [--all] [--purge-host]`
|
||||||
|
replaces the "rm -rf two paths" prose. One version: refuses the current one
|
||||||
|
(`box use` off it first). Everything: runs in the safe order — refuses
|
||||||
|
while boxes exist (naming them) unless `--purge-host` runs teardown-host
|
||||||
|
first — then removes every version, the `current` and PATH symlinks, and
|
||||||
|
the legacy claudebox crumbs (both name generations), and **ends with an
|
||||||
|
absence assert**: every removed path is re-checked, and any survivor makes
|
||||||
|
it exit 1 as `uninstall INCOMPLETE` naming the leftovers (the
|
||||||
|
`revoke --purge` discipline). `teardown-host.sh` gains `--yes`/`BOX_YES=1`
|
||||||
|
for automation and now points at `box uninstall` when done.
|
||||||
|
- **`BOX_INSTALL_SOURCE=<dir-or-tarball>`** — installs from a local tree,
|
||||||
|
bypassing the download. CI's rehearsal job now installs via install.sh
|
||||||
|
itself (proving the installer under review, not a `cp -r` mimic of it), and
|
||||||
|
ends with an **uninstall drill**: grant + `revoke --purge` a throwaway
|
||||||
|
user, `teardown-host`, `box uninstall --all`, then assert **zero residue**
|
||||||
|
— no networks, profiles, ACLs, nft tables, systemd units, files or
|
||||||
|
symlinks.
|
||||||
|
- **test/cli.sh drives real installs** — still dependency-free, non-root, no
|
||||||
|
daemon: `BOX_INSTALL_SOURCE` + throwaway `BOX_HOME`/`BOX_BIN` roots and a
|
||||||
|
fake `incus` on PATH (`$FAKE_BOXES`) turn layout, chain, no-op/converge,
|
||||||
|
reinstall, side-by-side upgrade, the three #66 refusals (install flip,
|
||||||
|
`use`, `uninstall` — boxes named), flat-tree migration, symlink healing,
|
||||||
|
single-version and zero-residue uninstalls, and the `INCOMPLETE` scream
|
||||||
|
into *driven* tests instead of greps (154 checks).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **`revoke --purge` re-checks the incus-user state** — the purge removed
|
||||||
|
`/var/lib/incus/users/<uid>` without ever asserting its absence, the one
|
||||||
|
path its own absence block did not cover; and the stat now rides
|
||||||
|
`$SUDO test -d` (`/var/lib/incus` is not traversable by a non-root admin,
|
||||||
|
so a bare `[ -d ]` answered "absent" for a directory that was there).
|
||||||
|
- **A wedged `$BINDIR/box` no longer blocks installing** — the old
|
||||||
|
no-op-if-installed check keyed off the symlink's existence OR the tree's,
|
||||||
|
so a stale symlink (or a half-removed tree) could fake "already installed"
|
||||||
|
forever. Installed-ness is now judged from `versions/<v>` itself; symlinks
|
||||||
|
are converged with `ln -sfn`, never trusted as the signal.
|
||||||
|
|
||||||
## 0.6.0 — 2026-07-18
|
## 0.6.0 — 2026-07-18
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
82
README.md
82
README.md
|
|
@ -42,29 +42,48 @@ design rationale.
|
||||||
curl -fsSL https://raw.githubusercontent.com/heavy-duty/box/main/install.sh | bash
|
curl -fsSL https://raw.githubusercontent.com/heavy-duty/box/main/install.sh | bash
|
||||||
```
|
```
|
||||||
|
|
||||||
It asks first — **"Install box?"** — then, if box is not already installed,
|
It asks first — **"Install box?"** — then downloads the tree into a
|
||||||
downloads the tree to `~/.local/share/box`, links `box` onto your `PATH`, and
|
**versioned** install (the way plenty of CLIs manage theirs), links `box` onto
|
||||||
asks a second question: **"Set up this machine as a box host now?"** Say yes and
|
your `PATH`, and on a fresh host asks a second question: **"Set up this
|
||||||
it builds the whole isolation stack for you (it may ask for `sudo`); say no and
|
machine as a box host now?"** Say yes and it builds the whole isolation stack
|
||||||
you can run `box setup-host` later. (No `git clone` needed.)
|
for you (it may ask for `sudo`); say no and you can run `box setup-host`
|
||||||
|
later. (No `git clone` needed.)
|
||||||
|
|
||||||
**Re-running is a safe no-op.** If box is already installed, the installer tells
|
The layout, under the install root (`~/.local/share/box`, or `/opt/box` for a
|
||||||
you so and changes nothing — a stray re-run can never clobber your install or
|
root install):
|
||||||
rebuild the stack under your boxes. Upgrading is therefore explicit: uninstall
|
|
||||||
what you have and install fresh. Preserve any boxes first — `box down <box>`,
|
```
|
||||||
copy out anything you need (a portable `box export` is
|
versions/<version>/ one full tree per installed version
|
||||||
|
current -> versions/<v> the tracked default
|
||||||
|
$BINDIR/box -> current/bin/box the PATH entry, riding the chain
|
||||||
|
```
|
||||||
|
|
||||||
|
**Re-running is a safe converge.** Installing a version you already have
|
||||||
|
changes nothing and says so (`BOX_REINSTALL=1` replaces that version's tree);
|
||||||
|
a stray re-run can never clobber your install or rebuild the stack under your
|
||||||
|
boxes. Installing a **new** version lands it side by side and flips `current`
|
||||||
|
only when you have **no boxes** — under existing boxes the flip is refused
|
||||||
|
(never change versions under a user's boxes,
|
||||||
|
[#66](https://github.com/heavy-duty/box/issues/66)) and switching stays a
|
||||||
|
deliberate act: preserve what you care about — `box down <box>`, copy out
|
||||||
|
anything you need (a portable `box export` is
|
||||||
[#70](https://github.com/heavy-duty/box/issues/70)), then `box rm <box>`
|
[#70](https://github.com/heavy-duty/box/issues/70)), then `box rm <box>`
|
||||||
(which deletes the box _and_ its snapshots) — then:
|
(which deletes the box _and_ its snapshots) — then:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
rm -rf ~/.local/share/box ~/.local/bin/box # uninstall
|
box versions # what is installed, which is current, which is running
|
||||||
curl -fsSL https://raw.githubusercontent.com/heavy-duty/box/main/install.sh | bash
|
box use <version> # flip the default (same refusal while boxes exist)
|
||||||
```
|
```
|
||||||
|
|
||||||
A version-aware upgrade that migrates boxes instead of asking you to is
|
A pre-0.7.0 flat install is migrated into `versions/` automatically on the
|
||||||
[#67](https://github.com/heavy-duty/box/issues/67). For unattended installs
|
next installer run — the tree is moved, not re-downloaded, and your boxes are
|
||||||
(CI, images), `BOX_YES=1` answers every prompt yes and `BOX_SKIP_SETUP_HOST=1`
|
untouched. A version-aware upgrade that migrates boxes instead of asking you
|
||||||
declines the host-setup step.
|
to is [#67](https://github.com/heavy-duty/box/issues/67). For unattended
|
||||||
|
installs (CI, images), `BOX_YES=1` answers every prompt yes,
|
||||||
|
`BOX_SKIP_SETUP_HOST=1` declines the host-setup step, and
|
||||||
|
`BOX_INSTALL_SOURCE=<dir-or-tarball>` installs from a local tree instead of
|
||||||
|
downloading (how CI proves the installer under review, and how the drill can
|
||||||
|
install an unpushed branch).
|
||||||
|
|
||||||
### Global vs per-user install
|
### Global vs per-user install
|
||||||
|
|
||||||
|
|
@ -83,7 +102,9 @@ box's tree is _executed by other users_ — so it cannot hide in one user's home
|
||||||
|
|
||||||
`BOX_HOME` / `BOX_BIN` override the destination on either path. A per-user
|
`BOX_HOME` / `BOX_BIN` override the destination on either path. A per-user
|
||||||
install under `/root` would be `0700` and unreadable to everyone else — which
|
install under `/root` would be `0700` and unreadable to everyone else — which
|
||||||
is exactly the bug the root branch fixes.
|
is exactly the bug the root branch fixes. When both tiers are installed, PATH
|
||||||
|
order decides which `box` wins — the installer warns when it sees the other
|
||||||
|
tier's tree.
|
||||||
|
|
||||||
## One-time host setup (Ubuntu 24.04 / Debian 13)
|
## One-time host setup (Ubuntu 24.04 / Debian 13)
|
||||||
|
|
||||||
|
|
@ -206,8 +227,7 @@ form; flags win). The template's identity (name, user) is stamped onto the insta
|
||||||
so `shell`, `exec` and `tmux` land in the right user — and a clone still
|
so `shell`, `exec` and `tmux` land in the right user — and a clone still
|
||||||
knows, because `incus copy` carries the metadata.
|
knows, because `incus copy` carries the metadata.
|
||||||
|
|
||||||
## Log in once, reuse via snapshotsrm -rf ~/.local/share/box ~/.local/bin/box # per-user install
|
## Log in once, reuse via snapshots
|
||||||
sudo rm -rf /opt/box /usr/local/bin/box # global (root) install
|
|
||||||
|
|
||||||
Because every fresh box is creds-free, re-authenticating each time would be
|
Because every fresh box is creds-free, re-authenticating each time would be
|
||||||
toil. Snapshot an authenticated box and clone from it instead:
|
toil. Snapshot an authenticated box and clone from it instead:
|
||||||
|
|
@ -388,13 +408,29 @@ documentation, not a host-executed script. See
|
||||||
|
|
||||||
## Uninstall
|
## Uninstall
|
||||||
|
|
||||||
|
`box uninstall` is the real uninstall, and it runs in the safe order — boxes
|
||||||
|
first, then the stack, then the tree — and **ends with an absence assert**:
|
||||||
|
every path it removed is re-checked, and any survivor makes it exit 1 naming
|
||||||
|
the leftovers instead of reporting a clean uninstall that wasn't (the same
|
||||||
|
discipline as `box revoke --purge`).
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
box teardown-host # boxes, network, ACL, profile, firewall
|
box uninstall <version> # one non-current version (side-by-side cleanup)
|
||||||
box teardown-host --purge-incus # ...and Incus itself
|
box uninstall --all --purge-host # everything: teardown-host (all boxes, the
|
||||||
rm -rf ~/.local/share/box ~/.local/bin/box # per-user install
|
# boxnet stack, the firewall), then every
|
||||||
sudo rm -rf /opt/box /usr/local/bin/box # global (root) install
|
# version, the symlinks, legacy claudebox crumbs
|
||||||
|
box uninstall # just the install — refuses while boxes exist
|
||||||
|
# (and names them); run teardown-host first,
|
||||||
|
# or use --purge-host
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The full-removal order on a multi-user host: `box revoke <user> --purge` each
|
||||||
|
granted user (it asserts its own zero-residue, including the incus-user state
|
||||||
|
under `/var/lib/incus/users/`), then `box teardown-host` (add `--purge-incus`
|
||||||
|
to drop Incus itself, `--yes`/`BOX_YES=1` for automation), then
|
||||||
|
`box uninstall`. CI drills exactly this sequence and asserts zero residue —
|
||||||
|
no networks, profiles, nft tables, systemd units, files or symlinks.
|
||||||
|
|
||||||
## Non-goals
|
## Non-goals
|
||||||
|
|
||||||
- **No unattended/CI bring-up.** The flow is interactive (log in, clone, ask
|
- **No unattended/CI bring-up.** The flow is interactive (log in, clone, ask
|
||||||
|
|
|
||||||
154
docs/plans/2026-07-18-versioned-install.md
Normal file
154
docs/plans/2026-07-18-versioned-install.md
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
# Versioned installs, and a real uninstall (0.7.0 core)
|
||||||
|
|
||||||
|
**Status: implemented and tested.** 154/154 in `test/cli.sh` (which now
|
||||||
|
*drives* real installer runs, not greps of them), shellcheck clean, and CI's
|
||||||
|
rehearsal job installs via `install.sh` itself and ends with a zero-residue
|
||||||
|
uninstall drill on a live Incus. This doc records the design and why each
|
||||||
|
decision fell where it did.
|
||||||
|
|
||||||
|
## What was asked
|
||||||
|
|
||||||
|
Two maintainer requests, one PR:
|
||||||
|
|
||||||
|
1. **Install should be versioned** — each box version goes to its own folder
|
||||||
|
and a tracked default names the one you run, like plenty of CLIs manage
|
||||||
|
theirs. Before this, `install.sh` refused to touch an existing install at
|
||||||
|
all: changing versions meant uninstalling by hand (`rm -rf` two paths from
|
||||||
|
the README) and re-running the installer.
|
||||||
|
2. **Uninstall is flaky** — there was no uninstall verb at all, only prose;
|
||||||
|
`teardown-host.sh` deliberately leaves the install tree; and nothing
|
||||||
|
encoded the safe full-removal order (revoke users → teardown-host → remove
|
||||||
|
trees/symlinks). Add thorough tests for uninstall, and for grant/revoke
|
||||||
|
that they are clean.
|
||||||
|
|
||||||
|
## The layout
|
||||||
|
|
||||||
|
```
|
||||||
|
<root>/ /opt/box (root) or ~/.local/share/box (user);
|
||||||
|
BOX_HOME overrides — both unchanged from #71
|
||||||
|
versions/<version>/ one full tree per version, each with its own
|
||||||
|
VERSION + INSTALLED_FROM
|
||||||
|
current -> versions/<v> the tracked default (a relative symlink, so the
|
||||||
|
root can move as a unit)
|
||||||
|
$BINDIR/box -> <root>/current/bin/box
|
||||||
|
```
|
||||||
|
|
||||||
|
The version key is the tree's own `VERSION` file — the identity of what was
|
||||||
|
installed, and the name `box versions` lists. `bin/box` needed **no change**
|
||||||
|
to run from here: line 8 already derives `$root` via `readlink -f`, which
|
||||||
|
resolves the whole `$BINDIR/box → current → versions/<v>` chain, so
|
||||||
|
`VERSION`, `templates/`, `host/` and `drill/` all resolve inside the version
|
||||||
|
tree that is actually running. That same fact is how the new verbs detect
|
||||||
|
their world: a versioned install always runs from `.../versions/<v>`; a git
|
||||||
|
checkout does not, and the verbs refuse instead of uninstalling somebody's
|
||||||
|
working copy.
|
||||||
|
|
||||||
|
## Install semantics (#66's stance, kept — at the flip)
|
||||||
|
|
||||||
|
#66 established: a stray installer re-run must never clobber a working
|
||||||
|
install or rebuild the stack under existing boxes. The old enforcement was a
|
||||||
|
blanket "refuse if anything is installed", which also blocked upgrades. The
|
||||||
|
versioned layout splits the two concerns:
|
||||||
|
|
||||||
|
- **Same version present** → converging no-op ("already installed", exit 0);
|
||||||
|
`BOX_REINSTALL=1` replaces that version's tree via two renames (never a
|
||||||
|
partial overlay). A converge/reinstall of a non-current version never
|
||||||
|
moves the default — switching is `box use`, a deliberate act.
|
||||||
|
- **Different version** → installs side-by-side, then flips `current` **only
|
||||||
|
when no boxes exist**. With boxes present (both tag generations, checked at
|
||||||
|
the caller's tier via a shared `existing_boxes()` — byte-identical in
|
||||||
|
`install.sh` and `bin/box`, diffed by the tests so the two #66 stances
|
||||||
|
cannot drift), the flip is refused loudly, the boxes are *named*, and the
|
||||||
|
operator is pointed at the remedy: down/copy-out/rm, then `box use <v>`.
|
||||||
|
A daemon that is absent or not answering has no boxes to protect — the
|
||||||
|
stance guards boxes, not daemons.
|
||||||
|
- **Pre-0.7.0 flat tree** → migrated before anything else: `mv` the root
|
||||||
|
aside, `mkdir versions/`, `mv` it to `versions/<its-VERSION>`, link
|
||||||
|
`current` and `$BINDIR/box`. Two renames inside one parent directory — no
|
||||||
|
copy, no window with no install, the operator's tree preserved bit for bit
|
||||||
|
(the tests assert the migrated tree's own `INSTALLED_FROM` survives).
|
||||||
|
- **Wedged symlinks** → healed, never trusted. The old no-op check keyed off
|
||||||
|
`$BINDIR/box` *or* `$DEST/bin/box` existing, so a stale symlink (or a
|
||||||
|
half-removed tree) faked "already installed" forever. Installed-ness is now
|
||||||
|
judged from `versions/<v>` itself; `ln -sfn` converges the links.
|
||||||
|
- **Tier coexistence** → a root and a per-user install shadow each other by
|
||||||
|
PATH order alone; the installer warns when it sees the other tier's tree.
|
||||||
|
- Host setup is offered on **fresh** hosts only — an upgraded host has made
|
||||||
|
that decision (and may have live boxes the stack must not be rebuilt
|
||||||
|
under); `box setup-host` re-applies stack changes deliberately.
|
||||||
|
|
||||||
|
`BOX_INSTALL_SOURCE=<dir-or-tarball>` bypasses the download (a directory is
|
||||||
|
tar-copied with `--exclude=.git`). This exists for CI and the drill — the
|
||||||
|
code under review is what lands — and it is what turned the test suite's
|
||||||
|
install coverage from greps into real runs.
|
||||||
|
|
||||||
|
## The new verbs
|
||||||
|
|
||||||
|
Table rows like every other verb (the CMDS table stays the single source of
|
||||||
|
truth); `uninstall` joins the host-verb flag passthrough so `--all` /
|
||||||
|
`--purge-host` reach it.
|
||||||
|
|
||||||
|
- `box versions` — lists `versions/*`, marking the current default and the
|
||||||
|
tree answering the command (they differ when another install shadows yours
|
||||||
|
on PATH).
|
||||||
|
- `box use <version>` — same existing-boxes refusal as the installer's flip
|
||||||
|
(shared helper, boxes named), then repoints `current`, converges every PATH
|
||||||
|
symlink that resolves into this install root (never one that is somebody
|
||||||
|
else's), and **asserts the effective result**: `current` must resolve to
|
||||||
|
the asked-for version and `current/bin/box --version` must answer it. A
|
||||||
|
flip that "worked" while the operator still runs the old tree is exactly
|
||||||
|
the flakiness this verb exists to end.
|
||||||
|
- `box uninstall [<version>] [--all] [--purge-host]` —
|
||||||
|
- one version: refuses the current one; removes the dir; re-checks it.
|
||||||
|
- full: the safe order — refuse while boxes exist (naming them) unless
|
||||||
|
`--purge-host` runs `teardown-host.sh` first (its own confirmation; a
|
||||||
|
note names granted users' surviving projects and `revoke --purge` as the
|
||||||
|
clean path); confirm (`--force` / `BOX_YES=1` — installer-family consent,
|
||||||
|
deliberately *not* the lifecycle `confirm()`, which must never
|
||||||
|
auto-accept from the environment); gather the removal set (root, every
|
||||||
|
PATH symlink pointing into it, claudebox crumbs of both name
|
||||||
|
generations); remove; then **the absence assert**: every path re-checked
|
||||||
|
for file/dir/symlink existence, any survivor → exit 1
|
||||||
|
`uninstall INCOMPLETE` naming the leftovers. `rm`'s exit code is not the
|
||||||
|
verdict — the re-check is (a half-removed tree is INCOMPLETE, not a
|
||||||
|
crash).
|
||||||
|
|
||||||
|
## Grant/revoke cleanliness
|
||||||
|
|
||||||
|
Reading `revoke-user.sh` against its own absence assert found the gap: the
|
||||||
|
purge removes `/var/lib/incus/users/<uid>` but never re-checks it — and the
|
||||||
|
stat was a bare `[ -d ]`, which lies for a non-root admin (`/var/lib/incus`
|
||||||
|
is not traversable, so the directory reads as absent while it is there).
|
||||||
|
Both fixed: the check rides `$SUDO test -d`, and the absence block now covers
|
||||||
|
the state dir. Grepped-and-guarded in `test/cli.sh`; drilled live in CI.
|
||||||
|
|
||||||
|
## Tests (the heart of this PR)
|
||||||
|
|
||||||
|
`test/cli.sh` stays dependency-free, non-root, daemon-free. New machinery: a
|
||||||
|
fake `incus` on PATH whose `list` prints `$FAKE_BOXES`, throwaway
|
||||||
|
`BOX_HOME`/`BOX_BIN` roots, and fabricated second/third sources with
|
||||||
|
different `VERSION`s. Driven end to end: fresh layout + chain
|
||||||
|
(`box --version` through both symlinks), no-op/canary, `BOX_REINSTALL`,
|
||||||
|
side-by-side + no-boxes flip, all three #66 refusals (install flip, `use`,
|
||||||
|
`uninstall` — boxes named, remedies named), `versions` markers, `use`
|
||||||
|
flip-and-assert, flat-tree migration (alone, and combined with an upgrade),
|
||||||
|
dangling- and stale-symlink healing, single-version uninstall (current
|
||||||
|
refused), full uninstall with planted legacy crumbs and a zero-residue
|
||||||
|
assert (files *and* symlinks *and* legacy names), the INCOMPLETE scream
|
||||||
|
(a chmod-pinned survivor), and refusals from a working tree. The existing
|
||||||
|
DEST/BINDIR-branch tests are kept unchanged (the branch itself is unchanged).
|
||||||
|
|
||||||
|
CI's rehearsal job now installs via `install.sh`
|
||||||
|
(`BOX_INSTALL_SOURCE=$GITHUB_WORKSPACE`), asserts the layout it left, runs
|
||||||
|
the stack from `/opt/box/current/...`, and appends the uninstall drill:
|
||||||
|
grant + `revoke --purge` a throwaway user (asserting the incus-user state
|
||||||
|
dir is gone), `teardown-host` (new `--yes`/`BOX_YES` support), `box
|
||||||
|
uninstall --all`, then zero residue — networks, profiles, ACLs, nft tables,
|
||||||
|
systemd units, files, symlinks, both name generations.
|
||||||
|
|
||||||
|
## What this is not
|
||||||
|
|
||||||
|
- Not #67: boxes still do not migrate across versions — this PR delivers the
|
||||||
|
version-agnostic upgrade *path* (side-by-side installs, an explicit flip);
|
||||||
|
data migration remains #67.
|
||||||
|
- Not a release: `VERSION` is untouched (a release PR bumps it).
|
||||||
Loading…
Reference in a new issue