Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add version metadata tool #7723

Merged
merged 19 commits into from
Mar 6, 2025
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
19 changes: 19 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ members = [
"mullvad-types",
"mullvad-types/intersection-derive",
"mullvad-update",
"mullvad-update/meta",
"mullvad-version",
"talpid-core",
"talpid-dbus",
Expand Down
43 changes: 42 additions & 1 deletion desktop/scripts/release/4-make-release
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

# This script downloads the build artifacts along with the signatures, verifies the signatures and
# creates a GitHub draft release. This should be run after `3-verify-build`.
# This also publishes new version metadata

set -eu

Expand All @@ -15,11 +16,14 @@ PRODUCT_VERSION=$(cat $PRODUCT_VERSION_PATH)
$REPO_ROOT/scripts/utils/gh-ready-check

REPO_URL="git@github.com:mullvad/mullvadvpn-app"
ARTIFACT_DIR=$(mktemp -d)
ARTIFACT_DIR="./artifacts"
REPO_DIR=$(mktemp -d)
CHANGELOG_PATH="$REPO_DIR/CHANGELOG.md"
URL_BASE="https://releases.mullvad.net/desktop/releases"

rm -rf $ARTIFACT_DIR
mkdir -p $ARTIFACT_DIR

function download_and_verify {
# Find GnuPG command to use. Prefer gpg2
gpg_cmd=$(command -v gpg2 || command -v gpg)
Expand All @@ -46,6 +50,42 @@ function download_and_verify {
done
}

# Preconditions:
# - $VERSION_METADATA_SECRET must be set to an ed25519 secret
function publish_metadata {
local platforms
platforms=(windows macos linux)

rm -rf currently_published/

echo ">>> Fetching current version metadata"
meta pull --assume-yes "${platforms[@]}"
echo ""

echo ">>> Backing up released data"
cp -r signed/ currently_published/
echo ""

echo ">>> Adding new release $$PRODUCT_VERSION (rollout = 1)"
meta add-release "$PRODUCT_VERSION" "${platforms[@]}"
echo ""

echo ">>> Signing $PRODUCT_VERSION metadata"
meta sign --secret "$VERSION_METADATA_SECRET" "${platforms[@]}"
echo ""

echo ">>> Verifying signed metadata"
meta verify "${platforms[@]}"
echo ""

echo ">>> New metadata including $$PRODUCT_VERSION"
git diff --no-index -- currently_published/ signed/
echo ""

read -rp "Press enter to upload if the diff looks good "
# TODO: push metadata
}

function publish_release {
echo ">>> Cloning repository to extract changelog"
git clone --depth 1 --branch "$PRODUCT_VERSION" $REPO_URL "$REPO_DIR" 2> /dev/null > /dev/null
Expand Down Expand Up @@ -89,4 +129,5 @@ function publish_release {
}

download_and_verify
publish_metadata
publish_release
8 changes: 5 additions & 3 deletions mullvad-update/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,21 +28,23 @@ async-trait = { version = "0.1", optional = true }
reqwest = { version = "0.12.9", default-features = false, features = ["rustls-tls"], optional = true }
sha2 = { version = "0.10", optional = true }
tokio = { workspace = true, features = ["rt-multi-thread", "fs", "process", "macros"], optional = true }
thiserror = { workspace = true, optional = true }

mullvad-version = { path = "../mullvad-version", features = ["serde"] }

# features required by binaries
clap = { workspace = true, optional = true }
rand = { version = "0.8.5", optional = true }

[target.'cfg(any(target_os = "macos", target_os = "windows"))'.dependencies]
thiserror = { workspace = true, optional = true }

[dev-dependencies]
async-tempfile = "0.6"
insta = { workspace = true }
mockito = "1.6.1"
rand = "0.8.5"
tokio = { workspace = true, features = ["test-util", "time", "macros"] }
tokio = { workspace = true, features = ["fs", "test-util", "time", "macros"] }

[[bin]]
name = "mullvad-version-metadata"
required-features = ["sign"]
required-features = ["sign"]
3 changes: 3 additions & 0 deletions mullvad-update/meta/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
/signed
/work
/artifacts
27 changes: 27 additions & 0 deletions mullvad-update/meta/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
[package]
name = "meta"
description = "Tools for managing Mullvad version metadata"
authors.workspace = true
repository.workspace = true
license.workspace = true
edition.workspace = true
rust-version.workspace = true

[lints]
workspace = true

[dependencies]
anyhow = "1.0"
chrono = { workspace = true, features = ["serde", "now"] }
clap = { workspace = true }
hex = { version = "0.4" }
rand = { version = "0.8.5" }
reqwest = { version = "0.12.9", default-features = false, features = ["rustls-tls"] }
serde_json = { workspace = true }
serde = { workspace = true }
sha2 = "0.10"
tokio = { version = "1", features = ["full"] }
toml = "0.8"

mullvad-version = { path = "../../mullvad-version", features = ["serde"] }
mullvad-update = { path = "../", features = ["client", "sign"] }
76 changes: 76 additions & 0 deletions mullvad-update/meta/src/artifacts.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
//! Generate metadata for installer artifacts

use anyhow::Context;
use std::path::Path;
use tokio::{fs, io::BufReader};

use mullvad_update::{format, hash};

/// Generate `format::Installer` for a given `artifact`.
///
/// The presence of the files relative to `base_urls` is not verified.
/// See [crate::config::Config::base_urls] for the assumptions made.
pub async fn generate_installer_details(
architecture: format::Architecture,
base_urls: &[String],
artifact: &Path,
) -> anyhow::Result<format::Installer> {
let file = fs::File::open(artifact)
.await
.with_context(|| format!("Failed to open file at {}", artifact.display()))?;
let metadata = file
.metadata()
.await
.context("Failed to retrieve file metadata")?;
let file_size = metadata.len();
let file = BufReader::new(file);

println!("Generating checksum for {}", artifact.display());

let checksum = hash::checksum(file)
.await
.context("Failed to compute checksum")?;

// Construct URLs from base URLs
let filename = artifact
.file_name()
.and_then(|f| f.to_str())
.context("Unexpected filename")?;
let urls = derive_urls(base_urls, filename);

Ok(format::Installer {
architecture,
urls,
size: file_size.try_into().context("Invalid file size")?,
sha256: hex::encode(checksum),
})
}

fn derive_urls(base_urls: &[String], filename: &str) -> Vec<String> {
base_urls
.iter()
.map(|base_url| {
let url = base_url.strip_suffix("/").unwrap_or(base_url);
format!("{url}/{}", filename)
})
.collect()
}

#[cfg(test)]
mod test {
use super::*;

/// Test derivation of URLs from base URLs
#[tokio::test]
pub async fn test_urls() {
let base_urls = vec![
"https://fake1.fake/".to_string(),
"https://fake2.fake".to_string(),
];

assert_eq!(
&derive_urls(&base_urls, "test.exe"),
&["https://fake1.fake/test.exe", "https://fake2.fake/test.exe",]
);
}
}
38 changes: 38 additions & 0 deletions mullvad-update/meta/src/config.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
//! TOML configuration file

use anyhow::Context;
use serde::{Deserialize, Serialize};
use tokio::{fs, io};

/// Path to the configuration file. Currently a file in the working directory.
const CONFIG_FILENAME: &str = "meta.toml";

#[derive(Default, Deserialize, Serialize)]
pub struct Config {
/// URLs to use as bases for installers.
/// Files are expected at (example): `<base>/<version>/MullvadVPN-<version>.exe`.
pub base_urls: Vec<String>,
}

impl Config {
/// Try to load [CONFIG_FILENAME] from the working directory, create one if it does not exist.
pub async fn load_or_create() -> anyhow::Result<Self> {
match fs::read_to_string(CONFIG_FILENAME).await {
Ok(toml_str) => toml::from_str(&toml_str).context("Failed to parse TOML file"),
Err(err) if err.kind() == io::ErrorKind::NotFound => {
eprintln!("Creating default {CONFIG_FILENAME}");
let self_ = Self::default();
self_.save().await?;
Ok(self_)
}
Err(err) => Err(err).with_context(|| format!("Failed to read {CONFIG_FILENAME}")),
}
}

async fn save(&self) -> anyhow::Result<()> {
let toml_str = toml::to_string_pretty(self).expect("Expected valid toml");
fs::write(CONFIG_FILENAME, toml_str.as_bytes())
.await
.with_context(|| format!("Failed to save {CONFIG_FILENAME}"))
}
}
18 changes: 18 additions & 0 deletions mullvad-update/meta/src/github.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
//! Tools for the GitHub repository.

use anyhow::Context;

/// Obtain changes.txt for a given version/tag from the GitHub repository
pub async fn fetch_changes_text(version: &mullvad_version::Version) -> anyhow::Result<String> {
let github_changes_url = format!("https://raw.githubusercontent.com/mullvad/mullvadvpn-app/refs/tags/{version}/desktop/packages/mullvad-vpn/changes.txt");
let changes = reqwest::get(github_changes_url)
.await
.context("Failed to retrieve changes.txt (tag missing?)")?;
if let Err(err) = changes.error_for_status_ref() {
return Err(err).context("Error status returned when downloading changes.txt");
}
changes
.text()
.await
.context("Failed to retrieve text for changes.txt (tag missing?)")
}
82 changes: 82 additions & 0 deletions mullvad-update/meta/src/io_util.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
//! File and I/O utilities

use std::path::Path;

use anyhow::Context;
use tokio::fs;

/// Wait for user to respond with yes or no
/// This returns `false` if reading from stdin fails
pub async fn wait_for_confirm(prompt: &str) -> bool {
const DEFAULT: bool = true;

let prompt = prompt.to_owned();

tokio::task::spawn_blocking(move || {
let stdin = std::io::stdin();

loop {
let mut s = String::new();

print!("{prompt}");
if DEFAULT {
println!(" [Y/n]");
} else {
println!(" [y/N]");
}

stdin.read_line(&mut s).context("Failed to read line")?;

match s.trim().to_ascii_lowercase().as_str() {
"" => break Ok::<bool, anyhow::Error>(DEFAULT),
"y" | "ye" | "yes" => break Ok(true),
"n" | "no" => break Ok(false),
_ => (),
}
}
})
.await
.unwrap()
.unwrap_or(false)
}

/// Wait for user to respond with any input, ignoring empty responses
pub async fn wait_for_input(prompt: &str) -> anyhow::Result<String> {
let prompt = prompt.to_owned();
tokio::task::spawn_blocking(move || {
let stdin = std::io::stdin();

loop {
let mut s = String::new();

println!("{prompt}");

stdin.read_line(&mut s).context("Failed to read line")?;

match s.trim().to_ascii_lowercase().as_str() {
"" => continue,
input => break Ok(input.to_owned()),
}
}
})
.await
.unwrap()
}

/// Recursively create directories and write to 'file'
pub async fn create_dir_and_write(
path: impl AsRef<Path>,
contents: impl AsRef<[u8]>,
) -> anyhow::Result<()> {
let path = path.as_ref();

let parent_dir = path.parent().context("Missing parent directory")?;
fs::create_dir_all(parent_dir)
.await
.context("Failed to create directories")?;

fs::write(path, contents)
.await
.with_context(|| format!("Failed to write to {}", path.display()))?;
Ok(())
}
Loading
Loading