aboutsummaryrefslogtreecommitdiff
path: root/src/update.rs
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-02 21:48:02 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-02 21:48:02 +0300
commitb751d102f158968aab681cb663d27b4c32197f62 (patch)
tree85d03609314559df7705c2cb0ab4e37ab6d9715f /src/update.rs
parent295b09ca25edbccac65401ee84fa420dd3888aad (diff)
feat: add preview update channel
Diffstat (limited to 'src/update.rs')
-rw-r--r--src/update.rs419
1 files changed, 371 insertions, 48 deletions
diff --git a/src/update.rs b/src/update.rs
index d05365d1..58095c08 100644
--- a/src/update.rs
+++ b/src/update.rs
@@ -17,12 +17,12 @@ use std::time::{Duration, Instant};
use serde::{Deserialize, Deserializer};
-const UPDATE_MANIFEST_URL: &str = "https://herdr.dev/latest.json";
+const STABLE_UPDATE_MANIFEST_URL: &str = "https://herdr.dev/latest.json";
+const PREVIEW_UPDATE_MANIFEST_URL: &str = "https://herdr.dev/preview.json";
const HOMEBREW_FORMULA_API_URL: &str = "https://formulae.brew.sh/api/formula/herdr.json";
const HERDR_UPDATE_COMMAND: &str = "herdr update";
const HOMEBREW_UPDATE_COMMAND: &str = "brew update && brew upgrade herdr";
const NIX_UPDATE_COMMAND: &str = "update through Nix";
-const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
const FAKE_UPDATE_VERSION_ENV: &str = "HERDR_FAKE_UPDATE_VERSION";
const FAKE_UPDATE_NOTES_VERSION_ENV: &str = "HERDR_FAKE_UPDATE_NOTES_VERSION";
const DEFAULT_FAKE_UPDATE_NOTES_VERSION: &str = "0.3.0";
@@ -73,7 +73,7 @@ impl Version {
}
pub fn current() -> Self {
- Self::parse(CURRENT_VERSION).expect("invalid CARGO_PKG_VERSION")
+ Self::parse(crate::build_info::BASE_VERSION).expect("invalid CARGO_PKG_VERSION")
}
}
@@ -87,13 +87,75 @@ impl std::fmt::Display for Version {
// Update manifest
// ---------------------------------------------------------------------------
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum UpdateChannel {
+ Stable,
+ Preview,
+}
+
+impl UpdateChannel {
+ fn configured() -> Self {
+ match crate::config::Config::load().config.update.channel {
+ crate::config::UpdateChannelConfig::Stable => Self::Stable,
+ crate::config::UpdateChannelConfig::Preview => Self::Preview,
+ }
+ }
+
+ fn as_str(self) -> &'static str {
+ match self {
+ Self::Stable => "stable",
+ Self::Preview => "preview",
+ }
+ }
+}
+
+#[derive(Debug, Clone)]
+struct AssetRef {
+ url: String,
+ sha256: Option<String>,
+}
+
+impl<'de> Deserialize<'de> for AssetRef {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: Deserializer<'de>,
+ {
+ let value = serde_json::Value::deserialize(deserializer)?;
+ match value {
+ serde_json::Value::String(url) if !url.trim().is_empty() => Ok(Self {
+ url: url.trim().to_string(),
+ sha256: None,
+ }),
+ serde_json::Value::Object(mut object) => {
+ let url = object
+ .remove("url")
+ .and_then(|value| value.as_str().map(str::to_string))
+ .ok_or_else(|| serde::de::Error::custom("asset object is missing url"))?;
+ let sha256 = object
+ .remove("sha256")
+ .and_then(|value| value.as_str().map(str::to_string));
+ if url.trim().is_empty() {
+ return Err(serde::de::Error::custom("asset url must not be empty"));
+ }
+ Ok(Self {
+ url: url.trim().to_string(),
+ sha256: sha256.filter(|value| !value.trim().is_empty()),
+ })
+ }
+ _ => Err(serde::de::Error::custom(
+ "asset must be a URL string or object with url",
+ )),
+ }
+ }
+}
+
#[derive(Deserialize)]
struct UpdateManifest {
version: String,
/// Thin-client protocol spoken by this release, when advertised by the manifest.
protocol: Option<u32>,
notes: String,
- assets: BTreeMap<String, String>,
+ assets: BTreeMap<String, AssetRef>,
announcement: Option<serde_json::Value>,
#[serde(default, deserialize_with = "deserialize_manifest_releases")]
releases: BTreeMap<String, serde_json::Value>,
@@ -119,6 +181,29 @@ struct ManifestReleaseMetadata {
}
#[derive(Deserialize)]
+struct PreviewManifest {
+ channel: String,
+ base_version: String,
+ build_id: String,
+ commit: String,
+ built_at: String,
+ protocol: u32,
+ notes: String,
+ assets: BTreeMap<String, AssetRef>,
+ #[serde(default)]
+ builds: BTreeMap<String, PreviewBuildMetadata>,
+}
+
+#[derive(Deserialize)]
+struct PreviewBuildMetadata {
+ base_version: String,
+ commit: String,
+ built_at: String,
+ protocol: u32,
+ assets: BTreeMap<String, AssetRef>,
+}
+
+#[derive(Deserialize)]
struct HomebrewFormula {
versions: HomebrewFormulaVersions,
}
@@ -129,8 +214,11 @@ struct HomebrewFormulaVersions {
}
impl UpdateManifest {
+ #[cfg(test)]
fn download_url_for(&self, os: &str, arch: &str) -> Option<String> {
- self.assets.get(&format!("{os}-{arch}")).cloned()
+ self.assets
+ .get(&format!("{os}-{arch}"))
+ .map(|asset| asset.url.clone())
}
fn metadata_for_version(&self, version: &Version) -> Option<ManifestReleaseMetadata> {
@@ -164,12 +252,34 @@ impl ManifestReleaseMetadata {
#[derive(Debug, Clone)]
struct ReleaseInfo {
version: Version,
+ identity: String,
+ channel: UpdateChannel,
+ build_id: Option<String>,
+ commit: Option<String>,
target_protocol: Option<u32>,
download_url: String,
+ sha256: Option<String>,
notes_body: String,
}
+impl ReleaseInfo {
+ fn label(&self) -> &str {
+ &self.identity
+ }
+}
+
fn fetch_update_manifest() -> Result<UpdateManifest, String> {
+ fetch_json_manifest(STABLE_UPDATE_MANIFEST_URL)
+}
+
+fn fetch_preview_manifest() -> Result<PreviewManifest, String> {
+ fetch_json_manifest(PREVIEW_UPDATE_MANIFEST_URL)
+}
+
+fn fetch_json_manifest<T>(url: &str) -> Result<T, String>
+where
+ T: serde::de::DeserializeOwned,
+{
let output = Command::new("curl")
.args([
"-sfL",
@@ -179,7 +289,7 @@ fn fetch_update_manifest() -> Result<UpdateManifest, String> {
"10",
"--max-time",
"20",
- UPDATE_MANIFEST_URL,
+ url,
])
.output()
.map_err(|e| format!("curl failed: {e}"))?;
@@ -219,7 +329,7 @@ fn release_info_from_manifest(manifest: &UpdateManifest) -> Result<Option<Releas
let latest = Version::parse(&manifest.version)
.ok_or_else(|| format!("invalid version in update manifest: {}", manifest.version))?;
- if latest <= current {
+ if !stable_channel_should_install(&latest, &current, crate::build_info::is_preview()) {
return Ok(None); // up to date
}
@@ -232,20 +342,117 @@ fn release_info_from_manifest(manifest: &UpdateManifest) -> Result<Option<Releas
}
let (os, arch) = platform_target();
- let download_url = manifest
- .download_url_for(os, arch)
- .ok_or_else(|| format!("no binary for {os}-{arch} in update manifest"))?;
+ let asset_key = format!("{os}-{arch}");
+ let asset = manifest
+ .assets
+ .get(&asset_key)
+ .ok_or_else(|| format!("no binary for {asset_key} in update manifest"))?;
+ let download_url = asset.url.clone();
Ok(Some(ReleaseInfo {
+ identity: latest.to_string(),
version: latest,
+ channel: UpdateChannel::Stable,
+ build_id: None,
+ commit: None,
target_protocol: manifest.protocol,
download_url,
+ sha256: asset.sha256.clone(),
+ notes_body,
+ }))
+}
+
+fn stable_channel_should_install(
+ latest: &Version,
+ current: &Version,
+ installed_is_preview: bool,
+) -> bool {
+ installed_is_preview || latest > current
+}
+
+fn preview_display_version(base_version: &str, build_id: &str) -> String {
+ format!(
+ "{}-preview.{}",
+ base_version.trim_start_matches('v'),
+ build_id
+ )
+}
+
+fn release_info_from_preview_manifest(
+ manifest: &PreviewManifest,
+) -> Result<Option<ReleaseInfo>, String> {
+ if manifest.channel != "preview" {
+ return Err(format!(
+ "invalid preview manifest channel: {}",
+ manifest.channel
+ ));
+ }
+ let build_id = manifest.build_id.trim();
+ if build_id.is_empty() {
+ return Err("preview manifest build_id is empty".into());
+ }
+ if crate::build_info::is_preview()
+ && crate::build_info::build_id().is_some_and(|current| current == build_id)
+ {
+ return Ok(None);
+ }
+
+ let version = Version::parse(&manifest.base_version).ok_or_else(|| {
+ format!(
+ "invalid base_version in preview manifest: {}",
+ manifest.base_version
+ )
+ })?;
+ let notes_body = manifest.notes.trim().to_string();
+ if notes_body.is_empty() {
+ return Err("preview manifest notes are empty".into());
+ }
+ let (os, arch) = platform_target();
+ let asset_key = format!("{os}-{arch}");
+ if let Some(archived) = manifest.builds.get(build_id) {
+ if archived.base_version != manifest.base_version
+ || archived.commit != manifest.commit
+ || archived.built_at != manifest.built_at
+ || archived.protocol != manifest.protocol
+ {
+ tracing::warn!(
+ build_id,
+ "preview manifest archived build metadata differs from top-level metadata"
+ );
+ }
+ }
+ let asset = manifest
+ .assets
+ .get(&asset_key)
+ .or_else(|| {
+ manifest
+ .builds
+ .get(build_id)
+ .and_then(|build| build.assets.get(&asset_key))
+ })
+ .ok_or_else(|| format!("no binary for {asset_key} in preview manifest"))?;
+ let download_url = asset.url.clone();
+
+ Ok(Some(ReleaseInfo {
+ identity: preview_display_version(&manifest.base_version, build_id),
+ version,
+ channel: UpdateChannel::Preview,
+ build_id: Some(build_id.to_string()),
+ commit: Some(manifest.commit.clone()),
+ target_protocol: Some(manifest.protocol),
+ download_url,
+ sha256: asset.sha256.clone(),
notes_body,
}))
}
/// Check the hosted update manifest for the latest release. Returns release info if newer.
fn check_latest() -> Result<Option<ReleaseInfo>, String> {
+ let channel = UpdateChannel::configured();
+ if channel == UpdateChannel::Preview {
+ return release_info_from_preview_manifest(&fetch_preview_manifest()?);
+ }
+
let manifest = fetch_update_manifest()?;
let release = release_info_from_manifest(&manifest)?;
if let Some(release) = &release {
@@ -357,6 +564,16 @@ fn download_update(release: &ReleaseInfo) -> Result<DownloadedUpdate, String> {
return Err("download failed".into());
}
+ if let Some(expected) = &release.sha256 {
+ if let Err(e) = crate::checksum::verify_sha256(&tmp_path, expected) {
+ let _ = fs::remove_file(&tmp_path);
+ return Err(format!(
+ "downloaded update checksum verification failed: {e}"
+ ));
+ }
+ tracing::info!(sha256 = %expected, "downloaded update checksum verified");
+ }
+
// Make executable
#[cfg(unix)]
{
@@ -703,8 +920,8 @@ fn prompt_to_stop_old_servers_before_update(
}
eprintln!(
- "Running sessions that must stop to use v{}:",
- release.version
+ "Running sessions that must stop to use {}:",
+ release.label()
);
for plan in plans {
eprintln!(
@@ -838,8 +1055,9 @@ fn prompt_to_complete_plain_update(
loop {
eprint!(
- "Stop running {} and install v{} now? [y/N] ",
- noun, release.version
+ "Stop running {} and install {} now? [y/N] ",
+ noun,
+ release.label()
);
io::stderr()
.flush()
@@ -899,7 +1117,7 @@ fn print_running_session_update_summary(
);
}
}
- eprintln!(" update: v{}", release.version);
+ eprintln!(" update: {}", release.label());
eprintln!();
}
@@ -927,7 +1145,7 @@ fn runtime_matches_release(status: &crate::api::RuntimeStatus, release: &Release
let protocol_matches = release
.target_protocol
.is_none_or(|protocol| status.protocol == Some(protocol));
- let version_matches = status.version.as_deref() == Some(&release.version.to_string());
+ let version_matches = status.version.as_deref() == Some(release.label());
protocol_matches && version_matches
}
@@ -956,10 +1174,10 @@ fn prompt_to_stop_old_server_after_failed_handoff(
plan.label()
);
eprintln!(" server: v{}", version_label(status.version.as_deref()));
- eprintln!(" installed: v{}", release.version);
+ eprintln!(" installed: {}", release.label());
eprintln!(
- "you can keep using the old server, or stop it now so the next `herdr` start uses v{}.",
- release.version
+ "you can keep using the old server, or stop it now so the next `herdr` start uses {}.",
+ release.label()
);
eprintln!("stopping the old server will exit its pane processes.");
@@ -1024,15 +1242,15 @@ fn recover_failed_live_handoff_for_update(
FailedHandoffServerState::NoServerResponding => {
if let Some(command) = plan.attach_command() {
eprintln!(
- "no herdr server is responding for session {}. the binary was updated; run `{command}` to start v{}.",
+ "no herdr server is responding for session {}. the binary was updated; run `{command}` to start {}.",
plan.label(),
- release.version
+ release.label()
);
} else {
eprintln!(
- "no herdr server is responding at {}. the binary was updated; restart with the same socket override to use v{}.",
+ "no herdr server is responding at {}. the binary was updated; restart with the same socket override to use {}.",
plan.socket_path().display(),
- release.version
+ release.label()
);
}
Ok(RunningServerUpdateOutcome::FailedHandoffNoServer)
@@ -1154,7 +1372,7 @@ fn live_handoff_server_via_api_for_release_at(
let params = ServerLiveHandoffParams {
import_exe: Some(updated_exe.display().to_string()),
expected_protocol: release.target_protocol,
- expected_version: Some(release.version.to_string()),
+ expected_version: Some(release.label().to_string()),
};
send_server_update_method_at(
@@ -1236,7 +1454,7 @@ fn wait_for_server_handoff_at(
socket_path,
timeout,
release.target_protocol,
- Some(&release.version.to_string()),
+ Some(release.label()),
)
}
@@ -1341,12 +1559,14 @@ fn print_running_session_update_outcomes(
eprintln!("Stopping exits active pane processes.");
match &outcome.attach_command {
Some(command) => eprintln!(
- "Run `{}`, then run `{command}` when ready to use v{}.",
- outcome.stop_command, release.version
+ "Run `{}`, then run `{command}` when ready to use {}.",
+ outcome.stop_command,
+ release.label()
),
None => eprintln!(
- "Run `{}`, then restart Herdr with the same socket override when ready to use v{}.",
- outcome.stop_command, release.version
+ "Run `{}`, then restart Herdr with the same socket override when ready to use {}.",
+ outcome.stop_command,
+ release.label()
),
}
}
@@ -1499,13 +1719,24 @@ fn homebrew_cellar_keg_root(path: &Path) -> Option<PathBuf> {
/// Manual self-update command (`herdr update`).
pub fn self_update(options: SelfUpdateOptions) -> Result<Version, String> {
+ let channel = UpdateChannel::configured();
if is_homebrew_managed_install() {
+ if channel == UpdateChannel::Preview {
+ return Err(
+ "self-update is disabled for Homebrew installs; preview is only available for direct Herdr installs".into(),
+ );
+ }
return Err(format!(
"self-update is disabled for Homebrew installs; run `{HOMEBREW_UPDATE_COMMAND}`"
));
}
if is_nix_managed_install() {
+ if channel == UpdateChannel::Preview {
+ return Err(
+ "self-update is disabled for Nix installs; preview is only available for direct Herdr installs".into(),
+ );
+ }
return Err(
"self-update is disabled for Nix installs; update with `nix profile upgrade` or update the flake input that provides Herdr".into(),
);
@@ -1515,14 +1746,14 @@ pub fn self_update(options: SelfUpdateOptions) -> Result<Version, String> {
return Err("run `herdr update` outside herdr after detaching from the session".into());
}
- eprintln!("checking for updates...");
+ eprintln!("checking {} channel for updates...", channel.as_str());
let current = Version::current();
let release = match check_latest()? {
Some(r) => r,
None => {
- eprintln!("already up to date (v{current})");
+ eprintln!("already up to date ({})", crate::build_info::version());
return Ok(current);
}
};
@@ -1531,15 +1762,16 @@ pub fn self_update(options: SelfUpdateOptions) -> Result<Version, String> {
let server_update_decisions =
confirm_running_server_update_action(running_server_plans, &release, options)?;
- eprintln!("downloading v{}...", release.version);
- if let Err(e) =
- crate::release_notes::save_pending(&release.version.to_string(), &release.notes_body)
- {
+ if let Some(commit) = &release.commit {
+ tracing::info!(commit = %commit, build_id = ?release.build_id, "selected preview update build");
+ }
+ eprintln!("downloading {}...", release.label());
+ if let Err(e) = crate::release_notes::save_pending(release.label(), &release.notes_body) {
tracing::warn!("failed to save pending release notes: {e}");
}
let downloaded_update = download_update(&release)?;
let updated_exe = downloaded_update.current_exe.clone();
- eprintln!("downloaded v{}", release.version);
+ eprintln!("downloaded {}", release.label());
if !options.live_handoff
&& !prompt_to_complete_plain_update(&server_update_decisions, &release)?
{
@@ -1548,7 +1780,7 @@ pub fn self_update(options: SelfUpdateOptions) -> Result<Version, String> {
return Ok(current);
}
install_downloaded_update(downloaded_update)?;
- eprintln!("installed v{}", release.version);
+ eprintln!("installed {}", release.label());
let server_update_decisions = if options.live_handoff {
server_update_decisions
} else {
@@ -1598,12 +1830,23 @@ pub fn auto_update(events: tokio::sync::mpsc::Sender<crate::events::AppEvent>) {
return;
}
+ let configured_channel = UpdateChannel::configured();
if is_homebrew_managed_install() {
+ if configured_channel == UpdateChannel::Preview {
+ crate::logging::update_check_failed(
+ "preview channel is not available for Homebrew installs",
+ );
+ return;
+ }
auto_update_homebrew(events);
return;
}
let nix_managed_install = is_nix_managed_install();
+ if nix_managed_install && configured_channel == UpdateChannel::Preview {
+ crate::logging::update_check_failed("preview channel is not available for Nix installs");
+ return;
+ }
let release = match check_latest() {
Ok(Some(r)) => r,
@@ -1614,22 +1857,20 @@ pub fn auto_update(events: tokio::sync::mpsc::Sender<crate::events::AppEvent>) {
}
};
- crate::logging::update_available(&release.version.to_string());
+ crate::logging::update_available(release.label());
tracing::info!(
- "new version v{} available at {}",
- release.version,
+ "new {} build available at {}",
+ release.channel.as_str(),
release.download_url
);
- if let Err(e) =
- crate::release_notes::save_pending(&release.version.to_string(), &release.notes_body)
- {
+ if let Err(e) = crate::release_notes::save_pending(release.label(), &release.notes_body) {
tracing::warn!("failed to save pending release notes: {e}");
}
tracing::info!(
- "auto-update check: v{} available, waiting for explicit install",
- release.version
+ "auto-update check: {} available, waiting for explicit install",
+ release.label()
);
// Notify the TUI — blocking_send is safe from a std::thread
@@ -1640,7 +1881,7 @@ pub fn auto_update(events: tokio::sync::mpsc::Sender<crate::events::AppEvent>) {
};
let _ = events.blocking_send(crate::events::AppEvent::UpdateReady {
- version: release.version.to_string(),
+ version: release.label().to_string(),
install_command: install_command.to_string(),
});
}
@@ -1792,8 +2033,13 @@ mod tests {
fn fake_release(version: &str, target_protocol: Option<u32>) -> ReleaseInfo {
ReleaseInfo {
version: Version::parse(version).unwrap(),
+ identity: version.to_string(),
+ channel: UpdateChannel::Stable,
+ build_id: None,
+ commit: None,
target_protocol,
download_url: "https://example.com/herdr".to_string(),
+ sha256: None,
notes_body: "### Changed\n- One".to_string(),
}
}
@@ -2071,8 +2317,13 @@ mod tests {
};
let compatible_release = ReleaseInfo {
version: Version::parse("0.5.6").unwrap(),
+ identity: "0.5.6".to_string(),
+ channel: UpdateChannel::Stable,
+ build_id: None,
+ commit: None,
target_protocol: Some(2),
download_url: "https://example.com/herdr".to_string(),
+ sha256: None,
notes_body: "### Changed\n- One".to_string(),
};
let incompatible_release = ReleaseInfo {
@@ -2305,8 +2556,13 @@ mod tests {
};
let release = ReleaseInfo {
version: Version::parse("0.5.6").unwrap(),
+ identity: "0.5.6".to_string(),
+ channel: UpdateChannel::Stable,
+ build_id: None,
+ commit: None,
target_protocol: Some(3),
download_url: "https://example.com/herdr".to_string(),
+ sha256: None,
notes_body: "### Changed\n- One".to_string(),
};
let plan = RunningServerUpdatePlan {
@@ -2435,8 +2691,13 @@ mod tests {
});
let release = ReleaseInfo {
version: Version::parse("9.8.7").unwrap(),
+ identity: "9.8.7".to_string(),
+ channel: UpdateChannel::Stable,
+ build_id: None,
+ commit: None,
target_protocol: Some(77),
download_url: "https://example.com/herdr".to_string(),
+ sha256: None,
notes_body: "### Changed\n- One".to_string(),
};
@@ -2722,6 +2983,67 @@ mod tests {
}
#[test]
+ fn stable_channel_installs_stable_asset_when_current_binary_is_preview() {
+ let older_stable = Version::parse("0.6.5").unwrap();
+ let preview_base = Version::parse("0.6.6").unwrap();
+ assert!(stable_channel_should_install(
+ &older_stable,
+ &preview_base,
+ true
+ ));
+ assert!(!stable_channel_should_install(
+ &older_stable,
+ &preview_base,
+ false
+ ));
+ }
+
+ #[test]
+ fn preview_manifest_reports_update_when_build_id_differs() {
+ let manifest: PreviewManifest = serde_json::from_str(
+ r####"{
+ "channel": "preview",
+ "base_version": "9.9.9",
+ "build_id": "2026-06-02-abcdef123456",
+ "commit": "abcdef1234567890",
+ "built_at": "2026-06-02T03:00:00Z",
+ "protocol": 77,
+ "notes": "### Fixed\n- One",
+ "assets": {
+ "linux-x86_64": {
+ "url": "https://example.com/herdr-linux-x86_64",
+ "sha256": "deadbeef"
+ }
+ },
+ "builds": {
+ "2026-06-02-abcdef123456": {
+ "base_version": "9.9.9",
+ "commit": "abcdef1234567890",
+ "built_at": "2026-06-02T03:00:00Z",
+ "protocol": 77,
+ "assets": {
+ "linux_x86_64": {
+ "url": "https://example.com/herdr-linux_x86_64",
+ "sha256": "deadbeef"
+ }
+ }
+ }
+ }
+ }"####,
+ )
+ .unwrap();
+
+ let release = release_info_from_preview_manifest(&manifest)
+ .unwrap()
+ .expect("preview update");
+
+ assert_eq!(release.channel, UpdateChannel::Preview);
+ assert_eq!(release.identity, "9.9.9-preview.2026-06-02-abcdef123456");
+ assert_eq!(release.target_protocol, Some(77));
+ assert_eq!(release.sha256.as_deref(), Some("deadbeef"));
+ }
+
+ #[test]
fn checked_in_website_manifest_matches_update_schema() {
let manifest: UpdateManifest = serde_json::from_str(include_str!("../website/latest.json"))
.expect("website/latest.json should match updater schema");
@@ -2744,10 +3066,11 @@ mod tests {
"macos-x86_64",
"macos-aarch64",
] {
- let url = manifest
+ let url = &manifest
.assets
.get(target)
- .unwrap_or_else(|| panic!("missing asset URL for {target}"));
+ .unwrap_or_else(|| panic!("missing asset URL for {target}"))
+ .url;
assert!(
url.contains(&format!("/releases/download/v{}/", manifest.version)),
"unexpected release URL for {target}: {url}"