mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 04:55:42 +09:00
feat(downloads): route desktop releases through GitHub
This commit is contained in:
1 parent
dc230926a4
commit
094fb0d1c8
15 files changed
+1093
-155
No files matched your search
+424
-76
@@ -10,7 +10,11 @@ use crate::common::{
|
||||
upload_directory_to_s3, upload_s3_plan_append_only, upload_s3_plan_overwrite,
|
||||
};
|
||||
use crate::functions::write_json_pretty;
|
||||
use crate::release::DESKTOP_RELEASE_ASSET_SUFFIXES;
|
||||
use crate::release::{
|
||||
DESKTOP_RELEASE_DESCRIPTOR_SCHEMA_VERSION, DesktopReleaseAsset, DesktopReleaseDescriptor,
|
||||
desktop_release_descriptor_filename, desktop_release_product,
|
||||
validate_desktop_release_descriptor,
|
||||
};
|
||||
use anyhow::{Context, Result, anyhow, bail, ensure};
|
||||
use aws_sdk_s3::Client as S3Client;
|
||||
use chrono::Utc;
|
||||
@@ -115,6 +119,8 @@ enum DesktopStep {
|
||||
UploadReleaseAssets,
|
||||
DownloadReleaseAssets,
|
||||
UploadPayload,
|
||||
PublishReleaseDescriptor,
|
||||
PublishReleaseMarker,
|
||||
BuildSummary,
|
||||
}
|
||||
|
||||
@@ -243,6 +249,8 @@ pub async fn run(args: BuildDesktopArgs) -> Result<()> {
|
||||
DesktopStep::UploadReleaseAssets => upload_release_assets_step().await,
|
||||
DesktopStep::DownloadReleaseAssets => download_release_assets_step().await,
|
||||
DesktopStep::UploadPayload => upload_payload_step().await,
|
||||
DesktopStep::PublishReleaseDescriptor => publish_release_descriptor_step().await,
|
||||
DesktopStep::PublishReleaseMarker => publish_release_marker_step().await,
|
||||
DesktopStep::BuildSummary => build_summary_step(),
|
||||
}
|
||||
}
|
||||
@@ -1901,12 +1909,23 @@ fn resolve_windows_unpacked_dir(arch: &str, main_exe: &str) -> Result<PathBuf> {
|
||||
struct WindowsPackageConfig {
|
||||
pack_id: &'static str,
|
||||
pack_title: &'static str,
|
||||
artifact_prefix: &'static str,
|
||||
icon_dir: &'static str,
|
||||
runtime: &'static str,
|
||||
main_exe: String,
|
||||
output_dir: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct VelopackAssetIndexEntry {
|
||||
#[serde(rename = "RelativeFileName")]
|
||||
relative_file_name: String,
|
||||
#[serde(rename = "Type")]
|
||||
asset_type: String,
|
||||
#[serde(flatten)]
|
||||
extra: BTreeMap<String, Value>,
|
||||
}
|
||||
|
||||
fn windows_package_config(build_channel: &str, arch: &str) -> WindowsPackageConfig {
|
||||
let canary = build_channel == "canary";
|
||||
let pack_title = if canary { "Fluxer Canary" } else { "Fluxer" };
|
||||
@@ -1917,6 +1936,7 @@ fn windows_package_config(build_channel: &str, arch: &str) -> WindowsPackageConf
|
||||
"fluxer_desktop"
|
||||
},
|
||||
pack_title,
|
||||
artifact_prefix: if canary { "Fluxer-Canary" } else { "Fluxer" },
|
||||
icon_dir: if canary {
|
||||
"icons-canary"
|
||||
} else {
|
||||
@@ -2081,6 +2101,7 @@ fn validate_velopack_output(
|
||||
let full_nupkg = full_nupkg.ok_or_else(|| {
|
||||
anyhow!("Velopack did not produce a full nupkg payload for Windows updates.")
|
||||
})?;
|
||||
let full_nupkg = rename_windows_update_package(config, version, arch, &full_nupkg)?;
|
||||
let release_feed = fs::read_to_string(&legacy_releases)
|
||||
.with_context(|| format!("Failed to read {}", legacy_releases.display()))?;
|
||||
let nupkg_name = file_name_string(&full_nupkg)?;
|
||||
@@ -2096,7 +2117,7 @@ fn validate_velopack_output(
|
||||
config.output_dir.display()
|
||||
)
|
||||
})?;
|
||||
let desired_setup_name = format!("{}-{version}-win-{arch}.exe", config.pack_title);
|
||||
let desired_setup_name = format!("{}-{version}-win-{arch}.exe", config.artifact_prefix);
|
||||
if file_name_string(&setup_exe)? != desired_setup_name {
|
||||
fs::rename(&setup_exe, config.output_dir.join(desired_setup_name))
|
||||
.with_context(|| format!("Failed to rename {}", setup_exe.display()))?;
|
||||
@@ -2104,6 +2125,122 @@ fn validate_velopack_output(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rename_windows_update_package(
|
||||
config: &WindowsPackageConfig,
|
||||
version: &str,
|
||||
arch: &str,
|
||||
source: &Path,
|
||||
) -> Result<PathBuf> {
|
||||
let source_name = file_name_string(source)?;
|
||||
let target_name = format!("{}-{version}-win-{arch}-full.nupkg", config.artifact_prefix);
|
||||
let setup_name = format!("{}-{version}-win-{arch}.exe", config.artifact_prefix);
|
||||
let portable_name = format!(
|
||||
"{}-{version}-portable-win-{arch}.zip",
|
||||
config.artifact_prefix
|
||||
);
|
||||
ensure!(
|
||||
source_name != target_name,
|
||||
"Velopack unexpectedly emitted the canonical package name {target_name:?} before feed normalization"
|
||||
);
|
||||
let legacy_path = config.output_dir.join("RELEASES");
|
||||
let legacy = fs::read_to_string(&legacy_path)
|
||||
.with_context(|| format!("Failed to read {}", legacy_path.display()))?;
|
||||
ensure!(
|
||||
legacy.matches(&source_name).count() == 1,
|
||||
"{} must reference Velopack package {source_name:?} exactly once",
|
||||
legacy_path.display()
|
||||
);
|
||||
fs::write(&legacy_path, legacy.replace(&source_name, &target_name))
|
||||
.with_context(|| format!("Failed to rewrite {}", legacy_path.display()))?;
|
||||
|
||||
let releases_path = config.output_dir.join("releases.win.json");
|
||||
let mut releases: Value = serde_json::from_slice(
|
||||
&fs::read(&releases_path)
|
||||
.with_context(|| format!("Failed to read {}", releases_path.display()))?,
|
||||
)
|
||||
.with_context(|| format!("Failed to parse {}", releases_path.display()))?;
|
||||
let replacements = replace_json_string(&mut releases, &source_name, &target_name);
|
||||
ensure!(
|
||||
replacements == 1,
|
||||
"{} must reference Velopack package {source_name:?} exactly once, found {replacements}",
|
||||
releases_path.display()
|
||||
);
|
||||
write_json_pretty(&releases_path, &releases)?;
|
||||
|
||||
let assets_path = config.output_dir.join("assets.win.json");
|
||||
let mut assets: Vec<VelopackAssetIndexEntry> = serde_json::from_slice(
|
||||
&fs::read(&assets_path)
|
||||
.with_context(|| format!("Failed to read {}", assets_path.display()))?,
|
||||
)
|
||||
.with_context(|| format!("Failed to parse {}", assets_path.display()))?;
|
||||
ensure!(
|
||||
assets.len() == 3,
|
||||
"{} must contain exactly three Velopack assets, found {}",
|
||||
assets_path.display(),
|
||||
assets.len()
|
||||
);
|
||||
let mut asset_types = BTreeSet::new();
|
||||
for asset in &mut assets {
|
||||
ensure!(
|
||||
asset_types.insert(asset.asset_type.as_str()),
|
||||
"{} contains duplicate asset type {:?}",
|
||||
assets_path.display(),
|
||||
asset.asset_type
|
||||
);
|
||||
asset.relative_file_name = match asset.asset_type.as_str() {
|
||||
"Installer" => setup_name.clone(),
|
||||
"Portable" => portable_name.clone(),
|
||||
"Full" => {
|
||||
ensure!(
|
||||
asset.relative_file_name == source_name,
|
||||
"{} Full asset references {:?}, expected {source_name:?}",
|
||||
assets_path.display(),
|
||||
asset.relative_file_name
|
||||
);
|
||||
target_name.clone()
|
||||
}
|
||||
other => bail!(
|
||||
"{} contains unsupported Velopack asset type {other:?}",
|
||||
assets_path.display()
|
||||
),
|
||||
};
|
||||
}
|
||||
ensure!(
|
||||
asset_types == BTreeSet::from(["Full", "Installer", "Portable"]),
|
||||
"{} contains an incomplete Velopack asset inventory",
|
||||
assets_path.display()
|
||||
);
|
||||
write_json_pretty(&assets_path, &assets)?;
|
||||
|
||||
let target = config.output_dir.join(&target_name);
|
||||
fs::rename(source, &target).with_context(|| {
|
||||
format!(
|
||||
"Failed to rename {} to {}",
|
||||
source.display(),
|
||||
target.display()
|
||||
)
|
||||
})?;
|
||||
Ok(target)
|
||||
}
|
||||
|
||||
fn replace_json_string(value: &mut Value, source: &str, target: &str) -> usize {
|
||||
match value {
|
||||
Value::String(current) if current == source => {
|
||||
*current = target.to_string();
|
||||
1
|
||||
}
|
||||
Value::Array(values) => values
|
||||
.iter_mut()
|
||||
.map(|value| replace_json_string(value, source, target))
|
||||
.sum(),
|
||||
Value::Object(values) => values
|
||||
.values_mut()
|
||||
.map(|value| replace_json_string(value, source, target))
|
||||
.sum(),
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn find_windows_unpacked_app(arch: &str, main_exe: &str) -> Option<PathBuf> {
|
||||
windows_unpacked_candidates(arch)
|
||||
.into_iter()
|
||||
@@ -2225,7 +2362,10 @@ fn create_portable_zip_windows_step() -> Result<()> {
|
||||
let portable_marker = pack_dir.join(".portable");
|
||||
fs::write(&portable_marker, "")
|
||||
.with_context(|| format!("Failed to write {}", portable_marker.display()))?;
|
||||
let zip_name = format!("{}-{version}-portable-win-{arch}.zip", config.pack_title);
|
||||
let zip_name = format!(
|
||||
"{}-{version}-portable-win-{arch}.zip",
|
||||
config.artifact_prefix
|
||||
);
|
||||
let zip_path = PathBuf::from("dist-electron").join(zip_name);
|
||||
create_zip_from_dir(&pack_dir, &zip_path)?;
|
||||
remove_file_if_exists(&portable_marker)?;
|
||||
@@ -3159,9 +3299,10 @@ fn verify_windows_signed_artifacts_step() -> Result<()> {
|
||||
config.output_dir.display()
|
||||
)
|
||||
})?;
|
||||
let setup_exe = config
|
||||
.output_dir
|
||||
.join(format!("{}-{version}-win-{arch}.exe", config.pack_title));
|
||||
let setup_exe = config.output_dir.join(format!(
|
||||
"{}-{version}-win-{arch}.exe",
|
||||
config.artifact_prefix
|
||||
));
|
||||
ensure!(
|
||||
setup_exe.is_file(),
|
||||
"Velopack Setup.exe not found: {}",
|
||||
@@ -3169,7 +3310,7 @@ fn verify_windows_signed_artifacts_step() -> Result<()> {
|
||||
);
|
||||
let portable_zip = PathBuf::from("dist-electron").join(format!(
|
||||
"{}-{version}-portable-win-{arch}.zip",
|
||||
config.pack_title
|
||||
config.artifact_prefix
|
||||
));
|
||||
ensure!(
|
||||
portable_zip.is_file(),
|
||||
@@ -3507,100 +3648,243 @@ fn build_payload_step() -> Result<()> {
|
||||
fn prepare_release_assets_step() -> Result<()> {
|
||||
let channel = require_env("CHANNEL")?;
|
||||
let version = require_env("VERSION")?;
|
||||
let product = match channel.as_str() {
|
||||
"stable" => "Fluxer",
|
||||
"canary" => "Fluxer.Canary",
|
||||
other => bail!("Unsupported desktop release channel {other:?}"),
|
||||
};
|
||||
let expected_asset_names = DESKTOP_RELEASE_ASSET_SUFFIXES
|
||||
.iter()
|
||||
.map(|suffix| format!("{product}-{version}-{suffix}"))
|
||||
.collect::<BTreeSet<_>>();
|
||||
let artifacts = Path::new("artifacts");
|
||||
let source_sha = require_env("SOURCE_SHA")?;
|
||||
let s3_prefix = require_env("S3_DESKTOP_PREFIX")?;
|
||||
ensure!(
|
||||
s3_prefix == "desktop",
|
||||
"GitHub desktop releases require S3_DESKTOP_PREFIX=desktop, received {s3_prefix:?}"
|
||||
);
|
||||
let product = desktop_release_product(&channel)?;
|
||||
let payload_root = Path::new("s3_payload").join(&s3_prefix).join(&channel);
|
||||
let release_assets = Path::new("release_assets");
|
||||
remove_dir_if_exists(release_assets)?;
|
||||
fs::create_dir_all(release_assets)?;
|
||||
|
||||
let mut asset_names = BTreeSet::new();
|
||||
for (dir, identity) in payload_artifact_dirs(artifacts, &channel)? {
|
||||
let mut release_builder =
|
||||
DesktopReleaseAssetBuilder::new(&s3_prefix, &channel, &version, product, release_assets);
|
||||
for (platform, arch) in [
|
||||
("win32", "x64"),
|
||||
("win32", "arm64"),
|
||||
("darwin", "x64"),
|
||||
("darwin", "arm64"),
|
||||
("linux", "x64"),
|
||||
("linux", "arm64"),
|
||||
] {
|
||||
let dir = payload_root.join(platform).join(arch);
|
||||
ensure!(
|
||||
identity.desktop_variant == DEFAULT_DESKTOP_VARIANT,
|
||||
"GitHub release asset naming is undefined for desktop variant {:?}",
|
||||
identity.desktop_variant
|
||||
dir.is_dir(),
|
||||
"Desktop release payload directory is missing: {}",
|
||||
dir.display()
|
||||
);
|
||||
let platform = match identity.platform.as_str() {
|
||||
"windows" => "win32",
|
||||
"macos" => "darwin",
|
||||
let manifest_path = dir.join("manifest.json");
|
||||
let manifest: DesktopManifest = serde_json::from_slice(
|
||||
&fs::read(&manifest_path)
|
||||
.with_context(|| format!("Failed to read {}", manifest_path.display()))?,
|
||||
)
|
||||
.with_context(|| format!("Failed to parse {}", manifest_path.display()))?;
|
||||
ensure!(
|
||||
manifest.channel == channel
|
||||
&& manifest.platform == platform
|
||||
&& manifest.arch == arch
|
||||
&& manifest.version == version
|
||||
&& manifest.variant.is_none(),
|
||||
"Desktop release manifest identity mismatch in {}",
|
||||
manifest_path.display()
|
||||
);
|
||||
let expected_kinds = match platform {
|
||||
"win32" => BTreeSet::from(["portable", "setup"]),
|
||||
"darwin" => BTreeSet::from(["dmg", "zip"]),
|
||||
"linux" => BTreeSet::from(["appimage", "deb", "rpm", "tar_gz"]),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let actual_kinds = manifest
|
||||
.files
|
||||
.keys()
|
||||
.map(String::as_str)
|
||||
.collect::<BTreeSet<_>>();
|
||||
ensure!(
|
||||
actual_kinds == expected_kinds,
|
||||
"Incomplete shipped artifact set for {platform}/{arch}: expected {:?}, found {:?}",
|
||||
expected_kinds,
|
||||
actual_kinds
|
||||
);
|
||||
for entry in manifest.files.values() {
|
||||
release_builder.add(platform, arch, &dir.join(entry.filename()), false)?;
|
||||
}
|
||||
let updater_files = desktop_updater_release_files(&dir, platform)?;
|
||||
for updater_file in updater_files {
|
||||
release_builder.add(platform, arch, &updater_file, true)?;
|
||||
}
|
||||
}
|
||||
let mut descriptor_assets = release_builder.finish();
|
||||
descriptor_assets.sort_by(|left, right| left.storage_key.cmp(&right.storage_key));
|
||||
let descriptor = DesktopReleaseDescriptor {
|
||||
schema_version: DESKTOP_RELEASE_DESCRIPTOR_SCHEMA_VERSION,
|
||||
channel: channel.clone(),
|
||||
version: version.clone(),
|
||||
release_tag: format!("fluxer-desktop-{channel}@{version}"),
|
||||
source_sha,
|
||||
assets: descriptor_assets,
|
||||
};
|
||||
validate_desktop_release_descriptor(&descriptor, &channel, &version, &descriptor.source_sha)?;
|
||||
let descriptor_path =
|
||||
release_assets.join(desktop_release_descriptor_filename(&channel, &version)?);
|
||||
write_json_pretty(&descriptor_path, &descriptor)?;
|
||||
println!("GitHub release asset tree:");
|
||||
print_tree(release_assets, 2)
|
||||
}
|
||||
|
||||
fn desktop_updater_release_files(dir: &Path, platform: &str) -> Result<Vec<PathBuf>> {
|
||||
let mut files = match platform {
|
||||
"win32" => vec![
|
||||
dir.join("RELEASES"),
|
||||
dir.join("releases.win.json"),
|
||||
dir.join("assets.win.json"),
|
||||
],
|
||||
"darwin" => vec![dir.join("RELEASES.json"), dir.join("releases.json")],
|
||||
"linux" => Vec::new(),
|
||||
other => bail!("Unsupported desktop release platform {other:?}"),
|
||||
};
|
||||
if platform == "win32" {
|
||||
let nupkgs = collect_files(dir)?
|
||||
.into_iter()
|
||||
.filter(|path| {
|
||||
path.file_name()
|
||||
.and_then(OsStr::to_str)
|
||||
.is_some_and(|name| name.ends_with("-full.nupkg"))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
ensure!(
|
||||
nupkgs.len() == 1,
|
||||
"Expected one Windows full update package in {}, found {}",
|
||||
dir.display(),
|
||||
nupkgs.len()
|
||||
);
|
||||
files.push(nupkgs[0].clone());
|
||||
}
|
||||
for path in &files {
|
||||
ensure!(
|
||||
path.is_file(),
|
||||
"Desktop updater release file is missing: {}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
struct DesktopReleaseAssetBuilder<'a> {
|
||||
s3_prefix: &'a str,
|
||||
channel: &'a str,
|
||||
version: &'a str,
|
||||
product: &'a str,
|
||||
release_assets: &'a Path,
|
||||
descriptor_assets: Vec<DesktopReleaseAsset>,
|
||||
storage_keys: BTreeSet<String>,
|
||||
release_asset_content: BTreeMap<String, (String, u64)>,
|
||||
}
|
||||
|
||||
impl<'a> DesktopReleaseAssetBuilder<'a> {
|
||||
fn new(
|
||||
s3_prefix: &'a str,
|
||||
channel: &'a str,
|
||||
version: &'a str,
|
||||
product: &'a str,
|
||||
release_assets: &'a Path,
|
||||
) -> Self {
|
||||
Self {
|
||||
s3_prefix,
|
||||
channel,
|
||||
version,
|
||||
product,
|
||||
release_assets,
|
||||
descriptor_assets: Vec::new(),
|
||||
storage_keys: BTreeSet::new(),
|
||||
release_asset_content: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn add(&mut self, platform: &str, arch: &str, source: &Path, qualify_name: bool) -> Result<()> {
|
||||
ensure!(
|
||||
source.is_file(),
|
||||
"Release source is missing: {}",
|
||||
source.display()
|
||||
);
|
||||
let source_name = file_name_string(source)?;
|
||||
let platform_token = match platform {
|
||||
"win32" => "win",
|
||||
"darwin" => "mac",
|
||||
"linux" => "linux",
|
||||
other => bail!("Unsupported desktop release platform {other:?}"),
|
||||
};
|
||||
let candidates = manifest_candidates(&dir, platform, &identity.arch)?;
|
||||
let candidate_kinds = candidates
|
||||
.iter()
|
||||
.map(|(kind, _)| kind.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
let expected_kinds = match platform {
|
||||
"win32" => vec!["setup", "portable"],
|
||||
"darwin" => vec!["dmg", "zip"],
|
||||
"linux" => vec!["appimage", "deb", "rpm", "tar_gz"],
|
||||
_ => unreachable!(),
|
||||
let canonical_prefix = format!("{}-{}-", self.product, self.version);
|
||||
let release_asset = if qualify_name && !source_name.starts_with(&canonical_prefix) {
|
||||
format!(
|
||||
"{}-{}-{platform_token}-{arch}-{source_name}",
|
||||
self.product, self.version
|
||||
)
|
||||
} else {
|
||||
source_name.clone()
|
||||
};
|
||||
ensure!(
|
||||
candidate_kinds == expected_kinds,
|
||||
"Incomplete shipped artifact set for {}/{:?}: expected {:?}, found {:?}",
|
||||
identity.platform,
|
||||
identity.arch,
|
||||
expected_kinds,
|
||||
candidate_kinds
|
||||
release_asset.starts_with(&canonical_prefix)
|
||||
&& release_asset.bytes().all(|byte| {
|
||||
byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_')
|
||||
}),
|
||||
"Desktop release asset name is not canonical and URL-safe: {release_asset:?}"
|
||||
);
|
||||
|
||||
for (_, source) in candidates {
|
||||
let source_name = file_name_string(&source)?;
|
||||
let asset_name = clean_release_asset_name(&source_name)?;
|
||||
let storage_key = format!(
|
||||
"{}/{}/{platform}/{arch}/{source_name}",
|
||||
self.s3_prefix, self.channel
|
||||
);
|
||||
ensure!(
|
||||
self.storage_keys.insert(storage_key.clone()),
|
||||
"Duplicate desktop release storage key {storage_key:?}"
|
||||
);
|
||||
let size = fs::metadata(source)
|
||||
.with_context(|| format!("Failed to inspect {}", source.display()))?
|
||||
.len();
|
||||
ensure!(
|
||||
size > 0,
|
||||
"Desktop release source is empty: {}",
|
||||
source.display()
|
||||
);
|
||||
let sha256 = sha256_file(source)?;
|
||||
if let Some((existing_sha256, existing_size)) =
|
||||
self.release_asset_content.get(&release_asset)
|
||||
{
|
||||
ensure!(
|
||||
asset_names.insert(asset_name.clone()),
|
||||
"Duplicate GitHub release asset name {asset_name:?}"
|
||||
existing_sha256 == &sha256 && *existing_size == size,
|
||||
"Desktop release asset {release_asset:?} has conflicting source content"
|
||||
);
|
||||
let destination = release_assets.join(&asset_name);
|
||||
let copied = fs::copy(&source, &destination).with_context(|| {
|
||||
} else {
|
||||
let destination = self.release_assets.join(&release_asset);
|
||||
let copied = fs::copy(source, &destination).with_context(|| {
|
||||
format!(
|
||||
"Failed to copy shipped artifact {} to {}",
|
||||
"Failed to copy desktop release asset {} to {}",
|
||||
source.display(),
|
||||
destination.display()
|
||||
)
|
||||
})?;
|
||||
ensure!(
|
||||
copied > 0,
|
||||
"Copied empty GitHub release asset {}",
|
||||
copied == size,
|
||||
"Desktop release asset copy size mismatch for {}",
|
||||
destination.display()
|
||||
);
|
||||
self.release_asset_content
|
||||
.insert(release_asset.clone(), (sha256.clone(), size));
|
||||
}
|
||||
self.descriptor_assets.push(DesktopReleaseAsset {
|
||||
storage_key,
|
||||
release_asset,
|
||||
sha256,
|
||||
size,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
ensure!(
|
||||
asset_names == expected_asset_names,
|
||||
"GitHub release asset inventory mismatch: expected {expected_asset_names:?}, found {asset_names:?}"
|
||||
);
|
||||
println!("GitHub release asset tree:");
|
||||
print_tree(release_assets, 2)
|
||||
}
|
||||
|
||||
fn clean_release_asset_name(source_name: &str) -> Result<String> {
|
||||
let name = source_name
|
||||
.chars()
|
||||
.map(|character| {
|
||||
if character.is_ascii_whitespace() {
|
||||
'.'
|
||||
} else {
|
||||
character
|
||||
}
|
||||
})
|
||||
.collect::<String>();
|
||||
ensure!(
|
||||
name.bytes()
|
||||
.all(|byte| { byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_') }),
|
||||
"Cannot produce a clean GitHub release asset name from {source_name:?}"
|
||||
);
|
||||
Ok(name)
|
||||
fn finish(self) -> Vec<DesktopReleaseAsset> {
|
||||
self.descriptor_assets
|
||||
}
|
||||
}
|
||||
|
||||
async fn upload_release_assets_step() -> Result<()> {
|
||||
@@ -3922,6 +4206,70 @@ async fn upload_payload_step() -> Result<()> {
|
||||
.await
|
||||
}
|
||||
|
||||
fn read_release_descriptor_from_assets() -> Result<(PathBuf, DesktopReleaseDescriptor)> {
|
||||
let channel = require_env("CHANNEL")?;
|
||||
let version = require_env("VERSION")?;
|
||||
let source_sha = require_env("SOURCE_SHA")?;
|
||||
let path =
|
||||
Path::new("release_assets").join(desktop_release_descriptor_filename(&channel, &version)?);
|
||||
let descriptor: DesktopReleaseDescriptor = serde_json::from_slice(
|
||||
&fs::read(&path).with_context(|| format!("Failed to read {}", path.display()))?,
|
||||
)
|
||||
.with_context(|| format!("Failed to parse {}", path.display()))?;
|
||||
validate_desktop_release_descriptor(&descriptor, &channel, &version, &source_sha)?;
|
||||
Ok((path, descriptor))
|
||||
}
|
||||
|
||||
async fn publish_release_descriptor_step() -> Result<()> {
|
||||
let (descriptor_path, descriptor) = read_release_descriptor_from_assets()?;
|
||||
let client = s3_client(None).await?;
|
||||
let bucket = require_env("S3_BUCKET")?;
|
||||
let key = format!(
|
||||
"desktop/{}/github-releases/{}.json",
|
||||
descriptor.channel, descriptor.version
|
||||
);
|
||||
let plan = vec![
|
||||
S3UploadPlanItem::new(descriptor_path, key)
|
||||
.with_content_type("application/json; charset=utf-8")
|
||||
.with_cache_control(VERSIONED_ARTIFACT_CACHE_CONTROL),
|
||||
];
|
||||
upload_s3_plan_append_only(&client, &bucket, plan).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn publish_release_marker_step() -> Result<()> {
|
||||
let (descriptor_path, descriptor) = read_release_descriptor_from_assets()?;
|
||||
let descriptor_sha256 = sha256_file(&descriptor_path)?;
|
||||
let temp = TempDir::new().context("Failed to create desktop release marker temp directory")?;
|
||||
let marker_path = temp
|
||||
.path()
|
||||
.join(format!("{}.ready.json", descriptor.version));
|
||||
write_json_pretty(
|
||||
&marker_path,
|
||||
&json!({
|
||||
"schema_version": 1,
|
||||
"channel": descriptor.channel,
|
||||
"version": descriptor.version,
|
||||
"release_tag": descriptor.release_tag,
|
||||
"source_sha": descriptor.source_sha,
|
||||
"descriptor_sha256": descriptor_sha256,
|
||||
}),
|
||||
)?;
|
||||
let client = s3_client(None).await?;
|
||||
let bucket = require_env("S3_BUCKET")?;
|
||||
let key = format!(
|
||||
"desktop/{}/github-releases/{}.ready.json",
|
||||
descriptor.channel, descriptor.version
|
||||
);
|
||||
let plan = vec![
|
||||
S3UploadPlanItem::new(marker_path, key)
|
||||
.with_content_type("application/json; charset=utf-8")
|
||||
.with_cache_control(VERSIONED_ARTIFACT_CACHE_CONTROL),
|
||||
];
|
||||
upload_s3_plan_append_only(&client, &bucket, plan).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn upload_payload_directory<F>(
|
||||
client: &S3Client,
|
||||
bucket: &str,
|
||||
|
||||
+219
-29
@@ -4,7 +4,7 @@ use crate::common::{CommandSpec, output_text, parse_version_instant, run_command
|
||||
use anyhow::{Context, Result, bail, ensure};
|
||||
use chrono::{DateTime, Utc};
|
||||
use clap::{Args, Subcommand};
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fs::{self, File};
|
||||
@@ -13,22 +13,188 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
const RELEASE_REPOSITORY: &str = "fluxerapp/fluxer";
|
||||
const RELEASE_COMPARE_URL: &str = "https://github.com/fluxerapp/fluxer/compare";
|
||||
pub(crate) const DESKTOP_RELEASE_ASSET_SUFFIXES: &[&str] = &[
|
||||
"linux-aarch64.rpm",
|
||||
"linux-amd64.deb",
|
||||
"linux-arm64.AppImage",
|
||||
"linux-arm64.deb",
|
||||
"linux-arm64.tar.gz",
|
||||
"linux-x64.tar.gz",
|
||||
"linux-x86_64.AppImage",
|
||||
"linux-x86_64.rpm",
|
||||
"mac-universal.dmg",
|
||||
"mac-universal.zip",
|
||||
"portable-win-arm64.zip",
|
||||
"portable-win-x64.zip",
|
||||
"win-arm64.exe",
|
||||
"win-x64.exe",
|
||||
];
|
||||
pub(crate) const DESKTOP_RELEASE_DESCRIPTOR_SCHEMA_VERSION: u8 = 1;
|
||||
pub(crate) const DESKTOP_RELEASE_ROUTE_COUNT: usize = 28;
|
||||
pub(crate) const DESKTOP_RELEASE_ASSET_COUNT: usize = 26;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct DesktopReleaseAsset {
|
||||
pub(crate) storage_key: String,
|
||||
pub(crate) release_asset: String,
|
||||
pub(crate) sha256: String,
|
||||
pub(crate) size: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct DesktopReleaseDescriptor {
|
||||
pub(crate) schema_version: u8,
|
||||
pub(crate) channel: String,
|
||||
pub(crate) version: String,
|
||||
pub(crate) release_tag: String,
|
||||
pub(crate) source_sha: String,
|
||||
pub(crate) assets: Vec<DesktopReleaseAsset>,
|
||||
}
|
||||
|
||||
pub(crate) fn desktop_release_product(channel: &str) -> Result<&'static str> {
|
||||
match channel {
|
||||
"stable" => Ok("Fluxer"),
|
||||
"canary" => Ok("Fluxer-Canary"),
|
||||
other => bail!("Unsupported desktop release channel {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn desktop_release_descriptor_filename(channel: &str, version: &str) -> Result<String> {
|
||||
Ok(format!(
|
||||
"{}-{version}-release-manifest.json",
|
||||
desktop_release_product(channel)?
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn validate_desktop_release_descriptor(
|
||||
descriptor: &DesktopReleaseDescriptor,
|
||||
channel: &str,
|
||||
version: &str,
|
||||
source_sha: &str,
|
||||
) -> Result<()> {
|
||||
ensure!(
|
||||
descriptor.schema_version == DESKTOP_RELEASE_DESCRIPTOR_SCHEMA_VERSION,
|
||||
"Unsupported desktop release descriptor schema version {}",
|
||||
descriptor.schema_version
|
||||
);
|
||||
ensure!(
|
||||
descriptor.channel == channel,
|
||||
"Desktop release descriptor channel {:?} does not match {channel:?}",
|
||||
descriptor.channel
|
||||
);
|
||||
ensure!(
|
||||
descriptor.version == version,
|
||||
"Desktop release descriptor version {:?} does not match {version:?}",
|
||||
descriptor.version
|
||||
);
|
||||
ensure!(
|
||||
descriptor.release_tag == format!("fluxer-desktop-{channel}@{version}"),
|
||||
"Desktop release descriptor tag {:?} is invalid",
|
||||
descriptor.release_tag
|
||||
);
|
||||
ensure!(
|
||||
descriptor.source_sha == source_sha,
|
||||
"Desktop release descriptor source SHA {:?} does not match {source_sha:?}",
|
||||
descriptor.source_sha
|
||||
);
|
||||
ensure!(
|
||||
source_sha.len() == 40
|
||||
&& source_sha
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()),
|
||||
"Invalid desktop release source SHA {source_sha:?}"
|
||||
);
|
||||
parse_version_instant(version)
|
||||
.with_context(|| format!("Invalid desktop release descriptor version {version:?}"))?;
|
||||
ensure!(
|
||||
descriptor.assets.len() == DESKTOP_RELEASE_ROUTE_COUNT,
|
||||
"Desktop release descriptor must contain {DESKTOP_RELEASE_ROUTE_COUNT} routes, found {}",
|
||||
descriptor.assets.len()
|
||||
);
|
||||
let storage_prefix = format!("desktop/{channel}/");
|
||||
let release_prefix = format!("{}-{version}-", desktop_release_product(channel)?);
|
||||
let descriptor_name = desktop_release_descriptor_filename(channel, version)?;
|
||||
let mut storage_keys = BTreeSet::new();
|
||||
let mut route_counts = BTreeMap::<String, usize>::new();
|
||||
let mut release_assets = BTreeMap::<&str, (&str, u64)>::new();
|
||||
for asset in &descriptor.assets {
|
||||
ensure!(
|
||||
storage_keys.insert(asset.storage_key.as_str()),
|
||||
"Desktop release descriptor contains duplicate storage key {:?}",
|
||||
asset.storage_key
|
||||
);
|
||||
let key_segments = asset.storage_key.split('/').collect::<Vec<_>>();
|
||||
ensure!(
|
||||
key_segments.len() == 5
|
||||
&& key_segments[0] == "desktop"
|
||||
&& key_segments[1] == channel
|
||||
&& matches!(key_segments[2], "win32" | "darwin" | "linux")
|
||||
&& matches!(key_segments[3], "x64" | "arm64")
|
||||
&& !key_segments[4].is_empty()
|
||||
&& key_segments[4].bytes().all(|byte| {
|
||||
byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_')
|
||||
})
|
||||
&& asset.storage_key.starts_with(&storage_prefix),
|
||||
"Desktop release descriptor contains invalid storage key {:?}",
|
||||
asset.storage_key
|
||||
);
|
||||
*route_counts
|
||||
.entry(format!("{}/{}", key_segments[2], key_segments[3]))
|
||||
.or_default() += 1;
|
||||
let platform_token = match key_segments[2] {
|
||||
"win32" => "win",
|
||||
"darwin" => "mac",
|
||||
"linux" => "linux",
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let expected_release_asset = if key_segments[4].starts_with(&release_prefix) {
|
||||
key_segments[4].to_string()
|
||||
} else {
|
||||
format!(
|
||||
"{release_prefix}{platform_token}-{}-{}",
|
||||
key_segments[3], key_segments[4]
|
||||
)
|
||||
};
|
||||
ensure!(
|
||||
asset.release_asset.starts_with(&release_prefix)
|
||||
&& asset.release_asset != descriptor_name
|
||||
&& asset.release_asset == expected_release_asset
|
||||
&& asset.release_asset.bytes().all(|byte| {
|
||||
byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_')
|
||||
}),
|
||||
"Desktop release descriptor contains invalid release asset {:?}",
|
||||
asset.release_asset
|
||||
);
|
||||
ensure!(
|
||||
asset.sha256.len() == 64
|
||||
&& asset
|
||||
.sha256
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()),
|
||||
"Desktop release descriptor contains invalid SHA-256 for {:?}",
|
||||
asset.release_asset
|
||||
);
|
||||
ensure!(
|
||||
asset.size > 0,
|
||||
"Desktop release descriptor contains an empty asset {:?}",
|
||||
asset.release_asset
|
||||
);
|
||||
if let Some((sha256, size)) = release_assets.get(asset.release_asset.as_str()) {
|
||||
ensure!(
|
||||
*sha256 == asset.sha256 && *size == asset.size,
|
||||
"Desktop release descriptor maps conflicting content to {:?}",
|
||||
asset.release_asset
|
||||
);
|
||||
} else {
|
||||
release_assets.insert(
|
||||
asset.release_asset.as_str(),
|
||||
(asset.sha256.as_str(), asset.size),
|
||||
);
|
||||
}
|
||||
}
|
||||
ensure!(
|
||||
release_assets.len() == DESKTOP_RELEASE_ASSET_COUNT,
|
||||
"Desktop release descriptor must contain {DESKTOP_RELEASE_ASSET_COUNT} unique release assets, found {}",
|
||||
release_assets.len()
|
||||
);
|
||||
let expected_route_counts = BTreeMap::from([
|
||||
("darwin/arm64".to_string(), 4usize),
|
||||
("darwin/x64".to_string(), 4usize),
|
||||
("linux/arm64".to_string(), 4usize),
|
||||
("linux/x64".to_string(), 4usize),
|
||||
("win32/arm64".to_string(), 6usize),
|
||||
("win32/x64".to_string(), 6usize),
|
||||
]);
|
||||
ensure!(
|
||||
route_counts == expected_route_counts,
|
||||
"Desktop release descriptor route inventory mismatch: expected {expected_route_counts:?}, found {route_counts:?}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Args, Clone)]
|
||||
pub struct ReleaseArgs {
|
||||
@@ -202,6 +368,7 @@ fn publish(args: PublishArgs) -> Result<()> {
|
||||
let assets = local_release_assets(
|
||||
&args.component,
|
||||
&args.build_version,
|
||||
&source_sha,
|
||||
args.asset_dir.as_deref(),
|
||||
)?;
|
||||
if let Some(existing) = existing_summary.filter(|release| !release.is_draft) {
|
||||
@@ -404,6 +571,7 @@ fn tag_exists(tag: &str) -> Result<bool> {
|
||||
fn local_release_assets(
|
||||
component: &str,
|
||||
version: &str,
|
||||
source_sha: &str,
|
||||
asset_dir: Option<&Path>,
|
||||
) -> Result<Vec<LocalReleaseAsset>> {
|
||||
let Some(channel) = desktop_channel(component) else {
|
||||
@@ -419,12 +587,16 @@ fn local_release_assets(
|
||||
"Desktop release asset directory does not exist: {}",
|
||||
asset_dir.display()
|
||||
);
|
||||
let product = match channel {
|
||||
"stable" => "Fluxer",
|
||||
"canary" => "Fluxer.Canary",
|
||||
other => bail!("Unsupported desktop release channel {other:?}"),
|
||||
};
|
||||
let product = desktop_release_product(channel)?;
|
||||
let prefix = format!("{product}-{version}-");
|
||||
let descriptor_name = desktop_release_descriptor_filename(channel, version)?;
|
||||
let descriptor_path = asset_dir.join(&descriptor_name);
|
||||
let descriptor: DesktopReleaseDescriptor = serde_json::from_slice(
|
||||
&fs::read(&descriptor_path)
|
||||
.with_context(|| format!("Failed to read {}", descriptor_path.display()))?,
|
||||
)
|
||||
.with_context(|| format!("Failed to parse {}", descriptor_path.display()))?;
|
||||
validate_desktop_release_descriptor(&descriptor, channel, version, source_sha)?;
|
||||
let mut entries = fs::read_dir(asset_dir)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
@@ -464,12 +636,9 @@ fn local_release_assets(
|
||||
.all(|byte| { byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_') }),
|
||||
"Release asset name is not clean and URL-safe: {name:?}"
|
||||
);
|
||||
let suffix = name
|
||||
.strip_prefix(&prefix)
|
||||
.with_context(|| format!("Release asset {name:?} must start with {prefix:?}"))?;
|
||||
ensure!(
|
||||
DESKTOP_RELEASE_ASSET_SUFFIXES.contains(&suffix),
|
||||
"Release asset {name:?} is not a supported shipped desktop artifact"
|
||||
name.starts_with(&prefix),
|
||||
"Release asset {name:?} must start with {prefix:?}"
|
||||
);
|
||||
assets.push(LocalReleaseAsset {
|
||||
digest: sha256_file(&path)?,
|
||||
@@ -478,9 +647,11 @@ fn local_release_assets(
|
||||
size: metadata.len(),
|
||||
});
|
||||
}
|
||||
let expected_names = DESKTOP_RELEASE_ASSET_SUFFIXES
|
||||
let expected_names = descriptor
|
||||
.assets
|
||||
.iter()
|
||||
.map(|suffix| format!("{prefix}{suffix}"))
|
||||
.map(|asset| asset.release_asset.clone())
|
||||
.chain(std::iter::once(descriptor_name))
|
||||
.collect::<BTreeSet<_>>();
|
||||
let actual_names = assets
|
||||
.iter()
|
||||
@@ -490,6 +661,25 @@ fn local_release_assets(
|
||||
actual_names == expected_names,
|
||||
"Desktop release asset inventory mismatch: expected {expected_names:?}, found {actual_names:?}"
|
||||
);
|
||||
let local_by_name = assets
|
||||
.iter()
|
||||
.map(|asset| (asset.name.as_str(), asset))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
for descriptor_asset in &descriptor.assets {
|
||||
let local = local_by_name
|
||||
.get(descriptor_asset.release_asset.as_str())
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"Desktop release descriptor references missing asset {:?}",
|
||||
descriptor_asset.release_asset
|
||||
)
|
||||
})?;
|
||||
ensure!(
|
||||
local.digest == descriptor_asset.sha256 && local.size == descriptor_asset.size,
|
||||
"Desktop release descriptor metadata does not match {:?}",
|
||||
descriptor_asset.release_asset
|
||||
);
|
||||
}
|
||||
Ok(assets)
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user