docs/internal/packaging-self-update.md
Status: design + Phase 1 landed. This document specifies a new packaging paradigm for
freshwhose defining property is deterministic install provenance: every distribution channel records — at install time — exactly which mechanism installed the binary, so the editor can self-update through the same mechanism without ever guessing. It supersedes the path-based heuristic inservices/release_checker.rs.Implemented (Phases 1–3):
Phase 1 — the
fresh-updatesubcrate:Channelregistry,install-receipt.tomlformat, layeredresolve()with confidence levels, the update-command registry, checksum verification, the atomic binary swap, and version comparison.release_checkerdelegates to it (the portable check/parse/detect logic now lives in the crate).Phase 2 — every packaging pipeline writes a receipt (deb, rpm, AUR bin+source, Homebrew, npm, Flatpak, Nix, install.sh AppImage, and the raw release archive). CI asserts the deb/rpm/Flatpak receipts.
Phase 3 —
fresh --cmd update [--check] [--yes] [--allow-downgrade](behind the defaultself-updatefeature): delegate to the package manager, or download → verify SHA-256 → extract → atomic swap for tarball/AppImage.fresh config pathsprints resolved provenance.Phase 4 (partial) — interactive in-editor update: the status-bar update indicator is clickable (and an "Update fresh" command exists); clicking prompts "Update now?", and confirming runs the update locally in the background (never on the window's remote
Authority), logging to<log_dir>/self-update.log. Gated by theself_updateconfig (default on). The indicator itself relays state (no transient status line): it showsUpdating…while the child runs, thenUpdated — restart freshorUpdate failed — click for logwhen a watcher thread reaps the process. Clicking the indicator once an update is under way (or the "Open update log" command) opens that log — read from the local filesystem viaopen_local_file, so a window bound to a remote authority still shows the right machine's log.Not yet done: GitHub build-attestation verification (SHA-256 is enforced; attestation is still a follow-up), and an optional
auto_update(no-prompt) mode. See §15 and §17.
fresh already ships through an unusually large number of channels (see the
inventory in §3). What it does not have is a reliable way to answer one
question at runtime:
"How was this copy of
freshinstalled, and therefore how should it be updated?"
Today services/release_checker.rs answers that by inspecting
current_exe() and pattern-matching the path (/opt/homebrew/… → Homebrew,
/.cargo/bin/… → Cargo, /usr/bin/… + Arch → AUR, …). That is a guess, and
it is wrong or blind in many real cases:
| Situation | Heuristic result | Truth |
|---|---|---|
AUR fresh-editor-bin vs pacman-official vs a manually-dropped /usr/bin/fresh | all → Aur | indistinguishable |
.deb vs .rpm install | both → PackageManager | different upgrade command |
| winget / scoop / chocolatey | Unknown | no detection at all |
| Flatpak / snap / AppImage | Unknown / PackageManager | no detection at all |
Homebrew with a custom --prefix | Unknown | should be Homebrew |
Cargo with CARGO_HOME relocated | Unknown | should be Cargo |
npm via pnpm / volta / nvm shims | often Unknown | should be npm |
FreeBSD pkg | PackageManager | should be pkg |
And even when the guess is right, the checker only prints a command — it
never actually updates anything (install-updater = false in
dist-workspace.toml).
The user requirement driving this design is explicit: the editor must know for sure which mechanism was used (AUR vs winget vs brew vs …) and self-update via that same mechanism, on Linux, Windows, and macOS.
The only way to know for sure is to stop inferring and start recording.
Goals
fresh. Runtime reads the record; it does not guess.winget upgrade, brew → brew upgrade, a raw
tarball → in-place binary swap.apt upgrade). It delegates instead.install.sh AppImage, direct download) the editor
performs a verified, atomic, in-place update.--no-upgrade-check, check_for_updates); introduce no new phone-home.Non-goals
fresh is a single self-contained Rust binary (fresh, crate fresh-editor,
embed-plugins bakes plugins + themes in). The release pipeline
(.github/workflows/release.yml, triggered on v* tags) produces one set of
per-target prebuilt archives and then feeds every downstream channel:
| # | Channel | Canonical id | Artifact / mechanism | Who owns updates |
|---|---|---|---|---|
| 1 | Homebrew (tap sinelaw/homebrew-fresh) | homebrew | .tar.xz via formula | brew |
| 2 | npm @fresh-editor/fresh-editor | npm | wrapper downloads archive | npm |
| 3 | crates.io | cargo | source build | cargo |
| 4 | cargo-binstall | cargo-binstall | prebuilt archive | cargo-binstall |
| 5 | AUR fresh-editor-bin | aur-bin | prebuilt .tar.xz | AUR helper |
| 6 | AUR fresh-editor (source) | aur | source build | AUR helper |
| 7 | Debian/Ubuntu .deb | apt | dpkg | apt/dpkg |
| 8 | Fedora/RHEL .rpm | dnf | rpm | dnf/rpm |
| 9 | openSUSE | zypper | the .rpm, installed by hand | (no repo — see §6) |
| 10 | Flatpak io.github.sinelaw.fresh | flatpak | flatpak bundle | flatpak |
| 11 | AppImage (install.sh / direct) | appimage | extracted to ~/.local | fresh (self) |
| 12 | winget sinelaw.fresh-editor | winget | .zip | winget |
| 13 | Scoop (planned) | scoop | .zip | scoop |
| 14 | Chocolatey (planned) | chocolatey | .zip | choco |
| 15 | Nix flake | nix | source build | nix |
| 16 | FreeBSD pkg/ports | freebsd-pkg | pkg | pkg |
| 17 | mise (github:sinelaw/fresh) | mise | prebuilt archive | mise |
| 18 | Raw GitHub release archive | tarball | manual download | fresh (self) |
| 19 | Built from a git checkout | source | cargo build | developer |
The pipeline already emits per-archive .sha256 files and enables GitHub
artifact attestations (github-attestations = true), plus npm OIDC
provenance — the raw materials for secure self-update already exist.
Provenance is resolved at runtime by a new module,
services::provenance, which returns a Provenance { channel, confidence, hints }. It is computed from three sources, in strict precedence order.
FRESH_INSTALL_CHANNEL env var, if set, wins. This is the escape hatch:
testing, CI, packagers overriding a wrong receipt, and power users. Confidence
= Overridden.
A small file, install-receipt.toml, written at install time by whatever
performed the install. Two flavours, both authoritative:
Packaged receipt — for OS/package-manager channels, the receipt is a
file inside the package (.deb, .rpm, AUR, flatpak, winget, scoop, choco,
nix, pkg). Because the package manager lays it down and removes it on
uninstall, it is tamper-evident and always consistent with what is installed.
It is read-only to the running editor — the editor can never accidentally
"upgrade" a packaged receipt into claiming self-update capability.
Sidecar receipt — for wrapper/manual channels that unpack the shared
prebuilt archive (npm, homebrew-bin, cargo-binstall, AppImage, install.sh,
raw tarball), the installer script writes the receipt next to the binary or
into the per-user data dir.
Search order for the receipt (first hit wins):
1. <dir(exe)>/install-receipt.toml # sidecar, same dir
2. <dir(exe)>/../share/fresh/install-receipt.toml # FHS, binary name
3. <dir(exe)>/../share/fresh-editor/install-receipt.toml # FHS, package name
4. <dir(exe)>/../lib/fresh/install-receipt.toml # npm/node layout
5. $XDG_DATA_HOME/fresh/install-receipt.toml (Linux) # per-user fallback
~/Library/Application Support/fresh/… (macOS)
%LOCALAPPDATA%\fresh\… (Windows)
Candidates 2 and 3 differ only in directory name, and both are required
because the binary and the package are not named the same thing. The binary is
fresh; the package is fresh-editor. OS packages put their data under
/usr/share/<package>, so .deb (debian/rules) and .rpm
([package.metadata.generate-rpm]) ship /usr/share/fresh-editor/, while the
wrapper channels that install under a prefix they control — the Homebrew
formula, the Flatpak manifest, the Nix derivation — use share/fresh/.
Both spellings are already installed on real machines, so the resolver searches both rather than picking a winner: renaming either side would strand every copy installed before the change. Note this is a search path, not a write path — installers must keep writing the one location their packaging convention dictates, and never both.
Confidence = Authoritative.
build.rs embeds FRESH_BUILD_CHANNEL (from the build env var of the same
name) and the target triple. Source-building channels that own their build set
it to their id: crates.io → cargo, AUR-source → aur, Nix → nix, the
.deb/.rpm builders → apt/dnf, FreeBSD ports → freebsd-pkg. When unset
(the default for the shared prebuilt archive and for developer builds) it is
prebuilt (release CI) or source (dirty git tree), which tells the resolver
"trust the receipt, and if there is none you're a bare tarball."
Confidence = Embedded.
The existing detect_install_method_from_path logic, retained but demoted. It
only runs when A–C produce nothing. Confidence = Heuristic. Results at this
confidence are shown to the user as a suggestion and never trigger an
automatic binary swap.
Overridden > Authoritative > Embedded > Heuristic > Unknown
confidence gates behaviour:
| Confidence | Notify of update? | Show exact command? | Auto self-swap allowed? |
|---|---|---|---|
| Overridden / Authoritative | yes | yes | yes, if channel is self-managed |
| Embedded | yes | yes | yes, if channel is self-managed |
| Heuristic | yes | yes (with "detected" caveat) | no |
| Unknown | yes | generic (link to releases) | no |
# install-receipt.toml — written by the installer, read by `fresh` to self-update.
# This file is provenance metadata only. Do not edit unless you know what you're doing.
schema = 1
channel = "homebrew" # canonical channel id (§3)
version = "0.4.4" # version this receipt was written for
package_name = "fresh-editor"
installed_at = "2026-07-23T10:30:00Z"
managed = true # true => an external manager owns updates
self_update = false # true => `fresh update` may swap the binary in place
# Optional, channel-specific hints used to build the exact update invocation:
[hints]
tap = "sinelaw/homebrew-fresh" # homebrew
formula = "fresh-editor" # homebrew
aur_pkg = "fresh-editor-bin" # aur-bin
winget_id = "sinelaw.fresh-editor" # winget
flatpak_ref = "io.github.sinelaw.fresh" # flatpak
npm_pkg = "@fresh-editor/fresh-editor"
target = "x86_64-unknown-linux-gnu" # self-update: which asset to fetch
asset = "fresh-editor-x86_64-unknown-linux-gnu.tar.xz"
install_root = "/home/u/.local/share/fresh-editor" # appimage/tarball: where to swap
managed and self_update are the two decision bits. Everything else is
either provenance (channel, version, installed_at) or a fact the update
path would otherwise have to re-derive.
That last category is not an optimisation — it is the point. Anything the
installer knows for certain and the running binary could only infer belongs
in [hints]. pkg_arch is the clearest case: the .deb build knows it
produced amd64, while the running binary would have to map its own target
triple through a per-tool arch-spelling table and hope the two agree. The deb
and rpm pipelines record target and pkg_arch for exactly this reason, and
registry::package_asset_with prefers the recorded value outright.
The exception is anything that is a property of the machine rather than the
install, and can change after it: which AUR helper is on PATH, whether sudo
exists. Those are detected at runtime (release_checker::fill_runtime_hints)
and must not be baked into a receipt, or the receipt goes stale the first time
the user switches helper.
A static table (services::provenance::registry) maps every channel id to
its update strategy. This is the one place that encodes "how do we update X".
pub enum UpdateStrategy {
/// External manager owns it. We run (or print) its command; never swap.
Delegated { command: &'static [&'static str], needs_privilege: bool },
/// We own the bits: download the release asset, verify, swap in place.
SelfContained,
/// User-scoped toolchain manager (cargo/npm/mise): delegate, no sudo.
Toolchain { command: &'static [&'static str] },
/// A package manager owns the files but no repository serves them: fetch
/// the release artifact and hand it to the local package tool.
DownloadPackage,
/// Unknown provenance: link to the releases page only.
Manual,
}
DownloadPackage exists because a channel being packaged does not mean it is
hosted. We publish the .deb, .rpm and .flatpak as GitHub release
artifacts; there is no apt repo, no dnf repo and no Flathub remote, so
apt-get install --only-upgrade / flatpak update have nothing to upgrade
from and report "already up to date" forever. These channels download the new
artifact from the release, verify it against its .sha256 sidecar, and install
it with dpkg/rpm/flatpak. An in-place binary swap is never an option for
them: it would leave the package database describing a file we replaced behind
its back. When the install needs root we print the command instead of running
it, and report ActionRequired so the editor's indicator doesn't claim an
update that hasn't landed yet (see the three-phase table in §15).
Two invariants govern this table.
Never dead-end at the releases page. A user who installed through one of our
channels must be able to keep updating through it. Telling them to go and
download a file by hand is the exact failure this mechanism exists to remove, so
every channel anything can resolve to names a route to the next version.
registry.rs's every_reachable_channel_has_a_same_channel_continuation holds
this: only unknown — the honest "we have no idea" — is exempt. That is why
zypper and pacman are not Manual despite having no repository: an openSUSE
user installed the release .rpm, so the next one arrives the same way, and an
Arch user came via the AUR, so that is where they go back to. Only snap, scoop
and chocolatey remain Manual, and only because we publish nothing they could
install and nothing writes their receipts — they are unreachable, not
dead-ended.
One mechanism per provenance class. A proved provenance must imply exactly
one way to update — no alternates chosen at runtime from what happens to be on
the machine. Every such fallback was removed: the AUR helper chain (above),
sudo/doas/run-unprivileged for privileged installs (now sudo, full stop),
and the recorded-asset-name-else-look-it-up branches in the download paths (now
the release feed for packages, the compiled-in target triple for archives). The
aur_helper and asset hints survive in the receipt for wire compatibility but
are no longer read.
The cost is deliberate: a system with no sudo at all — a minimal container
running as root — now fails with failed to run sudo instead of quietly
installing by a different route. That is the intended trade. A fallback that
succeeds by doing something other than what the provenance says is precisely the
guessing this design exists to remove, and when it does fail it fails in a way
that looks like a packaging bug rather than a missing sudo.
Never name a tool the channel doesn't guarantee. For most channels the tool
is the channel: a homebrew receipt means brew exists, an npm receipt
means npm does. The AUR is the exception — it implies pacman and makepkg,
not any particular helper — and the registry used to default to yay, naming a
binary that is simply absent on the many Arch systems that build with plain
makepkg, with the failure arriving only after the user had confirmed. The
helper is now detected at runtime (release_checker::fill_runtime_hints,
mirroring install.sh's list and order) and recorded in hints.aur_helper;
with none found, registry::aur_command falls back to the route that works
everywhere: clone the AUR repo and makepkg --syncdeps --install. Detection
happens before the plan is built, so the command shown in the popup is the one
that runs.
| channel | strategy | update invocation (templated with hints) |
|---|---|---|
homebrew | Delegated | brew upgrade {formula} |
apt | DownloadPackage (root) | fetch .deb from the release, verify, dpkg -i |
dnf | DownloadPackage (root) | fetch .rpm from the release, verify, rpm -U |
zypper | DownloadPackage (root) | fetch .rpm from the release, verify, zypper install |
aur-bin / aur | Delegated | git clone + makepkg --syncdeps --install |
pacman | Delegated | AUR: git clone + makepkg --syncdeps --install |
winget | Delegated | winget upgrade --id {winget_id} |
scoop | Manual | unreachable: no scoop manifest, nothing writes this receipt |
chocolatey | Manual | unreachable: no package, nothing writes this receipt |
flatpak | DownloadPackage | fetch .flatpak bundle, verify, flatpak install --user |
snap | Manual | unreachable: no snap published, nothing writes this receipt |
nix | Delegated | nix profile upgrade fresh (matches flake.nix's pname) |
freebsd-pkg | Delegated (root) | pkg upgrade fresh |
cargo | Toolchain | cargo install --locked fresh-editor |
cargo-binstall | Toolchain | cargo binstall fresh-editor |
npm | Toolchain | npm update -g {npm_pkg} |
mise | Toolchain | mise upgrade github:sinelaw/fresh (the tool ref, not fresh) |
appimage | SelfContained | fetch .AppImage, verify, replace file |
tarball | SelfContained | fetch archive, verify, atomic binary swap |
source | Manual | git pull && cargo install --path … (note) |
unknown | Manual | open releases page |
The heart of the paradigm: teach every packaging pipeline to emit the receipt. Concrete, per-channel changes:
build.rsExtend the existing build.rs (which already embeds FRESH_GIT_HASH) to emit:
let channel = std::env::var("FRESH_BUILD_CHANNEL").unwrap_or_else(|_| {
if option_env!("FRESH_GIT_HASH").is_some() { "source" } else { "prebuilt" }.into()
});
println!("cargo::rustc-env=FRESH_BUILD_CHANNEL={channel}");
println!("cargo::rustc-env=FRESH_TARGET_TRIPLE={}", std::env::var("TARGET").unwrap());
println!("cargo::rerun-if-env-changed=FRESH_BUILD_CHANNEL");
FRESH_BUILD_CHANNEL=cargo.flake.nix): set it to nix in the build derivation env; also drop
a packaged receipt in $out/share/fresh/.FRESH_BUILD_CHANNEL=aur in build(), and
install -Dm644 a receipt with channel=aur..deb (debian/rules) and .rpm (generate-rpm assets): build with
the env var and ship /usr/share/fresh-editor/install-receipt.toml
(channel=apt/dnf, managed=true, self_update=false). Added to
fresh-editor.install and the rpm asset list.freebsd-pkg receipt shipped in the pkg plist.These consume the shared fresh-editor-<triple>.tar.xz. The receipt is written
by the wrapper's own install step, so it is authoritative for that wrapper:
npm-package/binary-install.js): after extracting the binary, write
install-receipt.toml (channel=npm, managed=true) beside run-fresh.js.release.yml): in def install, write a
receipt into share/fresh/ with channel=homebrew + tap/formula hints.[package.metadata.binstall] pkg-fmt/hooks — ship the
receipt inside the archive under a known relative path, marked cargo-binstall.fresh-editor-bin PKGBUILD: install -Dm644 receipt
(channel=aur-bin, managed=true, aur_pkg=fresh-editor-bin).install -Dm644 receipt into /app/share/fresh/
(channel=flatpak, flatpak_ref=…).fresh.exe (channel=winget|scoop|chocolatey).The release build embeds a default receipt in the .tar.xz/.zip with
channel=tarball, managed=false, self_update=true, plus target/asset
hints. Any wrapper that repackages the archive overwrites this receipt with
its own (so npm's receipt wins over the tarball's). But a user who just
downloads and extracts the archive — or install.sh's AppImage path — keeps
the tarball/appimage receipt and gets genuine self-update.
install.shThe universal installer already knows exactly which branch it took. Each branch
writes the matching receipt (or relies on the package's own packaged receipt).
Its AppImage branch writes channel=appimage, self_update=true,
install_root=~/.local/share/fresh-editor.
.zip as a
raw download. winget-pkgs zip installers can't run a post-extract hook, so a
winget install currently inherits the archive's generic tarball receipt
(i.e. it would self-update rather than defer to winget upgrade). Marking it
managed needs a wrapper installer type; tracked for later. Scoop/Chocolatey
manifests can write a receipt, but those channels don't ship yet.cargo install build doesn't see any
build-time env, so it can't embed channel=cargo. It relies on the path
heuristic (~/.cargo/bin), which is reliable enough; a receipt isn't written.Only tarball and appimage (and future direct-download channels) ever swap
their own binary. Flow implemented in services::self_update:
strategy == SelfContained and
confidence >= Embedded.release_checker
(GitHub releases API, daily-debounced logic reused).FRESH_TARGET_TRIPLE using the asset hint
(fresh-editor-<triple>.tar.xz / .zip, or the .AppImage).services::http::download_to_file.<asset>.sha256 and compare;chmod +x, rename() the new binary over the
old one (atomic on the same fs). If EXDEV/permission denied → this
wasn't really a self-owned install; abort with guidance..AppImage file (or the extracted
install_root) the same way, preserving the ~/.local/bin/fresh symlink..exe can't be deleted but can be renamed. Rename
fresh.exe → fresh.exe.old, move the new exe into place, and schedule the
.old for deletion on next launch (the self-replace crate encapsulates
this; documented as the accepted approach).fresh restarts
into the new binary) or exit for the user to relaunch.Rollback: because the swap is a single atomic rename after full verification, a
crash before the rename leaves the old binary; a crash after leaves the new one.
The .old file (Windows) / a kept backup (opt-in) allows manual revert.
For every non-SelfContained channel, fresh update:
hints.sudo and the password prompt
appears in the update terminal. "Show the command" prints it instead.
--print-command is the CLI spelling of that choice.AUR is the one channel whose identity does not imply a tool: it means
pacman + makepkg, not any particular helper. It uses exactly one command —
git clone + makepkg --syncdeps --install — on every machine. Preferring
yay/paru when present was tried and removed: it made the upgrade mechanism a
property of the machine rather than of the proved provenance, so two users with
identical receipts would update by different routes and neither route could be
predicted from the receipt. It is not separately elevated: makepkg -si invokes
sudo itself for the pacman step, and wrapping it would nest prompts.
New user-facing surface, consistent with the existing clap --cmd subcommand
convention (daemon, config, grammar, init):
fresh --cmd update — check + update via the resolved strategy (prompts
before doing anything).fresh --cmd update --check — report status only (current, latest, channel,
what the update command would be); exit non-zero if outdated. Scriptable.fresh --cmd update --yes — non-interactive (CI, dotfiles).fresh --cmd config paths — extend to print resolved provenance + receipt
path (for debugging "why does it think I installed via X").Background notification (unchanged in spirit): the daily check still surfaces
the status-bar Update: vX.Y.Z indicator. Clicking / Ctrl+P → "Update fresh"
runs the same flow. For Heuristic/Unknown confidence the notification links
to instructions rather than offering a one-key update.
Config (config.rs): keep check_for_updates and --no-upgrade-check. Add
editor.auto_update = false (default) — reserved for a future opt-in that lets
SelfContained installs update without a prompt; off by default per the
non-goal on silent installs.
ureq + rustls stack in
services::http..sha256 and
GitHub artifact-attestation verification before any swap. Fail-closed.sudo in the interactive update terminal, so the
password prompt is the user's own shell prompt and they can see exactly what is
being run. We never cache credentials, never pass a password, and never
elevate without the user having picked "Update now" for that specific update.
Every popup with a privileged step says so before it is taken, and "Show the
command" prints it instead for anyone who would rather run it themselves.
This supersedes the earlier "print, never run" rule: that rule did not remove
the root command, it just moved it somewhere the user got no help with, and
the editor's own indicator then had to lie about whether an update happened.
Self-swap is unchanged — it only ever writes files the current user already
owns (precisely what self_update=true asserts) and is never elevated.<= current unless
--allow-downgrade is passed.self_update=true, but the
swap still requires signature/checksum verification against the official
release, so a forged receipt cannot cause arbitrary code to be installed — at
worst it points the user at the genuine latest release.com.apple.quarantine
from the freshly downloaded, checksum-verified artifact; for the signed GUI
.pkg path we stay Delegated (installer package), never self-swap..exe from the
release (we don't re-sign locally); SmartScreen reputation carries over from
the release artifact.No change to the privacy posture documented in README.md/docs/privacy.md:
should_run_daily_check + check_for_updates.--no-upgrade-check disables both the check and any notification.channel
to help prioritise packaging work — but only under the existing opt-out, and
it is not required for self-update to function.| Linux | macOS | Windows | |
|---|---|---|---|
| Packaged-receipt channels | apt, dnf, zypper, aur, pacman, flatpak, snap, nix, freebsd-pkg | homebrew (Cellar), nix | winget, scoop, chocolatey |
| Sidecar-receipt channels | npm, homebrew(linuxbrew), cargo-binstall, appimage, tarball | npm, cargo-binstall, tarball | npm, tarball |
| Self-update channels | tarball, appimage | tarball | tarball |
| Swap mechanism | atomic rename() | atomic rename() + de-quarantine | rename-running-exe + deferred delete |
| Receipt data dir fallback | $XDG_DATA_HOME/fresh | ~/Library/Application Support/fresh | %LOCALAPPDATA%\fresh |
The paradigm ships as its own workspace subcrate, fresh-update, so the
provenance/update logic is reusable (CLI, installers, tests) and testable in
isolation without compiling the whole editor:
crates/fresh-update/
build.rs // emits FRESH_UPDATE_TARGET (target triple);
// rerun-if-env-changed FRESH_BUILD_CHANNEL
src/
lib.rs // re-exports + TARGET_TRIPLE + embedded_channel()
channel.rs // Channel enum <-> stable string ids (+ aliases)
confidence.rs // Confidence ladder (Unknown<Heuristic<Embedded<Authoritative<Overridden)
receipt.rs // InstallReceipt + Hints (serde/toml), candidate_paths(), find()
registry.rs // Channel -> UpdateKind + UpdatePlan command templating
provenance.rs // Provenance + resolve_from() (pure) + resolve() (env/fs)
heuristic.rs // the demoted path-based detection (Layer D)
self_update.rs// verify_sha256 + atomic_replace (+ Windows deferred delete)
version.rs // is_newer() + parse_tag_name()
crates/fresh-editor/src/services/
release_checker.rs // keeps version-check + notification; its
// detect_install_method() now delegates to
// fresh_update::resolve(), falling back to the legacy
// path heuristic only when provenance is Unknown.
Dependencies are deliberately minimal (serde, toml, sha2, tracing) so
the crate builds fast and offline. Extraction and network I/O stay out of the
crate: the caller (the editor, which already has a ureq/rustls stack in
services::http) fetches the release asset and, for .tar.xz/.zip, extracts
the inner binary, then hands the verified executable bytes to
self_update::atomic_replace. AppImages need no extraction, so the flow is
usable end-to-end for them today.
Key types:
Key types:
pub enum Confidence { Overridden, Authoritative, Embedded, Heuristic, Unknown }
pub struct Provenance {
pub channel: Channel, // enum over the §3 ids
pub confidence: Confidence,
pub hints: Hints, // tap, formula, winget_id, target, asset, …
pub managed: bool,
pub self_update: bool,
}
pub fn resolve() -> Provenance; // A → B → C → D
pub fn update_command(p: &Provenance) -> UpdatePlan;
release_checker::InstallMethod::update_command is reimplemented on top of
registry so there is exactly one table of update commands. The existing
release_checker public API (used by main.rs and the e2e tests) stays
source-compatible; internally it calls provenance::resolve() instead of the
path heuristic.
Phase 1 — provenance plumbing (no behaviour change). ✅ landed.
Added the fresh-update subcrate: receipt schema, layered resolver, registry,
confidence, the demoted heuristic, checksum verify + atomic swap, and version
compare — all unit-tested. build.rs embeds the target triple and reruns on
FRESH_BUILD_CHANNEL; release_checker::detect_install_method() delegates to
fresh_update::resolve() and only falls back to the legacy path heuristic when
provenance is Unknown. Even with zero receipts written yet, this is already
strictly better (embedded channel + honest confidence).
Phase 2 — receipts everywhere. ✅ landed.
Each packaging pipeline (§7) writes its receipt via
scripts/write-install-receipt.sh (shell/CI channels) or an inline literal
(Ruby formula, npm JS, PKGBUILD, Nix). The deb/rpm install tests in
linux-packages.yml and the Flatpak install test assert the receipt exists
with the right channel (and, for deb/rpm, that nothing else leaked under
/usr/share/fresh-editor). This is the phase that delivers "know for sure."
Phase 3 — fresh update. ✅ landed.
services/updater.rs (feature self-update, in default). Delegated +
Toolchain paths run/print the known command; the SelfContained engine
downloads, verifies the SHA-256 sidecar (fail-closed), extracts (tar.xz/zip)
or --appimage-extracts, and atomically swaps — gated on
confidence >= Embedded via self_update::can_self_update. config paths
prints provenance. Covered by extraction unit tests and a mock-server
download→verify→extract integration test.
Phase 4 — interactive UX. ✅ landed (attestation remaining).
The status-bar Update: vX.Y.Z indicator is clickable (StatusBarClickable:: Update → Action::UpdateFresh), and a command-palette entry "Update fresh"
does the same. When self_update is on and an update is available, it prompts
ConfirmUpdate; confirming calls updater::spawn_background_update, which
re-invokes fresh --cmd update --yes as a local detached child (never the
window's Authority) and streams its output to <log_dir>/self-update.log.
The editor keeps running on the old inode until restart. Unknown/source
installs point at the releases page instead of prompting.
The result is surfaced on the indicator, not a transient status message
(which would scroll away and can't relay a "restart now" cue). App state
carries a SelfUpdatePhase with three terminal states, because
fresh --cmd update has three outcomes, not two:
| Child exit | Phase | Indicator |
|---|---|---|
0 | Succeeded | Updated — restart fresh |
EXIT_ACTION_REQUIRED (2) | ActionRequired | Update needs a command — click for details |
| anything else | Failed | Update failed — click for details |
The third state exists because two channel groups legitimately end with the
update not applied and nothing having gone wrong: DownloadPackage channels
that need root (dpkg -i, rpm -U) download and verify the package and then
stop, and Delegated/Toolchain channels whose command we only print. Folding
those into the pair produced a wrong indicator in both directions — the
privileged path exited 1 and read as "Update failed" when nothing had failed,
and the print-only path exited 0 and read as "Updated — restart fresh" when
nothing had been installed. UpdateStatus::ActionRequired and the distinct
exit code are what keep them apart.
On launch the phase flips to Running (indicator: Updating…), and the
terminal's exit code is mapped by SelfUpdatePhase::from_exit_code.
Clicking the indicator is state-dependent: Failed offers retry / show-log,
ActionRequired surfaces the pending command (it does not re-offer an
update that has already been downloaded and verified), and Running /
Succeeded jump to the update output.
The confirmation prompt itself is built from the resolved UpdatePlan rather
than being one fixed row, since "Update to vX" means five different things
across the channels. app::update_prompt::offer_for maps the plan to an
UpdateOffer — in-place swap, download-and-install, download-then-hand-over,
run-the-command, show-the-command, or nothing-we-can-do — and the popup states
which one applies before the user confirms. For the offers that leave a
root command behind, the body says so up front.
Once a run has started, clicking the indicator — or the "Open update log"
command (Action::OpenUpdateLog) — opens the log via open_local_file, i.e.
from the machine fresh runs on, never the window's (possibly remote)
authority. Still remaining: GitHub build-attestation verification alongside
SHA-256, and an optional no-prompt auto_update mode.
(receipt, embedded, path)
input triple, assert the resolved (channel, confidence)..deb/.rpm/AppImage/
flatpak install jobs to assert the packaged receipt is present and correct
after install, and gone after uninstall.release_checker.rs / update_notification.rs tests) serving
a signed+checksummed fake archive; assert download → verify → atomic swap →
new --version, and that a bad checksum aborts without touching the binary.self_update=true must still fail the
checksum/attestation gate.share/fresh/ location that survives brew upgrade.tarball receipt is overwritten to cargo-binstall, or accept that
binstalled copies self-update as tarball (arguably fine, since the user has
cargo — Toolchain would be nicer).--skip-attestation (checksum-only) escape hatch, clearly warned.mise upgrade github:sinelaw/fresh (the tool ref, matching the README's
mise use); a bare fresh matched nothing. Nothing writes a mise receipt
yet, so the channel is unreachable in practice — resolved as latent, not
fixed by observation.Manual rather than naming an invented command. Their
Channel variants are kept because the ids are receipt wire format, so a
receipt written by a future pipeline still parses. Open: whether to drop
the variants outright once it is clear those pipelines will not ship. That is
a wire-format change and needs a deliberate call, not a cleanup.fresh and a cargo fresh): the
receipt is resolved relative to current_exe(), so each updates itself
correctly — this is a feature of anchoring on the executable path, not the
heuristic's global guess.