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