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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions changelog.d/7786-no-cache-migrations.md
Original file line number Diff line number Diff line change
@@ -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.

<details>
<summary><b>Tests</b></summary>

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.
</details>
53 changes: 53 additions & 0 deletions changelog.d/7786-update-cooldown-skip-docs.md
Original file line number Diff line number Diff line change
@@ -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.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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`.

<details>
<summary><b>Tests</b></summary>

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.
</details>
23 changes: 23 additions & 0 deletions crates/perry/src/release_source.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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>,
Expand All @@ -84,16 +95,28 @@ pub(crate) fn from_config(
) -> Option<CheckSource> {
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(),
}),
Expand Down
124 changes: 94 additions & 30 deletions crates/perry/src/update_checker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// Which version that notice was about.
Expand All @@ -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<String>,
/// 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<String>,
/// 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<String>,
}

#[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<String>,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub assets: Vec<Asset>,
}
Expand Down Expand Up @@ -78,7 +105,10 @@ fn cache_path() -> PathBuf {
pub fn load_cache() -> Option<UpdateCache> {
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) {
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -365,23 +394,21 @@ fn fetch_latest_version() -> Result<UpdateCache> {
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);
Expand Down Expand Up @@ -413,13 +440,16 @@ fn fetch_latest_version() -> Result<UpdateCache> {
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,
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()),
published_at: info.published_at.clone(),
headline: info.name.clone().filter(|n| !n.trim().is_empty()),
};
save_cache(&cache);
return Ok(cache);
Expand Down Expand Up @@ -1625,41 +1655,75 @@ 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();
let parsed: UpdateCache = serde_json::from_str(&json).unwrap();
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]
Expand Down
Loading
Loading