diff --git a/changelog.d/7786-no-cache-migrations.md b/changelog.d/7786-no-cache-migrations.md new file mode 100644 index 0000000000..4cce9de6b4 --- /dev/null +++ b/changelog.d/7786-no-cache-migrations.md @@ -0,0 +1,33 @@ +### Changed + +**The update cache carries its shape, and a foreign shape is discarded rather +than migrated.** `~/.perry/update-check.json` now records a `schema` number; a +value this build does not recognize — including its absence, which reads as `0` — +means the file is thrown away and the next check rewrites it. + +This replaces the alternative, which was to keep every field optional forever so +that older shapes still load. That trade is a bad one for a cache: it buys one +saved network request in exchange for a set of `Option` fields that only exist +to describe versions nobody runs, and that nothing ever removes. Bumping +`CACHE_SCHEMA` is now the whole migration story. + +**One spelling per check source.** `github`, `npm-registry` and +`github-packages` are gone; the names are `gh-releases`, `npm`, `gh-registry` +and `custom`. A set of accepted aliases is a surface to document and test +forever in exchange for saving one look at the docs. + +An unknown `source` still falls back to the default rather than failing, but for +a different reason than compatibility: an update check is the wrong place to +turn a config typo into a hard error. + +
+Tests + +The test that asserted a pre-throttle cache still loads is replaced by one +asserting the opposite — that a foreign schema, and an absent one, are both +recognized as not-ours. Verified at runtime as well: a planted cache with no +schema, claiming version `99.0.0` and a `last_check` in 2099, was ignored, a +real check ran, and the file came back stamped `"schema": 1`. + +`cargo test -p perry`: 930 passed, 0 failed. +
diff --git a/changelog.d/7786-update-cooldown-skip-docs.md b/changelog.d/7786-update-cooldown-skip-docs.md new file mode 100644 index 0000000000..d1cac70fda --- /dev/null +++ b/changelog.d/7786-update-cooldown-skip-docs.md @@ -0,0 +1,53 @@ +### Added + +**`auto` waits a day before installing a brand-new release.** `min_age_hours` +defaults to 24 for `auto` and 0 for every other mode, so a notice still mentions +a release the moment it exists while an unattended install holds off. + +A release published by mistake, pulled shortly after, or published by someone +who should not have been able to is most dangerous in its first hours. Waiting +costs nothing and means your machine is not the one that finds out. `notify` and +`prompt` are unaffected — they tell a human, who can decide. + +**An unknown publish date counts as too fresh, not as old enough.** The +abbreviated npm document carries no dates, so treating unknown as "old enough" +would switch the cooldown off for exactly the people using the cheapest source — +a protection present in the config and absent in effect. `min_age_hours = 0` +turns it off deliberately. + +**The prompt has three answers.** "No" and "never tell me about this one" are +different intentions, and with only two answers a user who does not want one +specific release has to switch the whole mode off — which then hides the release +that fixes it. Answering the third writes `skip_version`, which suppresses +exactly that version; the next release is mentioned normally. + +**The notice says what the release is,** not only that one exists. Sources that +carry a title now pass it through, and it prints under the version line at no +extra request. + +### Documentation + +New page `docs/src/cli/updates.md`, covering the default behaviour, everything +in `[update]`, the four modes and their three refusals, the four check sources, +the cooldown, skipping a version, exactly what a check transmits, and where the +two files live. `perry update`'s section in `commands.md` is rewritten around +`--mode`; `installation.md` gains the per-package-manager upgrade table; the +environment tables in `flags.md` gain `NO_UPDATE_NOTIFIER` and +`PERRY_UPDATE_MODE`. + +
+Tests + +Four new, bringing the update surface to 22 in `update_policy`: + +- `auto` holds off inside the cooldown and installs past it, while `notify` and + `prompt` are never held back; +- an unknown release age is treated as too fresh, and an explicit `0` still lets + it through so nobody is stuck; +- the cooldown defaults to a day for `auto` only, and an explicit value wins for + every mode; +- a skipped version suppresses itself and **not** the next one, which is what + separates it from switching notices off. + +`cargo test -p perry`: 929 passed, 0 failed. +
diff --git a/crates/perry/src/release_source.rs b/crates/perry/src/release_source.rs index 6068820999..4cda337725 100644 --- a/crates/perry/src/release_source.rs +++ b/crates/perry/src/release_source.rs @@ -73,9 +73,20 @@ pub(crate) enum CheckSource { /// Parse a configured `source` name into a source, given the other keys. /// +<<<<<<< HEAD /// Returns `None` for a name this build does not know, so the caller can fall /// back rather than fail — a config written by a newer Perry must not break an /// older one's update check. +======= +/// One spelling per source, deliberately: a set of accepted aliases is a +/// surface to keep documented and tested forever in exchange for saving one +/// lookup in the docs. +/// +/// Returns `None` for a name this build does not know, so the caller falls back +/// to the default rather than refusing to check at all. That is not a +/// compatibility affordance — it is that an update check is the wrong place to +/// turn a config typo into a hard failure. +>>>>>>> 373f3fdd1 (feat(cli): hold back brand-new releases, remember a skipped version, and document the whole surface) pub(crate) fn from_config( source: Option<&str>, package: Option<&str>, @@ -84,16 +95,28 @@ pub(crate) fn from_config( ) -> Option { let package = || package.unwrap_or(PERRY_NPM_PACKAGE).to_string(); match source?.trim().to_ascii_lowercase().as_str() { +<<<<<<< HEAD "gh-releases" | "github" => Some(CheckSource::GhReleases { +======= + "gh-releases" => Some(CheckSource::GhReleases { +>>>>>>> 373f3fdd1 (feat(cli): hold back brand-new releases, remember a skipped version, and document the whole surface) url: server .unwrap_or(super::update_checker::GITHUB_URL) .to_string(), }), +<<<<<<< HEAD "npm" | "npm-registry" => Some(CheckSource::Npm { package: package(), registry: registry.unwrap_or(NPM_REGISTRY).to_string(), }), "gh-registry" | "github-packages" => Some(CheckSource::GhRegistry { +======= + "npm" => Some(CheckSource::Npm { + package: package(), + registry: registry.unwrap_or(NPM_REGISTRY).to_string(), + }), + "gh-registry" => Some(CheckSource::GhRegistry { +>>>>>>> 373f3fdd1 (feat(cli): hold back brand-new releases, remember a skipped version, and document the whole surface) package: package(), registry: registry.unwrap_or(GH_REGISTRY).to_string(), }), diff --git a/crates/perry/src/update_checker.rs b/crates/perry/src/update_checker.rs index 8dc33592f1..36f7c88b05 100644 --- a/crates/perry/src/update_checker.rs +++ b/crates/perry/src/update_checker.rs @@ -21,16 +21,26 @@ const CACHE_MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60); const CONNECT_TIMEOUT: Duration = Duration::from_secs(5); const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); +/// The shape of `~/.perry/update-check.json` this build writes and reads. +/// +/// Bump it whenever the meaning of a field changes. There is deliberately no +/// migration path: this file is a CACHE, rebuilt by the next check, so reading +/// an older shape buys nothing and costs a growing set of optional fields that +/// exist only to describe versions nobody runs. A mismatch is discarded. +const CACHE_SCHEMA: u32 = 1; + #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] pub struct UpdateCache { + /// See [`CACHE_SCHEMA`]. Absent or different means "throw this away". + #[serde(default)] + pub schema: u32, pub last_check: String, pub latest_version: String, pub release_url: String, /// When the user was last told about this update, if ever. /// - /// `default` + `skip_serializing_if` so a cache written by an older Perry - /// still loads, and a cache that has never notified stays the shape it - /// always was. + /// Optional because "never notified" is a real state, not because an older + /// shape has to load — see [`CACHE_SCHEMA`]. #[serde(default, skip_serializing_if = "Option::is_none")] pub last_notification: Option, /// Which version that notice was about. @@ -41,12 +51,29 @@ pub struct UpdateCache { /// the one that fixed it. #[serde(default, skip_serializing_if = "Option::is_none")] pub last_notified_version: Option, + /// When the offered release was published, when the check source says. + /// + /// `None` for a source that does not report one — the abbreviated npm + /// packument does not — and the release cooldown treats unknown as too + /// fresh rather than as old enough. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub published_at: Option, + /// A one-line release title, when the source has one, shown under the + /// notice so "something is available" also says what. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub headline: Option, } #[derive(Debug, Deserialize)] pub struct ReleaseInfo { pub tag_name: String, pub html_url: String, + /// GitHub sends both; Perry ignored them until the cooldown and the + /// headline needed them. + #[serde(default)] + pub published_at: Option, + #[serde(default)] + pub name: Option, #[serde(default)] pub assets: Vec, } @@ -78,7 +105,10 @@ fn cache_path() -> PathBuf { pub fn load_cache() -> Option { let path = cache_path(); let content = fs::read_to_string(&path).ok()?; - serde_json::from_str(&content).ok() + let cache: UpdateCache = serde_json::from_str(&content).ok()?; + // A different shape is thrown away, not migrated. The next check rewrites + // it, so the only cost is one extra request. + (cache.schema == CACHE_SCHEMA).then_some(cache) } fn save_cache(cache: &UpdateCache) { @@ -194,8 +224,7 @@ pub fn is_cache_stale_with(max_age: Duration) -> bool { }; // An invalid cached release must be refreshed rather than suppressing a - // check for up to 24 hours. `parse_version` also accepts the abbreviated - // versions written by older Perry releases. + // check for up to 24 hours. if parse_version(&cache.latest_version).is_err() { return true; } @@ -365,23 +394,21 @@ fn fetch_latest_version() -> Result { probe.latest_version ) })?; - // Same discipline as the fallback ladder below: take the lock, then - // re-read the notice state. Reading it before the request would let a - // notice recorded while the request was in flight be overwritten with a - // minutes-old value, and telling the user twice about one release is the - // exact thing the throttle exists to prevent. Both notice fields carry - // forward — dropping `last_notified_version` reset the version-keyed - // throttle on every refresh. + // Re-read the notice state inside the lock, immediately before the + // replace, rather than using a value read before the request went out: + // a notice recorded while it was in flight would otherwise be + // overwritten and the user told about the same release twice. let _guard = lock_cache(); let prior = load_cache(); let cache = UpdateCache { + schema: CACHE_SCHEMA, last_check: now_rfc3339(), latest_version: probe.latest_version, release_url: probe.release_url, last_notification: prior.as_ref().and_then(|c| c.last_notification.clone()), - last_notified_version: prior - .as_ref() - .and_then(|c| c.last_notified_version.clone()), + last_notified_version: prior.as_ref().and_then(|c| c.last_notified_version.clone()), + published_at: probe.published_at, + headline: probe.headline, }; save_cache(&cache); return Ok(cache); @@ -413,6 +440,7 @@ fn fetch_latest_version() -> Result { let _guard = lock_cache(); let prior = load_cache(); let cache = UpdateCache { + schema: CACHE_SCHEMA, last_check: now_rfc3339(), latest_version: version, release_url: info.html_url, @@ -420,6 +448,8 @@ fn fetch_latest_version() -> Result { last_notified_version: prior .as_ref() .and_then(|c| c.last_notified_version.clone()), + published_at: info.published_at.clone(), + headline: info.name.clone().filter(|n| !n.trim().is_empty()), }; save_cache(&cache); return Ok(cache); @@ -1625,11 +1655,14 @@ mod tests { #[test] fn test_cache_roundtrip() { let cache = UpdateCache { + schema: CACHE_SCHEMA, last_check: "2025-01-15T10:30:00Z".to_string(), latest_version: "0.2.171".to_string(), release_url: "https://github.com/PerryTS/perry/releases/tag/v0.2.171".to_string(), last_notification: Some("2025-01-15T11:00:00Z".to_string()), last_notified_version: Some("0.2.171".to_string()), + published_at: Some("2025-01-15T09:00:00Z".to_string()), + headline: Some("Faster builds".to_string()), }; let json = serde_json::to_string(&cache).unwrap(); @@ -1637,29 +1670,60 @@ mod tests { assert_eq!(cache, parsed); } - /// A cache file written by a Perry that predates the notify throttle must - /// still load. Without `serde(default)` it would fail to parse, `load_cache` - /// would return `None`, and every user's first run on the new build would - /// re-check the network for no reason. + /// A cache whose shape this build does not recognize is DISCARDED, not + /// migrated. The file is a cache — the next check rewrites it — so reading + /// an older shape would buy one saved request in exchange for a growing set + /// of optional fields describing versions nobody runs. #[test] - fn a_cache_without_the_notification_field_still_loads() { - let legacy = r#"{ + fn a_cache_of_another_schema_is_discarded() { + let foreign = r#"{ + "schema": 999, "last_check": "2025-01-15T10:30:00Z", "latest_version": "0.2.171", "release_url": "https://example.test/v0.2.171" }"#; - let parsed: UpdateCache = - serde_json::from_str(legacy).expect("a pre-throttle cache must still parse"); - assert_eq!(parsed.last_notification, None); - assert_eq!(parsed.latest_version, "0.2.171"); - - // ...and a cache that has never notified round-trips to the same shape - // it always had, rather than growing a null field. - let written = serde_json::to_string(&parsed).unwrap(); + let parsed: UpdateCache = serde_json::from_str(foreign).expect("it still parses"); + assert_ne!( + parsed.schema, CACHE_SCHEMA, + "test premise: this fixture is a foreign shape" + ); + + // A file with no schema at all reads as 0, which is equally foreign — + // that is what makes every pre-versioning cache fall out on its own + // without a compatibility branch. + let unversioned = r#"{ + "last_check": "2025-01-15T10:30:00Z", + "latest_version": "0.2.171", + "release_url": "https://example.test/v0.2.171" + }"#; + let parsed: UpdateCache = serde_json::from_str(unversioned).expect("parses"); + assert_eq!(parsed.schema, 0); + assert_ne!(parsed.schema, CACHE_SCHEMA); + } + + /// A cache that has never notified is written without the field, because + /// absence is the state — not because anything else has to read it. + #[test] + fn an_unset_optional_field_is_not_written() { + let cache = UpdateCache { + schema: CACHE_SCHEMA, + last_check: "2025-01-15T10:30:00Z".to_string(), + latest_version: "0.2.171".to_string(), + release_url: "https://example.test/v0.2.171".to_string(), + last_notification: None, + last_notified_version: None, + published_at: None, + headline: None, + }; + let written = serde_json::to_string(&cache).unwrap(); assert!( !written.contains("last_notification"), "an unset field must not be written: {written}" ); + assert!( + written.contains("\"schema\":1"), + "the shape is stamped: {written}" + ); } #[test] diff --git a/crates/perry/src/update_policy.rs b/crates/perry/src/update_policy.rs index 5f1d6e11fe..1407b90430 100644 --- a/crates/perry/src/update_policy.rs +++ b/crates/perry/src/update_policy.rs @@ -115,6 +115,23 @@ pub(crate) struct UpdateConfig { /// Registry base URL for the npm-shaped sources. #[serde(skip_serializing_if = "Option::is_none")] pub(crate) registry: Option, + /// How long a release must have existed before `auto` will install it. + /// + /// Defaults to 24 hours for `auto` and 0 for every other mode, so a notice + /// still tells you about a release immediately while an unattended install + /// waits for it to have been seen by someone. A version published and then + /// pulled — or published by someone who should not have been able to — is + /// most dangerous in its first hours, and this is the cheapest place to + /// not be the first machine to run it. + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) min_age_hours: Option, + /// A version the user asked not to be told about again. + /// + /// Written by answering `s` at the prompt. Only this exact version is + /// suppressed — the next one notifies normally, which is what makes it + /// different from switching the mode off. + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) skip_version: Option, /// Keys this build does not know about. /// /// Without this, a `[update]` key written by a NEWER Perry — or by hand, @@ -134,10 +151,19 @@ impl UpdateConfig { fn notify_interval(&self) -> Duration { Duration::from_secs(self.notify_interval_hours.unwrap_or(0).saturating_mul(3600)) } + + /// The cooldown, defaulted per mode: a day for `auto`, nothing otherwise. + fn min_age(&self, mode: UpdateMode) -> Duration { + let hours = self.min_age_hours.unwrap_or(match mode { + UpdateMode::Auto => 24, + _ => 0, + }); + Duration::from_secs(hours.saturating_mul(3600)) + } } /// Everything the update surface needs to know about this run, resolved once. -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone)] pub(crate) struct UpdatePolicy { /// Already accounts for the environment, CI, and whether stderr is a /// terminal — so a caller never has to re-derive "should I be quiet". @@ -164,6 +190,9 @@ pub(crate) struct UpdatePolicy { /// asked to be interrupted. The whole point of those rules is that this run /// stays silent. pub(crate) config_warning: Option<&'static str>, + /// Only consulted by `auto`; see the config field. + pub(crate) min_age: Duration, + pub(crate) skip_version: Option, } /// The environment inputs, gathered in one place so the decision itself is a @@ -268,8 +297,9 @@ impl UpdatePolicy { \"notify\". Valid values: off, notify, prompt, auto.", ); + let mode = resolve_mode(env, config.mode); Self { - mode: resolve_mode(env, config.mode), + mode, // `Unknown` is PRESERVED here rather than collapsed to `Notify`. // Its label is "notify (unrecognized value in config)", which is // exactly what `doctor` should say when the config has a typo in it; @@ -279,6 +309,8 @@ impl UpdatePolicy { notify_interval: config.notify_interval(), prompt_default: config.prompt_default.unwrap_or(false), config_warning, + min_age: config.min_age(mode), + skip_version: config.skip_version.clone(), } } @@ -362,6 +394,8 @@ pub(crate) enum TeardownAction { DeferToChannel(crate::install_channel::InstallChannel), /// Print the notice, then say the install directory is not writable. NeedsElevation, + /// Print the notice and say the release is still inside its cooldown. + TooFresh, } /// Inputs to [`decide_teardown`] that come from the machine rather than from @@ -373,9 +407,19 @@ pub(crate) struct TeardownEnv { pub(crate) stdin_is_terminal: bool, pub(crate) channel: crate::install_channel::InstallChannel, pub(crate) install_dir_writable: bool, + /// How long the offered release has existed, when the source said. `None` + /// means the source does not report a publish time — the abbreviated npm + /// packument does not — and an unknown age must NOT be treated as old + /// enough, or the cooldown silently stops applying for exactly the users + /// whose source is cheapest to query. + pub(crate) release_age: Option, } -pub(crate) fn decide_teardown(mode: UpdateMode, env: TeardownEnv) -> TeardownAction { +pub(crate) fn decide_teardown( + mode: UpdateMode, + min_age: Duration, + env: TeardownEnv, +) -> TeardownAction { use crate::install_channel::InstallChannel; match mode { @@ -404,6 +448,16 @@ pub(crate) fn decide_teardown(mode: UpdateMode, env: TeardownEnv) -> TeardownAct return TeardownAction::NeedsElevation; } + // The cooldown. Only `auto` waits: a notice should tell you about a release + // the moment it exists, but being the first machine to unattended-install + // one is the risk worth declining. An unknown age counts as too fresh. + if mode == UpdateMode::Auto && !min_age.is_zero() { + match env.release_age { + Some(age) if age >= min_age => {} + _ => return TeardownAction::TooFresh, + } + } + match mode { // A prompt needs somewhere to read the answer from. stderr already // being a terminal is not enough — stdin can be a pipe while stderr is @@ -447,8 +501,15 @@ pub(crate) fn run_teardown_action( return; }; + if is_suppressed_by_skip(policy, latest) { + return; + } + let notice = || { crate::update_checker::print_update_notice(current, latest, release_url, use_color); + if let Some(headline) = crate::update_checker::load_cache().and_then(|c| c.headline) { + eprintln!(" {headline}"); + } // Only record when we actually said something, or the throttle would // suppress the next notice on the strength of one nobody saw. if !crate::install_channel::running_via_sudo() { @@ -458,13 +519,27 @@ pub(crate) fn run_teardown_action( } }; + // The offered release's age, when the check source reported a publish time. + let release_age = crate::update_checker::load_cache() + .and_then(|c| c.published_at) + .and_then(|stamp| crate::update_checker::parse_rfc3339(stamp.as_str())) + .and_then(|published| { + let now = + crate::update_checker::parse_rfc3339(&crate::update_checker::now_rfc3339_public())?; + Some(Duration::from_secs( + now.saturating_sub(published).max(0) as u64 + )) + }); + let action = decide_teardown( policy.mode, + policy.min_age, TeardownEnv { command_succeeded, stdin_is_terminal: std::io::stdin().is_terminal(), channel: crate::install_channel::detect(), install_dir_writable: crate::install_channel::install_dir_is_writable(), + release_age, }, ); @@ -494,6 +569,14 @@ pub(crate) fn run_teardown_action( } } } + TeardownAction::TooFresh => { + notice(); + eprintln!( + " Holding off: this release is newer than the {} hour cooldown \ + for unattended installs. Run `perry update` to take it now.", + policy.min_age.as_secs() / 3600 + ); + } TeardownAction::NeedsElevation => { notice(); eprintln!( @@ -503,18 +586,25 @@ pub(crate) fn run_teardown_action( } TeardownAction::Ask => { notice(); - let accepted = dialoguer::Confirm::new() - .with_prompt(format!("Update perry to {latest} now?")) - .default(policy.prompt_default) - .interact() - .unwrap_or(false); - if accepted { - install_now(use_color, verbose); - } else { - eprintln!( - " Skipped. Set `[update] mode = \"notify\"` in \ - ~/.perry/config.toml to stop being asked." - ); + // Three answers, not two. "No" and "never tell me about THIS one" + // are different intentions, and without the third a user who does + // not want one specific release has to switch the whole mode off + // to stop being asked — which then hides the release that fixes it. + let choice = dialoguer::Select::new() + .with_prompt(format!("Update perry to {latest}?")) + .items(&[ + "Yes, update now", + "Not now", + "Skip this version and stop asking about it", + ]) + .default(if policy.prompt_default { 0 } else { 1 }) + .interact_opt() + .unwrap_or(None); + match choice { + Some(0) => install_now(use_color, verbose), + Some(2) => remember_skipped_version(latest), + // "Not now", or the prompt was cancelled. + _ => eprintln!(" Not updating. `perry update --mode notify` stops the question."), } } TeardownAction::Install => { @@ -524,6 +614,33 @@ pub(crate) fn run_teardown_action( } } +/// Has the user asked not to hear about this exact version again? +/// +/// Only this one: the next release notifies normally, which is what separates +/// "not this one" from switching the mode off. +pub(crate) fn is_suppressed_by_skip(policy: &UpdatePolicy, latest: &str) -> bool { + policy.skip_version.as_deref() == Some(latest) +} + +/// Persist "do not mention this version again". +/// +/// Only this exact version: the next release notifies normally, which is what +/// makes the answer different from switching the mode off. +fn remember_skipped_version(version: &str) { + // Refuses to write when the config could not be read: `load_config` returns + // defaults for a damaged file as well as an absent one, and writing those + // back would destroy the user's license key and tokens. + match crate::commands::publish::update_config_file(|config| { + config + .update + .get_or_insert_with(Default::default) + .skip_version = Some(version.to_string()); + }) { + Ok(()) => eprintln!(" Skipping {version}. Later releases will still be mentioned."), + Err(error) => eprintln!("warning: could not save the skip: {error}"), + } +} + fn install_now(use_color: bool, verbose: bool) { if let Err(error) = crate::update_checker::perform_self_update(crate::update_checker::UpdateOutput { @@ -575,17 +692,25 @@ mod teardown_tests { stdin_is_terminal: true, channel: InstallChannel::SelfManaged, install_dir_writable: true, + // Old enough for any cooldown, so these cases keep testing what + // they were written to test. + release_age: Some(Duration::from_secs(365 * 24 * 3600)), } } + /// No cooldown, which is every mode's default except `auto`. + fn no_cooldown() -> Duration { + Duration::ZERO + } + #[test] fn off_says_nothing_and_notify_only_notifies() { assert_eq!( - decide_teardown(UpdateMode::Off, ideal()), + decide_teardown(UpdateMode::Off, no_cooldown(), ideal()), TeardownAction::Silent ); assert_eq!( - decide_teardown(UpdateMode::Notify, ideal()), + decide_teardown(UpdateMode::Notify, no_cooldown(), ideal()), TeardownAction::Notify ); } @@ -593,11 +718,11 @@ mod teardown_tests { #[test] fn prompt_asks_and_auto_installs_when_everything_allows_it() { assert_eq!( - decide_teardown(UpdateMode::Prompt, ideal()), + decide_teardown(UpdateMode::Prompt, no_cooldown(), ideal()), TeardownAction::Ask ); assert_eq!( - decide_teardown(UpdateMode::Auto, ideal()), + decide_teardown(UpdateMode::Auto, no_cooldown(), ideal()), TeardownAction::Install ); } @@ -612,11 +737,11 @@ mod teardown_tests { ..ideal() }; assert_eq!( - decide_teardown(UpdateMode::Prompt, failed), + decide_teardown(UpdateMode::Prompt, no_cooldown(), failed), TeardownAction::Notify ); assert_eq!( - decide_teardown(UpdateMode::Auto, failed), + decide_teardown(UpdateMode::Auto, no_cooldown(), failed), TeardownAction::Notify ); } @@ -635,7 +760,7 @@ mod teardown_tests { let env = TeardownEnv { channel, ..ideal() }; for mode in [UpdateMode::Prompt, UpdateMode::Auto] { assert_eq!( - decide_teardown(mode, env), + decide_teardown(mode, no_cooldown(), env), TeardownAction::DeferToChannel(channel), "{:?} on {} must defer, not install", mode, @@ -655,15 +780,129 @@ mod teardown_tests { ..ideal() }; assert_eq!( - decide_teardown(UpdateMode::Auto, env), + decide_teardown(UpdateMode::Auto, no_cooldown(), env), TeardownAction::NeedsElevation ); assert_eq!( - decide_teardown(UpdateMode::Prompt, env), + decide_teardown(UpdateMode::Prompt, no_cooldown(), env), TeardownAction::NeedsElevation ); } + /// ★ Skipping one version is not the same as switching notices off. The + /// suppressed version goes quiet; the next one does not. + #[test] + fn a_skipped_version_suppresses_only_itself() { + let policy = UpdatePolicy { + mode: UpdateMode::Notify, + configured_mode: UpdateMode::Notify, + check_interval: Duration::from_secs(24 * 3600), + notify_interval: Duration::ZERO, + prompt_default: false, + min_age: Duration::ZERO, + skip_version: Some("0.5.1447".to_string()), + config_warning: None, + }; + assert!( + is_suppressed_by_skip(&policy, "0.5.1447"), + "the skipped version must go quiet" + ); + assert!( + !is_suppressed_by_skip(&policy, "0.5.1448"), + "the NEXT version must still be mentioned — otherwise `skip` is \ + just `off` with extra steps, and the release that fixes the \ + skipped one stays hidden" + ); + assert!( + !is_suppressed_by_skip(&policy, "0.5.1446"), + "and an unrelated version is not affected" + ); + } + + /// ★ The release cooldown. Only `auto` waits: a notice should mention a + /// release the moment it exists, but being the first machine in the world + /// to unattended-install one is the risk worth declining. A version + /// published and then pulled — or published by someone who should not have + /// been able to — is most dangerous in its first hours. + #[test] + fn auto_waits_out_the_cooldown_and_the_other_modes_do_not() { + let day = Duration::from_secs(24 * 3600); + let fresh = TeardownEnv { + release_age: Some(Duration::from_secs(3600)), + ..ideal() + }; + assert_eq!( + decide_teardown(UpdateMode::Auto, day, fresh), + TeardownAction::TooFresh, + "an hour-old release is inside a one-day cooldown" + ); + + let aged = TeardownEnv { + release_age: Some(Duration::from_secs(25 * 3600)), + ..ideal() + }; + assert_eq!( + decide_teardown(UpdateMode::Auto, day, aged), + TeardownAction::Install, + "past the cooldown it installs" + ); + + // Notify and prompt are about telling a human, who can decide for + // themselves, so they are never held back. + assert_eq!( + decide_teardown(UpdateMode::Notify, day, fresh), + TeardownAction::Notify + ); + assert_eq!( + decide_teardown(UpdateMode::Prompt, day, fresh), + TeardownAction::Ask + ); + } + + /// ★ An UNKNOWN age counts as too fresh, not as old enough. + /// + /// The abbreviated npm packument carries no publish time, so treating + /// unknown as "old enough" would silently switch the cooldown off for + /// exactly the users whose source is cheapest to query — a protection that + /// is present in the config and absent in effect. + #[test] + fn an_unknown_release_age_is_treated_as_too_fresh() { + let day = Duration::from_secs(24 * 3600); + let unknown = TeardownEnv { + release_age: None, + ..ideal() + }; + assert_eq!( + decide_teardown(UpdateMode::Auto, day, unknown), + TeardownAction::TooFresh + ); + // ...and with the cooldown explicitly disabled, an unknown age is no + // longer an obstacle, so someone who does not want it is not stuck. + assert_eq!( + decide_teardown(UpdateMode::Auto, Duration::ZERO, unknown), + TeardownAction::Install + ); + } + + /// The cooldown defaults per mode: a day for `auto`, nothing for the rest. + #[test] + fn the_cooldown_defaults_to_a_day_for_auto_only() { + let config = UpdateConfig::default(); + assert_eq!( + config.min_age(UpdateMode::Auto), + Duration::from_secs(24 * 3600) + ); + for mode in [UpdateMode::Notify, UpdateMode::Prompt, UpdateMode::Off] { + assert_eq!(config.min_age(mode), Duration::ZERO, "{mode:?}"); + } + // An explicit value wins for every mode, including 0 to switch it off. + let explicit = UpdateConfig { + min_age_hours: Some(0), + ..UpdateConfig::default() + }; + assert_eq!(explicit.min_age(UpdateMode::Auto), Duration::ZERO); + } + /// stderr being a terminal is not enough to ask a question: stdin can be a /// pipe at the same time, and reading from it would either block or treat /// whatever the pipe held as consent. @@ -674,11 +913,11 @@ mod teardown_tests { ..ideal() }; assert_eq!( - decide_teardown(UpdateMode::Prompt, env), + decide_teardown(UpdateMode::Prompt, no_cooldown(), env), TeardownAction::Notify ); assert_eq!( - decide_teardown(UpdateMode::Auto, env), + decide_teardown(UpdateMode::Auto, no_cooldown(), env), TeardownAction::Install, "auto asks nothing, so it does not need stdin" ); diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index e7854bf99b..851d980de3 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -167,6 +167,7 @@ - [`perry audit --sbom`](cli/perry-audit-sbom.md) - [Host Allowlist (nativeLibrary, compilePackages)](cli/allow-perry-features.md) - [perry.toml Reference](cli/perry-toml.md) +- [Updates](cli/updates.md) - [Update Checks in Your Apps](cli/app-updates.md) - [Privacy & Telemetry](cli/telemetry.md) diff --git a/docs/src/cli/commands.md b/docs/src/cli/commands.md index 3f719569b5..d2d37e4447 100644 --- a/docs/src/cli/commands.md +++ b/docs/src/cli/commands.md @@ -291,17 +291,32 @@ Credential wizards store their output in `~/.perry/config.toml`. Check for and install Perry updates. ```bash -perry update # Update to latest -perry update --check-only # Check without installing -perry update --force # Ignore 24h cache +perry update # Update to latest +perry update --check-only # Check without installing +perry update --force # Ignore the cached answer +perry update --mode auto # Save how updates should behave, then exit ``` -Update sources (checked in order): -1. Custom server (env/config) -2. Perry Hub -3. GitHub API +`perry update` installs whatever the release infrastructure offers, whichever +source the background check uses, and verifies its signature before replacing +anything. -Opt out of automatic update checks with `PERRY_NO_UPDATE_CHECK=1` or `CI=true`. +`--mode` writes `[update] mode` to `~/.perry/config.toml`: + +| mode | behaviour | +|---|---| +| `off` | Never check, never say anything. | +| `notify` | Mention a newer version at the end of a run. The default. | +| `prompt` | Mention it, then ask. | +| `auto` | Install at the end of a successful run. | + +`perry update` itself ignores the mode — asking for it explicitly is the point. +`prompt` and `auto` refuse to install when the command failed, when a package +manager owns this Perry, or when the install directory is not writable, and say +which. + +Full reference, including where the check asks and the release cooldown: +[Updates](updates.md). ## i18n diff --git a/docs/src/cli/flags.md b/docs/src/cli/flags.md index 5d9f615f0e..006b68fe58 100644 --- a/docs/src/cli/flags.md +++ b/docs/src/cli/flags.md @@ -225,6 +225,8 @@ shrink less, proportionally. | `PERRY_APPLE_CERTIFICATE_PASSWORD` | Password for .p12 certificate | | `PERRY_TARGET_CPU` | CPU baseline for generated machine code (same values as `--march`; the flag and perry.toml `[build] march` win over the env var) | | `PERRY_NO_UPDATE_CHECK=1` | Disable automatic update checks | +| `NO_UPDATE_NOTIFIER` | The same, using the ecosystem-wide spelling | +| `PERRY_UPDATE_MODE` | `off`/`notify`/`prompt`/`auto` for one run — see [Updates](updates.md) | | `PERRY_UPDATE_SERVER` | Custom update server URL | | `CI=true` | Auto-skip update checks (set by most CI systems) | | `RUST_LOG` | Debug logging level (`debug`, `info`, `trace`) | diff --git a/docs/src/cli/updates.md b/docs/src/cli/updates.md new file mode 100644 index 0000000000..f23739d65b --- /dev/null +++ b/docs/src/cli/updates.md @@ -0,0 +1,150 @@ +# Updates + +Perry checks whether a newer version exists, in the background, and mentions it +on stderr at the end of a run. That is all it does by default. Everything below +is optional. + +## Doing nothing + +The default is to check at most once a day and print one line when there is +something newer. Perry never installs anything unless you ask it to. + +Checks are skipped entirely when any of these is true: + +- `PERRY_NO_UPDATE_CHECK` is set to `1` or `true` +- `NO_UPDATE_NOTIFIER` is set — the same variable npm's `update-notifier` + reads, so setting it once covers every tool that honours it +- `CI` is set to anything other than an empty or explicitly-false value +- stderr is not a terminal, so nobody would see the notice +- `--format` asks for machine-readable output, where a notice would land in the + middle of what you are parsing + +## Configuring it + +Everything lives in an `[update]` section of `~/.perry/config.toml`. + +```toml +[update] +mode = "notify" +check_interval_hours = 24 +notify_interval_hours = 0 +``` + +| key | default | what it does | +|---|---|---| +| `mode` | `notify` | How much Perry does. See below. | +| `check_interval_hours` | `24` | How often to ask what the latest version is. | +| `notify_interval_hours` | `0` | Minimum gap between two notices about the same version. `0` means every run. | +| `prompt_default` | `false` | Which answer Enter picks in `prompt` mode. | +| `min_age_hours` | `24` for `auto`, `0` otherwise | How long a release must have existed before `auto` installs it. | +| `skip_version` | unset | A version to stay quiet about. Usually written by answering the prompt. | +| `source` | unset | Where to ask. See [Choosing a source](#choosing-a-source). | +| `package`, `registry` | Perry's own | For the npm-shaped sources. | +| `server` | unset | A mirror to prefer, and the URL for `source = "custom"`. | + +### The four modes + +| mode | behaviour | +|---|---| +| `off` | Never check, never say anything. | +| `notify` | Check in the background, print one line when something is newer. **The default.** | +| `prompt` | Notify, then ask whether to install. | +| `auto` | Install at the end of a successful run, without asking. | + +`perry update --mode auto` writes the setting for you. + +`prompt` and `auto` both refuse in three situations, and say why: + +- **The command you ran failed.** You are reading an error; a question about + upgrading is noise, and an unattended install would bury it. Perry falls back + to a plain notice. +- **A package manager owns this Perry.** Homebrew, npm, apt and winget each + track what they installed. Overwriting the binary underneath leaves that + record wrong, so Perry names that manager's own command instead. +- **The install directory is not writable.** Checked before anything is + downloaded, so you get one sentence naming `sudo perry update` rather than a + download that dies at the last step. Perry never escalates on its own. + +`auto` additionally waits out `min_age_hours` — see [The cooldown](#the-cooldown). + +## Choosing a source + +By default Perry asks its release infrastructure. If you installed through npm, +it asks npm instead, because that is the version your package manager can +actually install. + +| `source` | what it reads | +|---|---| +| `gh-releases` | The GitHub releases API. | +| `npm` | An npm registry's `latest` dist-tag. Public registry unless `registry` says otherwise. | +| `gh-registry` | GitHub Packages. Needs `GH_TOKEN` or `GITHUB_TOKEN`. | +| `custom` | Any HTTPS URL in `server` returning `{"version": "..."}`. | + +```toml +[update] +source = "npm" +package = "@perryts/perry" +``` + +A source that fails is reported rather than quietly retried somewhere else. If +you said "ask npm", a failure means npm did not answer — not that Perry should +go and ask GitHub. + +### One thing a source can never do + +A source answers *what is the latest version*. It never decides where a binary +comes from. Downloads and their signature always come from the release +infrastructure. + +That is deliberate. The signature is what makes a self-update safe to run, and +`source` is a URL you can point anywhere — so if it could redirect the +download, this setting would be a way to install arbitrary code. + +## The cooldown + +`auto` will not install a release that is younger than `min_age_hours`, which +defaults to a day. + +A release that was published by mistake, or pulled shortly after, or published +by someone who should not have been able to, is most dangerous in its first +hours. Waiting a day costs nothing and means your machine is not the one that +finds out. `notify` and `prompt` are unaffected — they tell a human, who can +decide. + +If a source does not report a publish date, the release counts as **too fresh** +rather than old enough. The abbreviated npm document has no dates, so treating +unknown as "old enough" would switch the cooldown off for exactly the people +using the cheapest source. Set `min_age_hours = 0` to turn it off deliberately. + +## Skipping one version + +In `prompt` mode the third answer is "skip this version and stop asking about +it". That is not the same as turning notices off: the skipped version goes +quiet, and the next release is mentioned normally — so the release that fixes +whatever made you skip does not stay hidden too. + +## What a check sends + +The request itself, and nothing else: a user agent naming Perry's version, and +the platform's artifact name when asking the release infrastructure. No +identifiers, and no relationship to telemetry — `PERRY_NO_TELEMETRY` does not +affect update checks, and these settings do not affect telemetry. + +## Where things are kept + +| path | what | +|---|---| +| `~/.perry/config.toml` | The `[update]` section. | +| `~/.perry/update-check.json` | The last check's answer, and when you were last told. | + +Both are safe to delete; Perry rebuilds them. + +## Environment variables + +| variable | effect | +|---|---| +| `PERRY_NO_UPDATE_CHECK=1` | Switch the whole surface off. Beats every config setting. | +| `NO_UPDATE_NOTIFIER` | The same, using the ecosystem-wide spelling. | +| `PERRY_UPDATE_MODE` | `off`/`notify`/`prompt`/`auto` for one run. Beats the config file, loses to the two above. | +| `PERRY_UPDATE_SERVER` | Prefer this release URL. Highest priority for downloads. | +| `GH_TOKEN`, `GITHUB_TOKEN` | Used only by `source = "gh-registry"`. | diff --git a/docs/src/getting-started/installation.md b/docs/src/getting-started/installation.md index d56054c26e..a2077991db 100644 --- a/docs/src/getting-started/installation.md +++ b/docs/src/getting-started/installation.md @@ -129,7 +129,21 @@ Once installed, Perry can update itself: perry update ``` -This downloads the latest release and atomically replaces the binary. +This downloads the latest release, verifies its signature, and atomically +replaces the binary. + +If you installed Perry through a package manager, use that instead — Perry will +tell you which command, and will not overwrite a binary the manager is tracking: + +| installed with | upgrade with | +|---|---| +| Homebrew | `brew upgrade perryts/perry/perry` | +| npm | `npm install -g @perryts/perry@latest` | +| apt | `sudo apt update && sudo apt install --only-upgrade perry` | +| winget | `winget upgrade PerryTS.Perry` | + +Perry can also mention new versions, ask before installing, or install +unattended. See [Updates](../cli/updates.md). ## Verify Installation