From f9397d0db9cdc9556796be9e83c3617bc46db969 Mon Sep 17 00:00:00 2001 From: Hampus Date: Sat, 19 Sep 2026 22:01:06 +0200 Subject: [PATCH] feat(ci): publish linux repositories from the desktop release (#2847) --- .github/workflows/build-desktop.yaml | 115 +-- tools/ci/src/common.rs | 318 ------ tools/ci/src/desktop.rs | 1328 +++----------------------- tools/ci/src/release.rs | 324 ++++++- 4 files changed, 476 insertions(+), 1609 deletions(-) diff --git a/.github/workflows/build-desktop.yaml b/.github/workflows/build-desktop.yaml index dff21ea8d..09e76ab21 100644 --- a/.github/workflows/build-desktop.yaml +++ b/.github/workflows/build-desktop.yaml @@ -11,11 +11,6 @@ on: - stable - canary default: stable - test_build: - description: Stash artifacts under desktop-test/ instead of desktop/ (API will not pick these up as a release). - required: false - default: false - type: boolean build_version: description: Explicit Fluxer CalVer build version (YYYY.MDD.MICRO, UTC HHMMSS without leading zeroes) to use instead of automatic UTC clock allocation. required: false @@ -32,13 +27,12 @@ permissions: actions: read concurrency: - group: desktop-${{ inputs.channel }}-${{ inputs.test_build && 'test' || 'release' }} + group: desktop-${{ inputs.channel }} cancel-in-progress: true env: CHANNEL: ${{ inputs.channel }} BUILD_CHANNEL: ${{ inputs.channel == 'canary' && 'canary' || 'stable' }} - TEST_BUILD: ${{ inputs.test_build && 'true' || 'false' }} jobs: meta: @@ -53,8 +47,6 @@ jobs: pub_date: ${{ steps.meta.outputs.pub_date }} channel: ${{ steps.meta.outputs.channel }} build_channel: ${{ steps.meta.outputs.build_channel }} - test_build: ${{ steps.meta.outputs.test_build }} - s3_prefix: ${{ steps.meta.outputs.s3_prefix }} source_sha: ${{ steps.meta.outputs.source_sha }} steps: - name: Checkout source @@ -85,7 +77,6 @@ jobs: cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop --step set_metadata --channel "${{ inputs.channel }}" - --test-build "${{ inputs.test_build }}" matrix: name: Resolve build matrix @@ -113,7 +104,7 @@ jobs: --skip-targets "${{ inputs.skip_targets }}" build: - name: Build ${{ matrix.platform }} (${{ matrix.arch }}, ${{ matrix.desktop_variant }}) + name: Build ${{ matrix.platform }} (${{ matrix.arch }}) needs: - meta - matrix @@ -137,15 +128,8 @@ jobs: PUBLIC_BUILD_VERSION: ${{ needs.meta.outputs.version }} PUB_DATE: ${{ needs.meta.outputs.pub_date }} SOURCE_SHA: ${{ needs.meta.outputs.source_sha }} - S3_DESKTOP_PREFIX: ${{ needs.meta.outputs.s3_prefix }} - DESKTOP_HANDOFF_PREFIX: _handoff/desktop/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }} - S3_ENDPOINT: ${{ vars.DOWNLOADS_S3_ENDPOINT }} - S3_BUCKET: ${{ vars.DOWNLOADS_S3_BUCKET }} - AWS_ACCESS_KEY_ID: ${{ secrets.DOWNLOADS_AWS_ACCESS_KEY_ID || secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.DOWNLOADS_AWS_SECRET_ACCESS_KEY || secrets.AWS_SECRET_ACCESS_KEY }} DESKTOP_PLATFORM: ${{ matrix.platform }} DESKTOP_ARCH: ${{ matrix.arch }} - DESKTOP_VARIANT: ${{ matrix.desktop_variant }} PLATFORM: ${{ matrix.platform }} ARCH: ${{ matrix.arch }} ELECTRON_ARCH: ${{ matrix.electron_arch }} @@ -495,13 +479,23 @@ jobs: cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop --step generate_checksums_windows - - name: Upload artifacts to S3 handoff + - name: Stage build artifacts + id: handoff run: >- cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop - --step upload_handoff + --step stage_handoff + + - name: Upload build artifacts + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: ${{ steps.handoff.outputs.artifact_name }} + path: upload_staging + if-no-files-found: error + retention-days: 1 + compression-level: 0 upload: - name: Upload to S3 + name: Assemble desktop release assets if: ${{ !cancelled() && needs.build.result == 'success' }} needs: - meta @@ -520,17 +514,8 @@ jobs: BUILD_VERSION: ${{ needs.meta.outputs.version }} PUBLIC_BUILD_VERSION: ${{ needs.meta.outputs.version }} PUB_DATE: ${{ needs.meta.outputs.pub_date }} - TEST_BUILD: ${{ needs.meta.outputs.test_build }} SOURCE_SHA: ${{ needs.meta.outputs.source_sha }} - S3_DESKTOP_PREFIX: ${{ needs.meta.outputs.s3_prefix }} - DESKTOP_HANDOFF_PREFIX: _handoff/desktop/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }} - DESKTOP_RELEASE_ASSETS_PREFIX: _handoff/desktop-release-assets/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }} - DESKTOP_METADATA_PREFIX: _handoff/desktop-metadata/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }} - S3_ENDPOINT: ${{ vars.DOWNLOADS_S3_ENDPOINT }} - S3_BUCKET: ${{ vars.DOWNLOADS_S3_BUCKET }} PUBLIC_DL_BASE: https://api.fluxer.app/dl - AWS_ACCESS_KEY_ID: ${{ secrets.DOWNLOADS_AWS_ACCESS_KEY_ID || secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.DOWNLOADS_AWS_SECRET_ACCESS_KEY || secrets.AWS_SECRET_ACCESS_KEY }} steps: - name: Checkout source uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 @@ -542,12 +527,13 @@ jobs: with: toolchain: "1.98.1" - - name: Download S3 handoff artifacts - run: >- - cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop - --step download_handoff + - name: Download build artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + path: artifacts + pattern: fluxer-desktop-${{ needs.meta.outputs.build_channel }}-* - - name: Build S3 payload layout (+ manifest.json) + - name: Build payload layout (+ manifest.json) env: VERSION: ${{ needs.meta.outputs.version }} PUB_DATE: ${{ needs.meta.outputs.pub_date }} @@ -556,42 +542,27 @@ jobs: --step build_payload - name: Prepare GitHub release assets - if: needs.meta.outputs.test_build != 'true' run: >- cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop --step prepare_release_assets - - name: Publish GitHub release descriptor - if: needs.meta.outputs.test_build != 'true' - run: >- - cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop - --step publish_release_descriptor - - - name: Upload payload to S3 - run: >- - cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop - --step upload_payload - - - name: Upload GitHub release asset handoff - if: needs.meta.outputs.test_build != 'true' - run: >- - cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop - --step upload_release_assets + - name: Upload GitHub release assets + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: fluxer-desktop-release-assets + path: release_assets + if-no-files-found: error + retention-days: 1 + compression-level: 0 - name: Build summary run: >- cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop --step build_summary - - name: Cleanup S3 handoff - if: ${{ success() }} - run: >- - cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop - --step cleanup_handoff - publish_release: name: Publish GitHub desktop release - if: ${{ !cancelled() && needs.upload.result == 'success' && needs.meta.outputs.test_build != 'true' }} + if: ${{ !cancelled() && needs.upload.result == 'success' }} needs: - meta - upload @@ -603,13 +574,6 @@ jobs: env: CHANNEL: ${{ needs.meta.outputs.build_channel }} VERSION: ${{ needs.meta.outputs.version }} - S3_DESKTOP_PREFIX: ${{ needs.meta.outputs.s3_prefix }} - DESKTOP_RELEASE_ASSETS_PREFIX: _handoff/desktop-release-assets/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }} - DESKTOP_METADATA_PREFIX: _handoff/desktop-metadata/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }} - S3_ENDPOINT: ${{ vars.DOWNLOADS_S3_ENDPOINT }} - S3_BUCKET: ${{ vars.DOWNLOADS_S3_BUCKET }} - AWS_ACCESS_KEY_ID: ${{ secrets.DOWNLOADS_AWS_ACCESS_KEY_ID || secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.DOWNLOADS_AWS_SECRET_ACCESS_KEY || secrets.AWS_SECRET_ACCESS_KEY }} steps: - name: Checkout source uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 @@ -622,9 +586,10 @@ jobs: toolchain: "1.98.1" - name: Download GitHub release assets - run: >- - cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop - --step download_release_assets + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: fluxer-desktop-release-assets + path: release_assets - name: Create token id: create-token @@ -656,15 +621,3 @@ jobs: release_args+=(--prerelease) fi cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- "${release_args[@]}" - - - name: Publish GitHub release readiness marker - env: - SOURCE_SHA: ${{ needs.meta.outputs.source_sha }} - run: >- - cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop - --step publish_release_marker - - - name: Publish payload metadata to S3 - run: >- - cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop - --step publish_payload_metadata diff --git a/tools/ci/src/common.rs b/tools/ci/src/common.rs index f7c04a34f..6fff33e0d 100644 --- a/tools/ci/src/common.rs +++ b/tools/ci/src/common.rs @@ -442,25 +442,6 @@ pub(crate) async fn s3_client(default_endpoint: Option<&str>) -> Result( - client: &S3Client, - bucket: &str, - prefix: &str, - root: &Path, - include: F, -) -> Result<()> -where - F: Fn(&Path) -> bool, -{ - let plan = directory_upload_plan(prefix, root, include)?; - let stats = upload_s3_plan_append_only(client, bucket, plan).await?; - println!( - "Append-only upload complete for s3://{bucket}/{prefix}: uploaded {}, skipped existing {}", - stats.uploaded, stats.skipped_existing - ); - Ok(()) -} - pub(crate) async fn upload_s3_plan_append_only( client: &S3Client, bucket: &str, @@ -528,47 +509,6 @@ pub(crate) async fn upload_s3_plan_append_only( Ok(stats) } -pub(crate) async fn upload_s3_plan_overwrite( - client: &S3Client, - bucket: &str, - plan: Vec, -) -> Result { - ensure_unique_s3_keys(&plan)?; - let concurrency = s3_write_concurrency(); - let semaphore = Arc::new(Semaphore::new(concurrency)); - let bucket = bucket.to_string(); - let mut tasks = JoinSet::new(); - for item in plan { - let permit = semaphore - .clone() - .acquire_owned() - .await - .context("S3 upload semaphore closed")?; - let client = client.clone(); - let bucket = bucket.clone(); - tasks.spawn(async move { - let _permit = permit; - put_file_to_s3_overwrite(&client, &bucket, &item) - .await - .with_context(|| { - format!("Failed overwrite upload for s3://{}/{}", bucket, item.key) - })?; - Ok::<_, anyhow::Error>(S3UploadDisposition::Uploaded) - }); - } - - let mut stats = S3UploadStats::default(); - while let Some(result) = tasks.join_next().await { - match result.context("S3 upload task failed")?? { - S3UploadDisposition::Uploaded => stats.uploaded += 1, - S3UploadDisposition::SkippedExisting => stats.skipped_existing += 1, - S3UploadDisposition::MetadataRepaired => stats.metadata_repaired += 1, - } - } - - Ok(stats) -} - #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct S3ObjectMetadata { pub(crate) e_tag: Option, @@ -862,69 +802,6 @@ async fn repair_existing_s3_object_metadata( unreachable!("S3 retry attempts are always greater than zero") } -async fn put_file_to_s3_overwrite( - client: &S3Client, - bucket: &str, - item: &S3UploadPlanItem, -) -> Result<()> { - println!( - "Overwriting {} -> s3://{bucket}/{}", - item.path.display(), - item.key - ); - let identity = s3_file_identity(&item.path)?; - let attempts = s3_retry_attempts(); - for attempt in 1..=attempts { - let body = ByteStream::from_path(&item.path) - .await - .with_context(|| format!("Failed to read {}", item.path.display()))?; - let mut request = client - .put_object() - .bucket(bucket) - .key(&item.key) - .content_md5(identity.md5_base64.clone()) - .body(body); - if let Some(content_type) = &item.content_type { - request = request.content_type(content_type); - } - if let Some(cache_control) = &item.cache_control { - request = request.cache_control(cache_control); - } - match request.send().await { - Ok(_) => return Ok(()), - Err(error) => { - let code = error - .as_service_error() - .and_then(|error| error.code()) - .map(ToOwned::to_owned); - let status = error - .raw_response() - .map(|response| response.status().as_u16()); - if is_retryable_s3_error(code.as_deref(), status) && attempt < attempts { - sleep_before_s3_retry( - "overwrite upload", - &format!("s3://{bucket}/{}", item.key), - attempt, - attempts, - code.as_deref(), - status, - ) - .await; - continue; - } - let summary = s3_error_summary(code.as_deref(), status); - return Err(error).with_context(|| { - format!( - "Failed to overwrite upload s3://{bucket}/{}{summary}", - item.key - ) - }); - } - } - } - unreachable!("S3 retry attempts are always greater than zero") -} - fn is_existing_object_error(code: Option<&str>, status: Option) -> bool { matches!( code, @@ -991,27 +868,6 @@ fn s3_write_concurrency() -> usize { .unwrap_or(DEFAULT_S3_WRITE_CONCURRENCY) } -pub(crate) fn directory_upload_plan( - prefix: &str, - root: &Path, - include: F, -) -> Result> -where - F: Fn(&Path) -> bool, -{ - Ok(collect_files(root)? - .into_iter() - .filter_map(|file| { - let relative = file.strip_prefix(root).ok()?; - if !include(relative) { - return None; - } - let key = join_s3_key(prefix, &path_to_s3_key(relative)); - Some(S3UploadPlanItem::new(file, key).with_detected_content_type()) - }) - .collect::>()) -} - pub(crate) fn s3_content_type_for_key(key: &str) -> Option<&'static str> { let ext = key.rsplit('.').next()?.to_ascii_lowercase(); match ext.as_str() { @@ -1045,49 +901,6 @@ pub(crate) fn s3_content_type_for_key(key: &str) -> Option<&'static str> { } } -pub(crate) async fn download_s3_prefix( - client: &S3Client, - bucket: &str, - prefix: &str, - target: &Path, -) -> Result<()> { - let list_prefix = s3_directory_prefix(prefix); - let keys = list_s3_keys(client, bucket, &list_prefix).await?; - for key in keys { - let relative = key - .strip_prefix(&list_prefix) - .unwrap_or(&key) - .trim_start_matches('/'); - if relative.is_empty() { - continue; - } - let output = safe_download_target(target, relative)?; - if let Some(parent) = output.parent() { - tokio::fs::create_dir_all(parent) - .await - .with_context(|| format!("Failed to create {}", parent.display()))?; - } - let bytes = get_s3_object_bytes(client, bucket, &key).await?; - tokio::fs::write(&output, bytes) - .await - .with_context(|| format!("Failed to write {}", output.display()))?; - println!("Downloaded s3://{bucket}/{key} -> {}", output.display()); - } - Ok(()) -} - -pub(crate) async fn list_s3_keys( - client: &S3Client, - bucket: &str, - prefix: &str, -) -> Result> { - Ok(list_s3_objects(client, bucket, prefix) - .await? - .into_iter() - .map(|object| object.key) - .collect()) -} - async fn list_s3_objects( client: &S3Client, bucket: &str, @@ -1158,78 +971,6 @@ async fn send_s3_list_objects_v2_page( unreachable!("S3 retry attempts are always greater than zero") } -pub(crate) async fn get_s3_object_bytes( - client: &S3Client, - bucket: &str, - key: &str, -) -> Result { - let object = send_s3_get_object(client, bucket, key).await?; - Ok(object - .body - .collect() - .await - .with_context(|| format!("Failed to collect s3://{bucket}/{key} body"))? - .into_bytes()) -} - -async fn send_s3_get_object( - client: &S3Client, - bucket: &str, - key: &str, -) -> Result { - let attempts = s3_retry_attempts(); - for attempt in 1..=attempts { - match client.get_object().bucket(bucket).key(key).send().await { - Ok(response) => return Ok(response), - Err(error) => { - let code = error - .as_service_error() - .and_then(|error| error.code()) - .map(ToOwned::to_owned); - let status = error - .raw_response() - .map(|response| response.status().as_u16()); - if is_retryable_s3_error(code.as_deref(), status) && attempt < attempts { - sleep_before_s3_retry( - "read", - &format!("s3://{bucket}/{key}"), - attempt, - attempts, - code.as_deref(), - status, - ) - .await; - continue; - } - let summary = s3_error_summary(code.as_deref(), status); - return Err(error) - .with_context(|| format!("Failed to read s3://{bucket}/{key}{summary}")); - } - } - } - unreachable!("S3 retry attempts are always greater than zero") -} - -pub(crate) fn join_s3_key(prefix: &str, child: &str) -> String { - let prefix = prefix.trim_matches('/'); - let child = child.trim_matches('/'); - match (prefix.is_empty(), child.is_empty()) { - (true, true) => String::new(), - (true, false) => child.to_string(), - (false, true) => prefix.to_string(), - (false, false) => format!("{prefix}/{child}"), - } -} - -pub(crate) fn s3_directory_prefix(prefix: &str) -> String { - let prefix = prefix.trim_matches('/'); - if prefix.is_empty() { - String::new() - } else { - format!("{prefix}/") - } -} - pub(crate) fn path_to_s3_key(path: &Path) -> String { path.components() .filter_map(|component| match component { @@ -1240,17 +981,6 @@ pub(crate) fn path_to_s3_key(path: &Path) -> String { .join("/") } -fn safe_download_target(target: &Path, relative: &str) -> Result { - let candidate = Path::new(relative); - for component in candidate.components() { - ensure!( - matches!(component, std::path::Component::Normal(_)), - "Refusing to write S3 object outside download target: {relative}" - ); - } - Ok(target.join(candidate)) -} - pub(crate) async fn download_file(url: &str, path: &Path) -> Result<()> { let bytes = Client::new() .get(url) @@ -1319,16 +1049,6 @@ pub(crate) fn count_files(root: &Path) -> Result { Ok(collect_files(root)?.len()) } -pub(crate) fn count_files_min_depth(root: &Path, min_depth: usize) -> Result { - let mut count = 0; - for entry in WalkDir::new(root).min_depth(min_depth) { - if entry?.file_type().is_file() { - count += 1; - } - } - Ok(count) -} - pub(crate) fn title_case(value: &str) -> String { let mut chars = value.chars(); match chars.next() { @@ -1371,14 +1091,6 @@ mod tests { #[test] fn s3_key_helpers_are_platform_neutral() { - assert_eq!( - join_s3_key("/desktop/", "/canary/linux/"), - "desktop/canary/linux" - ); - assert_eq!( - s3_directory_prefix("/_handoff/desktop/build/"), - "_handoff/desktop/build/" - ); assert_eq!( path_to_s3_key(Path::new("assets").join("chunks").join("a.js").as_path()), "assets/chunks/a.js" @@ -1545,36 +1257,6 @@ mod tests { assert_eq!(s3_error_summary(None, Some(500)), " (HTTP status 500)"); } - #[test] - fn directory_upload_plan_filters_and_prefixes_keys() { - let temp = tempfile::tempdir().unwrap(); - let root = temp.path(); - fs::create_dir_all(root.join("nested")).unwrap(); - fs::write(root.join("keep.txt"), "keep").unwrap(); - fs::write(root.join("nested").join("skip.map"), "skip").unwrap(); - fs::write(root.join("nested").join("keep.js"), "keep").unwrap(); - - let plan = directory_upload_plan("static", root, |relative| { - relative.extension().and_then(OsStr::to_str) != Some("map") - }) - .unwrap(); - - assert_eq!( - plan.iter() - .map(|item| item.key.as_str()) - .collect::>(), - vec!["static/keep.txt", "static/nested/keep.js"] - ); - assert_eq!( - plan[0].content_type.as_deref(), - Some("text/plain; charset=utf-8") - ); - assert_eq!( - plan[1].content_type.as_deref(), - Some("application/javascript; charset=utf-8") - ); - } - #[test] fn s3_content_type_for_key_covers_browser_module_assets() { assert_eq!( diff --git a/tools/ci/src/desktop.rs b/tools/ci/src/desktop.rs index e879fbbef..0d8a74569 100644 --- a/tools/ci/src/desktop.rs +++ b/tools/ci/src/desktop.rs @@ -1,23 +1,21 @@ // SPDX-License-Identifier: AGPL-3.0-or-later use crate::common::{ - CalverEnv, CommandSpec, S3UploadPlanItem, append_github_env, append_github_output, - append_github_path, capture, collect_files, command_succeeds, copy_dir_contents, count_files, - count_files_min_depth, directory_upload_plan, download_file, download_s3_prefix, env_bool, - env_string, get_s3_object_bytes, join_s3_key, list_s3_keys, output_bytes, output_text, - parse_bool, parse_version_instant, path_to_s3_key, remove_dir_if_exists, remove_file_if_exists, - require_any_env, require_env, require_home, resolve_calver, run_command, runner_temp, - s3_client, title_case, trim_option, upload_directory_to_s3, upload_s3_plan_append_only, - upload_s3_plan_overwrite, + CalverEnv, CommandSpec, append_github_env, append_github_output, append_github_path, capture, + collect_files, command_succeeds, copy_dir_contents, count_files, download_file, env_bool, + env_string, output_bytes, output_text, parse_bool, path_to_s3_key, remove_dir_if_exists, + remove_file_if_exists, require_any_env, require_env, require_home, resolve_calver, run_command, + runner_temp, title_case, trim_option, }; use crate::functions::write_json_pretty; use crate::release::{ DESKTOP_RELEASE_DESCRIPTOR_SCHEMA_VERSION, DesktopReleaseAsset, DesktopReleaseDescriptor, - desktop_release_asset_name, desktop_release_descriptor_filename, desktop_release_product, + desktop_release_asset_name, desktop_release_coordinates, desktop_release_descriptor_filename, + desktop_release_product, desktop_release_shipped_formats, + desktop_release_update_payload_suffix, desktop_release_updater_feeds, validate_desktop_release_descriptor, }; use anyhow::{Context, Result, anyhow, bail, ensure}; -use aws_sdk_s3::Client as S3Client; use chrono::Utc; use clap::{Args, ValueEnum}; use serde::{Deserialize, Serialize}; @@ -35,10 +33,9 @@ use tempfile::TempDir; use walkdir::WalkDir; use zip::write::SimpleFileOptions; -const PUBLIC_DL_BASE: &str = "https://api.fluxer.app/dl"; +const PACKAGE_ORIGIN_BASE: &str = "https://pkgs.fluxer.com"; const PNPM_VERSION: &str = "12.4.2"; const RUST_TOOLCHAIN: &str = "1.98.1"; -const DEFAULT_DESKTOP_VARIANT: &str = "default"; const LINUX_PIPEWIRE_VERSION: &str = "0.3.65"; const LINUX_PIPEWIRE_SOURCE_SHA256: &str = "bb76f938136d0ce8c35bffa99e002dc2dbaeab5e14c6c34154e7f750013d1d6b"; @@ -47,7 +44,7 @@ const LINUX_LIBFIDO2_SOURCE_SHA256: &str = "8c2b6fb279b5b42e9ac92ade71832e485852647b53607c43baaafbbcecea04e4"; pub(crate) const MACOS_UNIVERSAL_ARCH: &str = "universal"; const MACOS_MINIMUM_SYSTEM_VERSION: &str = "13.0"; -const WINDOWS_GAME_CAPTURE_DESKTOP_VARIANT: &str = "windows-game-capture"; +const DESKTOP_PAYLOAD_PREFIX: &str = "desktop"; #[derive(Debug, Args, Clone)] pub struct BuildDesktopArgs { @@ -56,8 +53,6 @@ pub struct BuildDesktopArgs { #[arg(long)] channel: Option, #[arg(long)] - test_build: Option, - #[arg(long)] skip_targets: Option, #[arg(long)] skip_windows: Option, @@ -113,17 +108,9 @@ enum DesktopStep { NormaliseUpdaterYaml, GenerateChecksumsUnix, GenerateChecksumsWindows, - UploadHandoff, - DownloadHandoff, - CleanupHandoff, + StageHandoff, BuildPayload, PrepareReleaseAssets, - UploadReleaseAssets, - DownloadReleaseAssets, - UploadPayload, - PublishReleaseDescriptor, - PublishReleaseMarker, - PublishPayloadMetadata, BuildSummary, } @@ -131,7 +118,6 @@ enum DesktopStep { struct Platform { platform: &'static str, arch: &'static str, - desktop_variant: &'static str, os: &'static str, electron_arch: &'static str, } @@ -140,35 +126,30 @@ const PLATFORMS: &[Platform] = &[ Platform { platform: "windows", arch: "x64", - desktop_variant: DEFAULT_DESKTOP_VARIANT, os: "windows-2025", electron_arch: "x64", }, Platform { platform: "windows", arch: "arm64", - desktop_variant: DEFAULT_DESKTOP_VARIANT, os: "windows-2025", electron_arch: "arm64", }, Platform { platform: "macos", arch: MACOS_UNIVERSAL_ARCH, - desktop_variant: DEFAULT_DESKTOP_VARIANT, os: "fluxer-desktop-macos-arm64", electron_arch: MACOS_UNIVERSAL_ARCH, }, Platform { platform: "linux", arch: "x64", - desktop_variant: DEFAULT_DESKTOP_VARIANT, os: "ubuntu-22.04", electron_arch: "x64", }, Platform { platform: "linux", arch: "arm64", - desktop_variant: DEFAULT_DESKTOP_VARIANT, os: "ubuntu-22.04-arm", electron_arch: "arm64", }, @@ -183,12 +164,7 @@ pub async fn run(args: BuildDesktopArgs) -> Result<()> { .filter(|value| !value.is_empty()) .or_else(|| env_string("CHANNEL")) .unwrap_or_else(|| "stable".to_string()); - let test_build = args - .test_build - .as_deref() - .map(parse_bool) - .unwrap_or_else(|| env_bool("TEST_BUILD")); - set_metadata_step(&channel, test_build) + set_metadata_step(&channel) } DesktopStep::SetMatrix => set_matrix_step(&args), DesktopStep::WindowsPaths => windows_paths_step().await, @@ -244,17 +220,9 @@ pub async fn run(args: BuildDesktopArgs) -> Result<()> { ArtifactChecksumKind::Extension("nupkg"), ArtifactChecksumKind::Extension("zip"), ]), - DesktopStep::UploadHandoff => upload_handoff_step(false).await, - DesktopStep::DownloadHandoff => download_handoff_step().await, - DesktopStep::CleanupHandoff => cleanup_handoff_step().await, + DesktopStep::StageHandoff => stage_handoff_step(), DesktopStep::BuildPayload => build_payload_step(), DesktopStep::PrepareReleaseAssets => prepare_release_assets_step(), - 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::PublishPayloadMetadata => publish_payload_metadata_step().await, DesktopStep::BuildSummary => build_summary_step(), } } @@ -267,7 +235,7 @@ fn calver_env_from_process() -> CalverEnv { } } -fn set_metadata_step(channel: &str, test_build: bool) -> Result<()> { +fn set_metadata_step(channel: &str) -> Result<()> { let version = resolve_calver(&calver_env_from_process(), Utc::now())?; let pub_date = Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); let build_channel = if channel == "canary" { @@ -275,11 +243,6 @@ fn set_metadata_step(channel: &str, test_build: bool) -> Result<()> { } else { "stable" }; - let s3_prefix = if test_build { - "desktop-test" - } else { - "desktop" - }; let source_sha = resolve_source_sha()?; append_github_output(&[ @@ -287,8 +250,6 @@ fn set_metadata_step(channel: &str, test_build: bool) -> Result<()> { ("pub_date", pub_date.as_str()), ("channel", channel), ("build_channel", build_channel), - ("test_build", if test_build { "true" } else { "false" }), - ("s3_prefix", s3_prefix), ("source_sha", source_sha.as_str()), ]) } @@ -451,52 +412,11 @@ fn skip_platform( fn platform_json(platform: Platform) -> String { format!( - "{{\"platform\":\"{}\",\"arch\":\"{}\",\"desktop_variant\":\"{}\",\"os\":\"{}\",\"electron_arch\":\"{}\"}}", - platform.platform, - platform.arch, - platform.desktop_variant, - platform.os, - platform.electron_arch + "{{\"platform\":\"{}\",\"arch\":\"{}\",\"os\":\"{}\",\"electron_arch\":\"{}\"}}", + platform.platform, platform.arch, platform.os, platform.electron_arch ) } -fn desktop_variant_from_env() -> Result { - let variant = env::var("DESKTOP_VARIANT") - .ok() - .filter(|value| !value.is_empty()) - .unwrap_or_else(|| DEFAULT_DESKTOP_VARIANT.to_string()); - ensure_valid_desktop_variant(&variant)?; - Ok(variant) -} - -fn ensure_valid_desktop_variant(variant: &str) -> Result<()> { - ensure!( - matches!( - variant, - DEFAULT_DESKTOP_VARIANT | WINDOWS_GAME_CAPTURE_DESKTOP_VARIANT - ), - "Unknown desktop variant: {variant}" - ); - Ok(()) -} - -fn ensure_platform_supports_desktop_variant(platform: &str, variant: &str) -> Result<()> { - ensure_valid_desktop_variant(variant)?; - ensure!( - variant == DEFAULT_DESKTOP_VARIANT || platform == "windows", - "Desktop variant {variant} is only supported for Windows artifacts." - ); - Ok(()) -} - -fn desktop_variant_path_segment(variant: &str) -> Option<&str> { - if variant == DEFAULT_DESKTOP_VARIANT { - None - } else { - Some(variant) - } -} - fn workspace_dir() -> PathBuf { env::var("GITHUB_WORKSPACE") .map(PathBuf::from) @@ -3493,76 +3413,36 @@ fn checksum_kind_matches(kind: ArtifactChecksumKind, name: &str) -> bool { } } -async fn upload_handoff_step(signed_windows_artifacts: bool) -> Result<()> { - let client = s3_client(None).await?; - let bucket = require_env("S3_BUCKET")?; - let prefix = require_env("DESKTOP_HANDOFF_PREFIX")?; +fn stage_handoff_step() -> Result<()> { let build_channel = require_env("BUILD_CHANNEL")?; let platform = require_any_env(&["DESKTOP_PLATFORM", "PLATFORM"])?; let arch = require_any_env(&["DESKTOP_ARCH", "ARCH"])?; - let desktop_variant = desktop_variant_from_env()?; - ensure_platform_supports_desktop_variant(&platform, &desktop_variant)?; let staging = Path::new("upload_staging"); ensure!(staging.exists(), "upload_staging is missing."); let artifact_count = count_files(staging)?; ensure!(artifact_count > 0, "upload_staging is empty."); - let artifact_name = handoff_artifact_name( - &build_channel, - &platform, - &arch, - &desktop_variant, - signed_windows_artifacts, - ); - let artifact_prefix = join_s3_key(&prefix, &artifact_name); - println!("Uploading {artifact_count} desktop artifact file(s) to {artifact_prefix}"); - upload_directory_to_s3(&client, &bucket, &artifact_prefix, staging, |_| true).await?; - Ok(()) + let artifact_name = handoff_artifact_name(&build_channel, &platform, &arch, false); + println!("Staging {artifact_count} desktop artifact file(s) as {artifact_name}"); + append_github_output(&[("artifact_name", artifact_name.as_str())]) } fn handoff_artifact_name( build_channel: &str, platform: &str, arch: &str, - desktop_variant: &str, signed_windows_artifacts: bool, ) -> String { - let variant_suffix = desktop_variant_path_segment(desktop_variant) - .map(|variant| format!("-{variant}")) - .unwrap_or_default(); let signed_suffix = if signed_windows_artifacts && platform == "windows" { "-signed" } else { "" }; - format!("fluxer-desktop-{build_channel}-{platform}-{arch}{variant_suffix}{signed_suffix}") -} - -async fn download_handoff_step() -> Result<()> { - let client = s3_client(None).await?; - let bucket = require_env("S3_BUCKET")?; - let prefix = require_env("DESKTOP_HANDOFF_PREFIX")?; - let artifacts = Path::new("artifacts"); - remove_dir_if_exists(artifacts)?; - fs::create_dir_all(artifacts)?; - println!("Downloading desktop handoff artifacts from {prefix}"); - download_s3_prefix(&client, &bucket, &prefix, artifacts).await?; - ensure!( - count_files_min_depth(artifacts, 2)? > 0, - "No desktop handoff files were downloaded." - ); - println!("Downloaded handoff artifact tree:"); - print_tree(artifacts, 3) -} - -async fn cleanup_handoff_step() -> Result<()> { - println!("S3 handoff cleanup skipped: CI S3 writes are append-only and never delete objects."); - Ok(()) + format!("fluxer-desktop-{build_channel}-{platform}-{arch}{signed_suffix}") } fn build_payload_step() -> Result<()> { - let s3_prefix = require_env("S3_DESKTOP_PREFIX")?; - let payload_root = Path::new("s3_payload").join(&s3_prefix); + let payload_root = Path::new("payload_tree").join(DESKTOP_PAYLOAD_PREFIX); remove_dir_if_exists(&payload_root)?; fs::create_dir_all(&payload_root)?; @@ -3570,7 +3450,13 @@ fn build_payload_step() -> Result<()> { let version = require_env("VERSION")?; let pub_date = require_env("PUB_DATE")?; let artifacts = Path::new("artifacts"); - for (dir, identity) in payload_artifact_dirs(artifacts, &channel)? { + let artifact_dirs = payload_artifact_dirs(artifacts, &channel)?; + ensure!( + !artifact_dirs.is_empty(), + "No desktop build artifacts were downloaded into {}", + artifacts.display() + ); + for (dir, identity) in artifact_dirs { let platform = match identity.platform.as_str() { "windows" => "win32", "macos" => "darwin", @@ -3581,13 +3467,10 @@ fn build_payload_step() -> Result<()> { } }; for published_arch in published_arches(platform, &identity.arch) { - let mut dest = payload_root + let dest = payload_root .join(&channel) .join(platform) .join(published_arch); - if let Some(segment) = desktop_variant_path_segment(&identity.desktop_variant) { - dest = dest.join(segment); - } fs::create_dir_all(&dest)?; copy_dir_contents(&dir, &dest)?; let manifest = build_desktop_manifest( @@ -3596,13 +3479,12 @@ fn build_payload_step() -> Result<()> { channel: channel.clone(), platform: platform.to_string(), arch: published_arch.to_string(), - desktop_variant: identity.desktop_variant.clone(), version: version.clone(), pub_date: pub_date.clone(), }, )?; if platform == "darwin" { - write_macos_releases(&dest, &s3_prefix, &channel, &manifest)?; + write_macos_releases(&dest, &channel, &manifest)?; } write_json_pretty(&dest.join("manifest.json"), &manifest)?; } @@ -3616,27 +3498,17 @@ fn prepare_release_assets_step() -> Result<()> { let channel = require_env("CHANNEL")?; let version = require_env("VERSION")?; 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 payload_root = Path::new("payload_tree") + .join(DESKTOP_PAYLOAD_PREFIX) + .join(&channel); let release_assets = Path::new("release_assets"); remove_dir_if_exists(release_assets)?; fs::create_dir_all(release_assets)?; 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"), - ] { + DesktopReleaseAssetBuilder::new(&channel, &version, product, release_assets); + for (platform, arch) in desktop_release_coordinates() { let dir = payload_root.join(platform).join(arch); ensure!( dir.is_dir(), @@ -3653,17 +3525,14 @@ fn prepare_release_assets_step() -> Result<()> { manifest.channel == channel && manifest.platform == platform && manifest.arch == arch - && manifest.version == version - && manifest.variant.is_none(), + && manifest.version == version, "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 expected_kinds = desktop_release_shipped_formats(platform)? + .iter() + .copied() + .collect::>(); let actual_kinds = manifest .files .keys() @@ -3702,32 +3571,26 @@ fn prepare_release_assets_step() -> Result<()> { } fn desktop_updater_release_files(dir: &Path, platform: &str) -> Result> { - 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)? + let mut files = desktop_release_updater_feeds(platform)? + .iter() + .map(|name| dir.join(name)) + .collect::>(); + if let Some(suffix) = desktop_release_update_payload_suffix(platform)? { + let payloads = collect_files(dir)? .into_iter() .filter(|path| { path.file_name() .and_then(OsStr::to_str) - .is_some_and(|name| name.ends_with("-full.nupkg")) + .is_some_and(|name| name.ends_with(suffix)) }) .collect::>(); ensure!( - nupkgs.len() == 1, - "Expected one Windows full update package in {}, found {}", + payloads.len() == 1, + "Expected one {suffix} desktop update payload in {}, found {}", dir.display(), - nupkgs.len() + payloads.len() ); - files.push(nupkgs[0].clone()); + files.push(payloads[0].clone()); } for path in &files { ensure!( @@ -3740,7 +3603,6 @@ fn desktop_updater_release_files(dir: &Path, platform: &str) -> Result { - s3_prefix: &'a str, channel: &'a str, version: &'a str, product: &'a str, @@ -3752,15 +3614,8 @@ struct DesktopReleaseAssetBuilder<'a> { } impl<'a> DesktopReleaseAssetBuilder<'a> { - fn new( - s3_prefix: &'a str, - channel: &'a str, - version: &'a str, - product: &'a str, - release_assets: &'a Path, - ) -> Self { + fn new(channel: &'a str, version: &'a str, product: &'a str, release_assets: &'a Path) -> Self { Self { - s3_prefix, channel, version, product, @@ -3803,8 +3658,8 @@ impl<'a> DesktopReleaseAssetBuilder<'a> { ); } let storage_key = format!( - "{}/{}/{platform}/{arch}/{source_name}", - self.s3_prefix, self.channel + "{DESKTOP_PAYLOAD_PREFIX}/{}/{platform}/{arch}/{source_name}", + self.channel ); ensure!( self.storage_keys.insert(storage_key.clone()), @@ -3857,43 +3712,10 @@ impl<'a> DesktopReleaseAssetBuilder<'a> { } } -async fn upload_release_assets_step() -> Result<()> { - let client = s3_client(None).await?; - let bucket = require_env("S3_BUCKET")?; - let prefix = require_env("DESKTOP_RELEASE_ASSETS_PREFIX")?; - let release_assets = Path::new("release_assets"); - ensure!( - release_assets.is_dir(), - "GitHub release asset directory is missing" - ); - ensure!( - count_files(release_assets)? > 0, - "GitHub release asset directory is empty" - ); - upload_directory_to_s3(&client, &bucket, &prefix, release_assets, |_| true).await -} - -async fn download_release_assets_step() -> Result<()> { - let client = s3_client(None).await?; - let bucket = require_env("S3_BUCKET")?; - let prefix = require_env("DESKTOP_RELEASE_ASSETS_PREFIX")?; - let release_assets = Path::new("release_assets"); - remove_dir_if_exists(release_assets)?; - fs::create_dir_all(release_assets)?; - download_s3_prefix(&client, &bucket, &prefix, release_assets).await?; - ensure!( - count_files(release_assets)? > 0, - "No GitHub release assets were downloaded from {prefix}" - ); - println!("Downloaded GitHub release asset tree:"); - print_tree(release_assets, 2) -} - #[derive(Debug, Clone, PartialEq, Eq)] struct ArtifactIdentity { platform: String, arch: String, - desktop_variant: String, signed: bool, } @@ -3904,15 +3726,10 @@ fn parse_artifact_dir_name(base: &str, channel: &str) -> Option Result> { - let mut selected = BTreeMap::<(String, String, String), (PathBuf, ArtifactIdentity)>::new(); + let mut selected = BTreeMap::<(String, String), (PathBuf, ArtifactIdentity)>::new(); if !artifacts.exists() { return Ok(Vec::new()); } @@ -3942,11 +3759,7 @@ fn payload_artifact_dirs( continue; }; - let key = ( - identity.platform.clone(), - identity.arch.clone(), - identity.desktop_variant.clone(), - ); + let key = (identity.platform.clone(), identity.arch.clone()); match selected.get(&key) { Some((_, current)) if current.signed && !identity.signed => {} Some((_, current)) if !current.signed && identity.signed => { @@ -3967,7 +3780,6 @@ struct PayloadManifestInput { channel: String, platform: String, arch: String, - desktop_variant: String, version: String, pub_date: String, } @@ -3977,8 +3789,6 @@ struct DesktopManifest { channel: String, platform: String, arch: String, - #[serde(skip_serializing_if = "Option::is_none")] - variant: Option, version: String, pub_date: String, #[serde(skip_serializing_if = "Option::is_none")] @@ -4012,7 +3822,6 @@ fn build_desktop_manifest(dest: &Path, input: &PayloadManifestInput) -> Result Vec<&'static str> { } } -fn write_macos_releases( - dest: &Path, - s3_prefix: &str, - channel: &str, - manifest: &DesktopManifest, -) -> Result<()> { +fn write_macos_releases(dest: &Path, channel: &str, manifest: &DesktopManifest) -> Result<()> { let Some(zip) = manifest.files.get("zip") else { println!( "No .zip found for macOS {} in {} (auto-update requires zip artifacts).", @@ -4120,13 +3924,11 @@ fn write_macos_releases( return Ok(()); }; let url = format!( - "{PUBLIC_DL_BASE}/{s3_prefix}/{channel}/{}/{}/{}/{}", + "{PACKAGE_ORIGIN_BASE}/{DESKTOP_PAYLOAD_PREFIX}/{channel}/{}/{}/{}", manifest.platform, manifest.arch, - zip.filename(), - "" + zip.filename() ); - let url = url.trim_end_matches('/').to_string(); let releases = json!({ "currentRelease": manifest.version, "releases": [{ @@ -4145,352 +3947,24 @@ fn write_macos_releases( Ok(()) } -async fn upload_payload_step() -> Result<()> { - let client = s3_client(None).await?; - let s3_prefix = require_env("S3_DESKTOP_PREFIX")?; - let bucket = require_env("S3_BUCKET")?; - let payload_root = Path::new("s3_payload").join(&s3_prefix); - let test_build = env_bool("TEST_BUILD"); - let metadata_prefix = if should_defer_payload_metadata(test_build) { - Some(require_env("DESKTOP_METADATA_PREFIX")?) - } else { - None - }; - - println!("Uploading desktop binaries and checksums first (prefix: {s3_prefix})..."); - run_payload_upload( - &client, - &bucket, - payload_binary_upload(&s3_prefix, &payload_root, test_build)?, - ) - .await?; - match metadata_prefix.as_deref() { - Some(prefix) => println!( - "Holding manifests and updater metadata in {prefix} until the GitHub release is ready..." - ), - None => println!( - "Uploading manifests and updater metadata last, overwriting the previous release feed..." - ), - } - run_payload_upload( - &client, - &bucket, - payload_metadata_upload(&s3_prefix, &payload_root, metadata_prefix.as_deref())?, - ) - .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 publish_payload_metadata_step() -> Result<()> { - let client = s3_client(None).await?; - let s3_prefix = require_env("S3_DESKTOP_PREFIX")?; - let bucket = require_env("S3_BUCKET")?; - let metadata_prefix = require_env("DESKTOP_METADATA_PREFIX")?; - let metadata_root = Path::new("payload_metadata"); - remove_dir_if_exists(metadata_root)?; - fs::create_dir_all(metadata_root)?; - download_s3_prefix(&client, &bucket, &metadata_prefix, metadata_root).await?; - ensure!( - count_files(metadata_root)? > 0, - "No desktop payload metadata was downloaded from {metadata_prefix}" - ); - let version = require_env("VERSION")?; - for key in release_feed_manifest_keys(&s3_prefix, metadata_root)? { - let live = read_optional_s3_object(&client, &bucket, &key).await?; - ensure_release_feed_not_superseded(&key, live.as_deref(), &version)?; - } - println!( - "Uploading manifests and updater metadata last, overwriting the previous release feed..." - ); - run_payload_upload( - &client, - &bucket, - payload_metadata_upload(&s3_prefix, metadata_root, None)?, - ) - .await -} - -fn release_feed_manifest_keys(s3_prefix: &str, metadata_root: &Path) -> Result> { - let mut keys = Vec::new(); - for file in collect_files(metadata_root)? { - let Ok(relative) = file.strip_prefix(metadata_root) else { - continue; - }; - if relative.file_name().and_then(OsStr::to_str) == Some("manifest.json") { - keys.push(join_s3_key(s3_prefix, &path_to_s3_key(relative))); - } - } - Ok(keys) -} - -async fn read_optional_s3_object( - client: &S3Client, - bucket: &str, - key: &str, -) -> Result>> { - let listed = list_s3_keys(client, bucket, key).await?; - if !listed.iter().any(|candidate| candidate == key) { - return Ok(None); - } - Ok(Some( - get_s3_object_bytes(client, bucket, key).await?.to_vec(), - )) -} - -fn live_release_feed_version(manifest: &[u8]) -> Option { - let value: Value = serde_json::from_slice(manifest).ok()?; - Some(value.get("version")?.as_str()?.to_string()) -} - -fn ensure_release_feed_not_superseded( - key: &str, - live_manifest: Option<&[u8]>, - version: &str, -) -> Result<()> { - let publishing = parse_version_instant(version)?; - let Some(live_manifest) = live_manifest else { - println!("Supersede check skipped for {key}: nothing is published there yet"); - return Ok(()); - }; - let Some(live_version) = live_release_feed_version(live_manifest) else { - println!("Supersede check skipped for {key}: the live manifest carries no version string"); - return Ok(()); - }; - let Ok(live) = parse_version_instant(&live_version) else { - println!( - "Supersede check skipped for {key}: live version {live_version} is not a build version" - ); - return Ok(()); - }; - ensure!( - live <= publishing, - "Refusing to publish desktop update feeds for {version}: {key} already serves newer release {live_version}" - ); - println!( - "Supersede check passed for {key}: live release {live_version} is not newer than {version}" - ); - Ok(()) -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct PayloadUpload { - prefix: String, - plan: Vec, - overwrite_existing: bool, -} - -fn payload_binary_upload( - s3_prefix: &str, - payload_root: &Path, - test_build: bool, -) -> Result { - Ok(PayloadUpload { - prefix: s3_prefix.to_string(), - plan: desktop_payload_upload_plan(s3_prefix, payload_root, |relative| { - !is_payload_metadata_key(relative) - })?, - overwrite_existing: should_overwrite_payload(s3_prefix, test_build), - }) -} - -fn payload_metadata_upload( - s3_prefix: &str, - payload_root: &Path, - handoff_prefix: Option<&str>, -) -> Result { - match handoff_prefix { - Some(prefix) => Ok(PayloadUpload { - prefix: prefix.to_string(), - plan: directory_upload_plan(prefix, payload_root, is_payload_metadata_key)?, - overwrite_existing: false, - }), - None => Ok(PayloadUpload { - prefix: s3_prefix.to_string(), - plan: desktop_payload_upload_plan(s3_prefix, payload_root, is_payload_metadata_key)?, - overwrite_existing: true, - }), - } -} - -async fn run_payload_upload(client: &S3Client, bucket: &str, upload: PayloadUpload) -> Result<()> { - let PayloadUpload { - prefix, - plan, - overwrite_existing, - } = upload; - if overwrite_existing { - let stats = upload_s3_plan_overwrite(client, bucket, plan).await?; - println!( - "Overwrite upload complete for s3://{bucket}/{prefix}: uploaded {}", - stats.uploaded - ); - } else { - let stats = upload_s3_plan_append_only(client, bucket, plan).await?; - println!( - "Append-only upload complete for s3://{bucket}/{prefix}: uploaded {}, skipped existing {}", - stats.uploaded, stats.skipped_existing - ); - } - Ok(()) -} - -fn desktop_payload_upload_plan( - s3_prefix: &str, - payload_root: &Path, - include: F, -) -> Result> -where - F: Fn(&Path) -> bool, -{ - Ok(directory_upload_plan(s3_prefix, payload_root, include)? - .into_iter() - .map(|item| { - let cache_control = desktop_object_cache_control(&item.key); - item.with_cache_control(cache_control) - }) - .collect()) -} - -pub(crate) const MUTABLE_DOWNLOAD_CACHE_CONTROL: &str = "public, max-age=300"; -pub(crate) const VERSIONED_ARTIFACT_CACHE_CONTROL: &str = "public, max-age=31536000"; - -fn desktop_object_cache_control(key: &str) -> &'static str { - if is_versioned_desktop_artifact_key(key) { - VERSIONED_ARTIFACT_CACHE_CONTROL - } else { - MUTABLE_DOWNLOAD_CACHE_CONTROL - } -} - -fn is_versioned_desktop_artifact_key(key: &str) -> bool { - if !key.starts_with("desktop/") { - return false; - } - let Some(filename) = key.rsplit('/').next() else { - return false; - }; - if filename.is_empty() { - return false; - } - !is_payload_metadata_key(Path::new(filename)) -} - -fn should_overwrite_payload(s3_prefix: &str, test_build: bool) -> bool { - test_build && s3_prefix == "desktop-test" -} - -fn should_defer_payload_metadata(test_build: bool) -> bool { - !test_build -} - -fn is_payload_metadata_key(relative: &Path) -> bool { - let name = relative - .file_name() - .and_then(OsStr::to_str) - .unwrap_or_default(); - name == "manifest.json" - || name.ends_with(".yml") - || name.ends_with(".yaml") - || name.starts_with("RELEASES") - || (name.starts_with("releases") && name.ends_with(".json")) - || (name.starts_with("assets") && name.ends_with(".json")) -} - fn build_summary_step() -> Result<()> { let summary = require_env("GITHUB_STEP_SUMMARY")?; - let test_build = env_bool("TEST_BUILD"); let display_channel = env::var("DISPLAY_CHANNEL").unwrap_or_default(); let version = require_env("VERSION")?; - let s3_prefix = require_env("S3_DESKTOP_PREFIX")?; let channel = require_env("CHANNEL")?; let mut file = OpenOptions::new() .create(true) .append(true) .open(&summary) .with_context(|| format!("Failed to open {summary}"))?; - if test_build { - writeln!( - file, - "## Desktop {} Test Upload Complete", - title_case(&display_channel) - )?; - writeln!( - file, - "\n_This is a **test build**. Artifacts were stashed under `{s3_prefix}/` so the API will not promote them as a release._" - )?; - } else { - writeln!( - file, - "## Desktop {} Upload Complete", - title_case(&display_channel) - )?; - } writeln!( file, - "\n**Version:** {version}\n\n**S3 prefix:** {s3_prefix}/{channel}/\n\n**Redirect endpoint shape:** /dl/{s3_prefix}/{channel}/{{plat}}/{{arch}}[/{{variant}}]/{{format}}" + "## Desktop {} Release Assets Ready", + title_case(&display_channel) + )?; + writeln!( + file, + "\n**Version:** {version}\n\n**Download prefix:** {DESKTOP_PAYLOAD_PREFIX}/{channel}/\n\n**Redirect endpoint shape:** /dl/{DESKTOP_PAYLOAD_PREFIX}/{channel}/{{plat}}/{{arch}}/{{format}}" )?; Ok(()) } @@ -4664,71 +4138,9 @@ fn print_tree(root: &Path, max_depth: usize) -> Result<()> { #[cfg(test)] mod tests { use super::*; - use crate::common::{directory_upload_plan, parse_version_instant, s3_directory_prefix}; + use crate::common::parse_version_instant; use chrono::{DateTime, TimeZone, Utc}; - #[test] - fn every_uploaded_desktop_object_carries_a_cache_instruction() { - let temp = tempfile::tempdir().unwrap(); - let root = temp.path().join("desktop").join("stable").join("darwin"); - fs::create_dir_all(root.join("arm64")).unwrap(); - fs::write(root.join("arm64").join("Fluxer-1.2.3-arm64.dmg"), "dmg").unwrap(); - fs::write(root.join("arm64").join("manifest.json"), "{}").unwrap(); - fs::write(root.join("arm64").join("latest-mac.yml"), "version: 1").unwrap(); - - let plan = - desktop_payload_upload_plan("desktop", temp.path().join("desktop").as_path(), |_| true) - .unwrap(); - - assert!( - !plan.is_empty(), - "the sample payload produced no upload plan" - ); - for item in &plan { - assert!( - item.cache_control.is_some(), - "{} would be stored with no cache instruction at all", - item.key - ); - } - } - - #[test] - fn the_stored_lifetime_follows_the_key_not_the_upload_batch() { - assert_eq!( - desktop_object_cache_control("desktop/stable/darwin/arm64/Fluxer-1.2.3-arm64.dmg"), - VERSIONED_ARTIFACT_CACHE_CONTROL - ); - assert_eq!( - desktop_object_cache_control( - "desktop/stable/darwin/arm64/Fluxer-1.2.3-arm64.dmg.sha256" - ), - VERSIONED_ARTIFACT_CACHE_CONTROL - ); - assert_eq!( - desktop_object_cache_control("desktop/stable/darwin/arm64/manifest.json"), - MUTABLE_DOWNLOAD_CACHE_CONTROL, - "the release pointer must stay reachable when it moves" - ); - assert_eq!( - desktop_object_cache_control("desktop/stable/win32/x64/latest.yml"), - MUTABLE_DOWNLOAD_CACHE_CONTROL - ); - assert_eq!( - desktop_object_cache_control("desktop/stable/win32/x64/RELEASES.json"), - MUTABLE_DOWNLOAD_CACHE_CONTROL - ); - assert_eq!( - desktop_object_cache_control("desktop-test/canary/linux/x64/Fluxer-1.2.3.AppImage"), - MUTABLE_DOWNLOAD_CACHE_CONTROL, - "test artifacts are overwritten in place, so they are not immutable" - ); - assert_ne!( - VERSIONED_ARTIFACT_CACHE_CONTROL, MUTABLE_DOWNLOAD_CACHE_CONTROL, - "the two policies collapsed into one, so this test proves nothing" - ); - } - fn dt(year: i32, month: u32, day: u32, hour: u32, minute: u32, second: u32) -> DateTime { Utc.with_ymd_and_hms(year, month, day, hour, minute, second) .single() @@ -4739,7 +4151,6 @@ mod tests { BuildDesktopArgs { step: DesktopStep::SetMatrix, channel: None, - test_build: None, skip_targets: None, skip_windows: Some("false".to_string()), skip_windows_x64: Some("false".to_string()), @@ -4758,13 +4169,6 @@ mod tests { fs::write(path, contents).unwrap(); } - #[test] - fn only_desktop_test_payloads_overwrite_existing_s3_objects() { - assert!(should_overwrite_payload("desktop-test", true)); - assert!(!should_overwrite_payload("desktop", true)); - assert!(!should_overwrite_payload("desktop-test", false)); - } - #[test] fn resolves_explicit_calver_with_precedence() { let calver_env = CalverEnv { @@ -4814,9 +4218,9 @@ mod tests { assert_eq!( selected, vec![ - "{\"platform\":\"windows\",\"arch\":\"arm64\",\"desktop_variant\":\"default\",\"os\":\"windows-2025\",\"electron_arch\":\"arm64\"}", - "{\"platform\":\"linux\",\"arch\":\"x64\",\"desktop_variant\":\"default\",\"os\":\"ubuntu-22.04\",\"electron_arch\":\"x64\"}", - "{\"platform\":\"linux\",\"arch\":\"arm64\",\"desktop_variant\":\"default\",\"os\":\"ubuntu-22.04-arm\",\"electron_arch\":\"arm64\"}", + "{\"platform\":\"windows\",\"arch\":\"arm64\",\"os\":\"windows-2025\",\"electron_arch\":\"arm64\"}", + "{\"platform\":\"linux\",\"arch\":\"x64\",\"os\":\"ubuntu-22.04\",\"electron_arch\":\"x64\"}", + "{\"platform\":\"linux\",\"arch\":\"arm64\",\"os\":\"ubuntu-22.04-arm\",\"electron_arch\":\"arm64\"}", ] ); } @@ -4833,11 +4237,6 @@ mod tests { .count(), 2 ); - assert!( - selected - .iter() - .all(|platform| platform.desktop_variant == DEFAULT_DESKTOP_VARIANT) - ); } #[test] @@ -4854,9 +4253,9 @@ mod tests { assert_eq!( selected, vec![ - "{\"platform\":\"windows\",\"arch\":\"arm64\",\"desktop_variant\":\"default\",\"os\":\"windows-2025\",\"electron_arch\":\"arm64\"}", - "{\"platform\":\"linux\",\"arch\":\"x64\",\"desktop_variant\":\"default\",\"os\":\"ubuntu-22.04\",\"electron_arch\":\"x64\"}", - "{\"platform\":\"linux\",\"arch\":\"arm64\",\"desktop_variant\":\"default\",\"os\":\"ubuntu-22.04-arm\",\"electron_arch\":\"arm64\"}", + "{\"platform\":\"windows\",\"arch\":\"arm64\",\"os\":\"windows-2025\",\"electron_arch\":\"arm64\"}", + "{\"platform\":\"linux\",\"arch\":\"x64\",\"os\":\"ubuntu-22.04\",\"electron_arch\":\"x64\"}", + "{\"platform\":\"linux\",\"arch\":\"arm64\",\"os\":\"ubuntu-22.04-arm\",\"electron_arch\":\"arm64\"}", ] ); } @@ -4886,73 +4285,6 @@ mod tests { assert!(error.to_string().contains("Unknown desktop skip target")); } - #[test] - fn matrix_skip_targets_reject_retired_windows_game_capture_variant() { - for target in [ - WINDOWS_GAME_CAPTURE_DESKTOP_VARIANT, - "windows-game-capture-x64", - "windows-game-capture-arm64", - ] { - let mut args = matrix_args(); - args.skip_targets = Some(target.to_string()); - - let error = selected_platforms(&args).unwrap_err(); - - assert!( - error.to_string().contains("Unknown desktop skip target"), - "{target} should no longer be a recognised skip target" - ); - } - } - - #[test] - fn s3_key_join_and_path_conversion_are_platform_neutral() { - assert_eq!( - join_s3_key("/desktop/", "/canary/linux/"), - "desktop/canary/linux" - ); - assert_eq!( - s3_directory_prefix("/_handoff/desktop/build/"), - "_handoff/desktop/build/" - ); - assert_eq!(join_s3_key("", "manifest.json"), "manifest.json"); - assert_eq!( - path_to_s3_key(Path::new("canary").join("linux").join("x64").as_path()), - "canary/linux/x64" - ); - } - - #[test] - fn upload_plan_splits_payload_metadata_without_s3() { - let temp = tempfile::tempdir().unwrap(); - let root = temp.path(); - write_file(&root.join("canary/linux/x64/Fluxer.AppImage"), "app"); - write_file(&root.join("canary/linux/x64/manifest.json"), "{}"); - write_file(&root.join("canary/darwin/x64/releases.json"), "{}"); - - let binaries = directory_upload_plan("desktop", root, |relative| { - !is_payload_metadata_key(relative) - }) - .unwrap() - .into_iter() - .map(|item| item.key) - .collect::>(); - let metadata = directory_upload_plan("desktop", root, is_payload_metadata_key) - .unwrap() - .into_iter() - .map(|item| item.key) - .collect::>(); - - assert_eq!(binaries, vec!["desktop/canary/linux/x64/Fluxer.AppImage"]); - assert_eq!( - metadata, - vec![ - "desktop/canary/darwin/x64/releases.json", - "desktop/canary/linux/x64/manifest.json", - ] - ); - } - const BUILD_DESKTOP_WORKFLOW: &str = include_str!("../../../.github/workflows/build-desktop.yaml"); @@ -4977,367 +4309,6 @@ mod tests { .collect() } - const PAYLOAD_BINARIES: &[&str] = &[ - "canary/darwin/arm64/Fluxer-Canary-2026.908.173325-mac-universal.dmg", - "canary/darwin/arm64/Fluxer-Canary-2026.908.173325-mac-universal.dmg.sha256", - "canary/darwin/arm64/Fluxer-Canary-2026.908.173325-mac-universal.zip", - "canary/darwin/arm64/Fluxer-Canary-2026.908.173325-mac-universal.zip.blockmap", - "canary/darwin/arm64/Fluxer-Canary-2026.908.173325-mac-universal.zip.sha256", - "canary/darwin/x64/Fluxer-Canary-2026.908.173325-mac-universal.zip", - "canary/darwin/x64/Fluxer-Canary-2026.908.173325-mac-universal.zip.sha256", - "canary/linux/x64/Fluxer-Canary-2026.908.173325-linux-amd64.deb", - "canary/linux/x64/Fluxer-Canary-2026.908.173325-linux-amd64.deb.sha256", - "canary/linux/x64/Fluxer-Canary-2026.908.173325-linux-x64.tar.gz", - "canary/linux/x64/Fluxer-Canary-2026.908.173325-linux-x64.tar.gz.sha256", - "canary/linux/x64/Fluxer-Canary-2026.908.173325-linux-x86_64.AppImage", - "canary/linux/x64/Fluxer-Canary-2026.908.173325-linux-x86_64.AppImage.sha256", - "canary/linux/x64/Fluxer-Canary-2026.908.173325-linux-x86_64.rpm", - "canary/win32/x64/Fluxer-Canary-2026.908.173325-portable-win-x64.zip", - "canary/win32/x64/Fluxer-Canary-2026.908.173325-portable-win-x64.zip.sha256", - "canary/win32/x64/Fluxer-Canary-2026.908.173325-win-x64-full.nupkg", - "canary/win32/x64/Fluxer-Canary-2026.908.173325-win-x64-full.nupkg.sha256", - "canary/win32/x64/Fluxer-Canary-2026.908.173325-win-x64.exe", - "canary/win32/x64/Fluxer-Canary-2026.908.173325-win-x64.exe.sha256", - "canary/win32/x64/windows-game-capture/Fluxer-Canary-2026.908.173325-win-x64-full.nupkg", - "canary/win32/x64/windows-game-capture/Fluxer-Canary-2026.908.173325-win-x64.exe", - ]; - - const PAYLOAD_UPDATE_FEEDS: &[&str] = &[ - "canary/darwin/arm64/RELEASES.json", - "canary/darwin/arm64/latest-mac-arm64.yml", - "canary/darwin/arm64/manifest.json", - "canary/darwin/x64/manifest.json", - "canary/darwin/x64/releases.json", - "canary/linux/x64/latest-linux.yml", - "canary/linux/x64/manifest.json", - "canary/win32/x64/RELEASES", - "canary/win32/x64/assets.win.json", - "canary/win32/x64/manifest.json", - "canary/win32/x64/releases.win.json", - "canary/win32/x64/windows-game-capture/RELEASES", - "canary/win32/x64/windows-game-capture/manifest.json", - "canary/win32/x64/windows-game-capture/releases.win.json", - ]; - - const UPDATE_FEED_HANDOFF_PREFIX: &str = - "_handoff/desktop-metadata/canary/2026.908.173325/35d73eae761836dc72b74a3d58a3bd475104d509"; - - fn write_release_payload(root: &Path) { - for file in PAYLOAD_BINARIES.iter().chain(PAYLOAD_UPDATE_FEEDS) { - write_file(&root.join(file), file); - } - } - - fn live_manifest(version: &str) -> Vec { - serde_json::to_vec(&json!({ - "channel": "canary", - "platform": "win32", - "arch": "x64", - "version": version, - "pub_date": "2026-09-08T17:33:25Z", - "files": {}, - })) - .unwrap() - } - - #[test] - fn payload_key_classification_guard_splits_binaries_from_update_feeds() { - let temp = tempfile::tempdir().unwrap(); - let root = temp.path(); - write_release_payload(root); - let planned_keys = |plan: Vec| { - plan.into_iter() - .map(|item| item.key) - .collect::>() - }; - let expected_keys = |files: &[&str]| { - files - .iter() - .map(|file| format!("desktop/{file}")) - .collect::>() - }; - - assert_eq!( - planned_keys(payload_binary_upload("desktop", root, false).unwrap().plan), - expected_keys(PAYLOAD_BINARIES) - ); - assert_eq!( - planned_keys(payload_metadata_upload("desktop", root, None).unwrap().plan), - expected_keys(PAYLOAD_UPDATE_FEEDS) - ); - } - - #[test] - fn release_builds_keep_update_feeds_out_of_the_live_prefix() { - let temp = tempfile::tempdir().unwrap(); - let root = temp.path(); - write_release_payload(root); - - let binaries = payload_binary_upload("desktop", root, false).unwrap(); - let feeds = payload_metadata_upload( - "desktop", - root, - should_defer_payload_metadata(false).then_some(UPDATE_FEED_HANDOFF_PREFIX), - ) - .unwrap(); - - assert_eq!(binaries.prefix, "desktop"); - assert!(!binaries.overwrite_existing); - assert_eq!(feeds.prefix, UPDATE_FEED_HANDOFF_PREFIX); - assert!(!feeds.overwrite_existing); - assert_eq!(feeds.plan.len(), PAYLOAD_UPDATE_FEEDS.len()); - - let live_feeds = binaries - .plan - .iter() - .chain(&feeds.plan) - .filter(|item| item.key.starts_with("desktop/")) - .filter(|item| is_payload_metadata_key(Path::new(&item.key))) - .map(|item| item.key.clone()) - .collect::>(); - - assert!( - live_feeds.is_empty(), - "the upload job runs before the GitHub release exists, so it must write no live update feed: {live_feeds:?}" - ); - } - - #[test] - fn the_handoff_path_stores_every_update_feed_exactly_like_the_direct_path() { - let temp = tempfile::tempdir().unwrap(); - let payload_root = temp.path().join("s3_payload").join("desktop"); - write_release_payload(&payload_root); - - let direct = payload_metadata_upload("desktop", &payload_root, None).unwrap(); - let staged = - payload_metadata_upload("desktop", &payload_root, Some(UPDATE_FEED_HANDOFF_PREFIX)) - .unwrap(); - - let metadata_root = temp.path().join("payload_metadata"); - let list_prefix = s3_directory_prefix(UPDATE_FEED_HANDOFF_PREFIX); - for item in &staged.plan { - write_file( - &metadata_root.join(item.key.strip_prefix(&list_prefix).unwrap()), - &fs::read_to_string(&item.path).unwrap(), - ); - } - let restored = payload_metadata_upload("desktop", &metadata_root, None).unwrap(); - let stored = |upload: &PayloadUpload| { - upload - .plan - .iter() - .map(|item| { - ( - item.key.clone(), - item.cache_control.clone(), - item.content_type.clone(), - ) - }) - .collect::>() - }; - - assert_eq!(direct.plan.len(), PAYLOAD_UPDATE_FEEDS.len()); - assert_eq!(stored(&restored), stored(&direct)); - assert_eq!(restored.overwrite_existing, direct.overwrite_existing); - for item in &restored.plan { - assert_eq!( - fs::read_to_string(&item.path).unwrap(), - item.key.strip_prefix("desktop/").unwrap() - ); - } - } - - #[test] - fn the_supersede_guard_checks_every_destination_manifest_including_variants() { - let temp = tempfile::tempdir().unwrap(); - let metadata_root = temp.path(); - write_release_payload(metadata_root); - - assert_eq!( - release_feed_manifest_keys("desktop", metadata_root).unwrap(), - vec![ - "desktop/canary/darwin/arm64/manifest.json", - "desktop/canary/darwin/x64/manifest.json", - "desktop/canary/linux/x64/manifest.json", - "desktop/canary/win32/x64/manifest.json", - "desktop/canary/win32/x64/windows-game-capture/manifest.json", - ] - ); - } - - #[test] - fn a_stale_publish_rerun_refuses_to_roll_back_a_manifest_this_crate_cannot_deserialise() { - let key = "desktop/canary/win32/x64/manifest.json"; - let live: &[u8] = br#"{"version":"2026.909.120000","unexpected_future_field":true}"#; - - let error = ensure_release_feed_not_superseded(key, Some(live), "2026.908.173325") - .unwrap_err() - .to_string(); - - assert!(error.contains("2026.909.120000"), "{error}"); - } - - #[test] - fn a_stale_publish_rerun_refuses_to_roll_the_release_feed_back() { - let key = "desktop/canary/win32/x64/manifest.json"; - - let error = ensure_release_feed_not_superseded( - key, - Some(&live_manifest("2026.909.120000")), - "2026.908.173325", - ) - .unwrap_err() - .to_string(); - - assert!(error.contains("2026.909.120000"), "{error}"); - assert!(error.contains(key), "{error}"); - } - - #[test] - fn publishing_update_feeds_proceeds_for_the_newest_release_and_unreadable_live_feeds() { - let key = "desktop/canary/win32/x64/manifest.json"; - - for live in [ - None, - Some(live_manifest("2026.908.173325")), - Some(live_manifest("2026.907.120000")), - Some(b"not json at all".to_vec()), - Some(b"{}".to_vec()), - Some(live_manifest("not-a-calver")), - ] { - ensure_release_feed_not_superseded(key, live.as_deref(), "2026.908.173325").unwrap(); - } - } - - #[test] - fn the_payload_and_cache_classifiers_agree_on_every_release_feed_filename() { - for name in [ - "manifest.json", - "RELEASES", - "RELEASES.json", - "releases.json", - "releases.win.json", - "assets.win.json", - "latest.yml", - "latest-mac-arm64.yml", - "latest-linux.yml", - "latest-linux.yaml", - "Fluxer-Canary-2026.908.173325-win-x64.exe", - "Fluxer-Canary-2026.908.173325-win-x64-full.nupkg", - "Fluxer-Canary-2026.908.173325-mac-universal.dmg", - "Fluxer-Canary-2026.908.173325-mac-universal.zip.blockmap", - "Fluxer-Canary-2026.908.173325-linux-x86_64.AppImage.sha256", - ] { - let key = format!("desktop/canary/win32/x64/{name}"); - - assert_eq!( - is_versioned_desktop_artifact_key(&key), - !is_payload_metadata_key(Path::new(name)), - "{name} is stored as immutable exactly when it is not an update feed" - ); - } - assert!(is_payload_metadata_key(Path::new("channel.yaml"))); - assert_eq!( - desktop_object_cache_control("desktop/canary/linux/x64/latest-linux.yaml"), - MUTABLE_DOWNLOAD_CACHE_CONTROL - ); - } - - #[test] - fn update_feed_handoff_round_trip_guard_restores_the_release_feed_keys() { - let temp = tempfile::tempdir().unwrap(); - let payload_root = temp.path().join("s3_payload").join("desktop"); - let feeds = [ - "canary/darwin/arm64/RELEASES.json", - "canary/linux/x64/manifest.json", - "canary/win32/x64/RELEASES", - "canary/win32/x64/releases.win.json", - ]; - for file in feeds { - write_file(&payload_root.join(file), file); - } - write_file( - &payload_root - .join("canary/darwin/arm64/Fluxer-Canary-2026.908.173325-mac-universal.zip"), - "zip", - ); - let metadata_prefix = "_handoff/desktop-metadata/canary/2026.908.173325/35d73eae761836dc72b74a3d58a3bd475104d509"; - - let handoff = - directory_upload_plan(metadata_prefix, &payload_root, is_payload_metadata_key).unwrap(); - - assert_eq!( - handoff - .iter() - .map(|item| item.key.clone()) - .collect::>(), - feeds.map(|file| format!("{metadata_prefix}/{file}")) - ); - - let metadata_root = temp.path().join("payload_metadata"); - let list_prefix = s3_directory_prefix(metadata_prefix); - for item in &handoff { - write_file( - &metadata_root.join(item.key.strip_prefix(&list_prefix).unwrap()), - &fs::read_to_string(&item.path).unwrap(), - ); - } - let feed = desktop_payload_upload_plan("desktop", &metadata_root, is_payload_metadata_key) - .unwrap(); - - assert_eq!( - feed.iter().map(|item| item.key.clone()).collect::>(), - feeds.map(|file| format!("desktop/{file}")) - ); - for item in &feed { - assert_eq!( - fs::read_to_string(&item.path).unwrap(), - item.key.strip_prefix("desktop/").unwrap() - ); - assert_eq!( - item.cache_control.as_deref(), - Some(MUTABLE_DOWNLOAD_CACHE_CONTROL), - "{} must keep the short release feed lifetime after the handoff", - item.key - ); - } - } - - #[test] - fn test_builds_keep_uploading_update_feeds_with_the_payload() { - let temp = tempfile::tempdir().unwrap(); - let root = temp.path(); - write_release_payload(root); - - let binaries = payload_binary_upload("desktop-test", root, true).unwrap(); - let feeds = payload_metadata_upload( - "desktop-test", - root, - should_defer_payload_metadata(true).then_some(UPDATE_FEED_HANDOFF_PREFIX), - ) - .unwrap(); - - assert_eq!(feeds.prefix, "desktop-test"); - assert!(binaries.overwrite_existing); - assert!(feeds.overwrite_existing); - assert_eq!(feeds.plan.len(), PAYLOAD_UPDATE_FEEDS.len()); - assert!( - feeds - .plan - .iter() - .all(|item| item.key.starts_with("desktop-test/")), - "a test build still publishes its update feeds with the payload" - ); - assert!( - workflow_job("upload").contains("TEST_BUILD: ${{ needs.meta.outputs.test_build }}"), - "the upload job must hold update feeds back based on the same test_build output" - ); - assert!( - workflow_job("publish_release").contains("needs.meta.outputs.test_build != 'true'"), - "held back update feeds are only published by the publish job, so it must run for every non-test build" - ); - } - #[test] fn every_build_desktop_workflow_step_dispatches_to_a_desktop_step() { let steps = BUILD_DESKTOP_WORKFLOW @@ -5345,7 +4316,7 @@ mod tests { .filter_map(|line| line.trim().strip_prefix("--step ")) .collect::>(); - assert!(steps.contains(&"publish_payload_metadata")); + assert!(steps.contains(&"stage_handoff")); for step in steps { assert!( ::from_str(step, false).is_ok(), @@ -5353,56 +4324,84 @@ mod tests { ); } assert!(matches!( - ::from_str("publish_payload_metadata", false), - Ok(DesktopStep::PublishPayloadMetadata) + ::from_str("stage_handoff", false), + Ok(DesktopStep::StageHandoff) )); } #[test] - fn publish_job_moves_update_feeds_only_after_the_readiness_marker() { - let publish = workflow_job("publish_release"); + fn the_github_release_is_the_only_destination_for_built_artifacts() { + for job in ["build", "upload", "publish_release"] { + let body = workflow_job(job); + for forbidden in [ + "S3_BUCKET", + "S3_ENDPOINT", + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "DOWNLOADS_S3", + "_handoff/", + ] { + assert!( + !body.contains(forbidden), + "{job} must not reference {forbidden} now that the downloads bucket is gone" + ); + } + } + assert_eq!( - workflow_step_names(publish), + workflow_step_names(workflow_job("publish_release")), vec![ "Checkout source", "Set up Rust toolchain (CI helpers)", "Download GitHub release assets", "Create token", "Publish GitHub desktop release", - "Publish GitHub release readiness marker", - "Publish payload metadata to S3", ] ); - let marker = publish.find("--step publish_release_marker").unwrap(); - let feeds = publish.find("--step publish_payload_metadata").unwrap(); - assert!( - marker < feeds, - "update feeds must move only after the readiness marker exists" - ); + } + + #[test] + fn the_job_handoff_travels_as_github_actions_artifacts() { + let build = workflow_job("build"); + assert!(build.contains("--step stage_handoff")); + assert!(build.contains("name: ${{ steps.handoff.outputs.artifact_name }}")); + assert!(build.contains("path: upload_staging")); let upload = workflow_job("upload"); - assert!(upload.contains("--step upload_payload")); assert!( - !upload.contains("--step publish_payload_metadata"), - "the upload job runs before the GitHub release exists" + upload.contains("pattern: fluxer-desktop-${{ needs.meta.outputs.build_channel }}-*"), + "the upload job must collect every build leg for this channel" ); + assert!(upload.contains("path: artifacts")); + assert!(upload.contains("name: fluxer-desktop-release-assets")); - let metadata_prefix = "DESKTOP_METADATA_PREFIX: _handoff/desktop-metadata/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}"; - for (name, job) in [("upload", upload), ("publish_release", publish)] { - assert!( - job.contains(metadata_prefix), - "{name} must key the payload metadata handoff by channel, version and source SHA" - ); - } - for entry in [ - "S3_DESKTOP_PREFIX: ${{ needs.meta.outputs.s3_prefix }}", - "S3_BUCKET: ${{ vars.DOWNLOADS_S3_BUCKET }}", - "AWS_ACCESS_KEY_ID: ${{ secrets.DOWNLOADS_AWS_ACCESS_KEY_ID || secrets.AWS_ACCESS_KEY_ID }}", - ] { - assert!( - publish.contains(entry), - "publish_release must carry {entry}" - ); + let publish = workflow_job("publish_release"); + assert!(publish.contains("name: fluxer-desktop-release-assets")); + assert!(publish.contains("path: release_assets")); + + for job in ["build", "upload", "publish_release"] { + let body = workflow_job(job); + for action in ["actions/upload-artifact@", "actions/download-artifact@"] { + for line in body.lines().filter(|line| line.contains(action)) { + let pin = line.rsplit('@').next().unwrap_or_default(); + assert!( + pin.len() == 40 && pin.bytes().all(|byte| byte.is_ascii_hexdigit()), + "{job} must pin {action} to a full commit sha, found {line:?}" + ); + } + } + for line in body + .lines() + .filter_map(|line| line.trim().strip_prefix("retention-days: ")) + { + let days = line + .parse::() + .expect("retention-days must be a number"); + assert!( + days <= 7, + "{job} keeps a job relay artifact for {days} days, which is how staging piled up before" + ); + } } } @@ -5413,30 +4412,15 @@ mod tests { ArtifactIdentity { platform: "windows".to_string(), arch: "arm64".to_string(), - desktop_variant: DEFAULT_DESKTOP_VARIANT.to_string(), signed: false, } ); - assert_eq!( - parse_artifact_dir_name( - "fluxer-desktop-canary-windows-x64-windows-game-capture-signed", - "canary", - ) - .unwrap(), - ArtifactIdentity { - platform: "windows".to_string(), - arch: "x64".to_string(), - desktop_variant: WINDOWS_GAME_CAPTURE_DESKTOP_VARIANT.to_string(), - signed: true, - } - ); assert!(parse_artifact_dir_name("fluxer-desktop-stable-linux-x64", "canary").is_none()); assert_eq!( parse_artifact_dir_name("fluxer-desktop-canary-windows-x64-signed", "canary").unwrap(), ArtifactIdentity { platform: "windows".to_string(), arch: "x64".to_string(), - desktop_variant: DEFAULT_DESKTOP_VARIANT.to_string(), signed: true, } ); @@ -5445,25 +4429,15 @@ mod tests { #[test] fn handoff_artifact_name_only_marks_signed_windows_uploads() { assert_eq!( - handoff_artifact_name("canary", "windows", "x64", DEFAULT_DESKTOP_VARIANT, true), + handoff_artifact_name("canary", "windows", "x64", true), "fluxer-desktop-canary-windows-x64-signed" ); assert_eq!( - handoff_artifact_name("canary", "linux", "x64", DEFAULT_DESKTOP_VARIANT, true), + handoff_artifact_name("canary", "linux", "x64", true), "fluxer-desktop-canary-linux-x64" ); assert_eq!( - handoff_artifact_name( - "stable", - "windows", - "arm64", - WINDOWS_GAME_CAPTURE_DESKTOP_VARIANT, - false, - ), - "fluxer-desktop-stable-windows-arm64-windows-game-capture" - ); - assert_eq!( - handoff_artifact_name("stable", "windows", "arm64", DEFAULT_DESKTOP_VARIANT, false), + handoff_artifact_name("stable", "windows", "arm64", false), "fluxer-desktop-stable-windows-arm64" ); } @@ -5514,14 +4488,6 @@ export const CHANNEL_DISPLAY_NAME = BUILD_CHANNEL;\n" let artifacts = temp.path(); fs::create_dir_all(artifacts.join("fluxer-desktop-canary-windows-x64")).unwrap(); fs::create_dir_all(artifacts.join("fluxer-desktop-canary-windows-x64-signed")).unwrap(); - fs::create_dir_all( - artifacts.join("fluxer-desktop-canary-windows-x64-windows-game-capture"), - ) - .unwrap(); - fs::create_dir_all( - artifacts.join("fluxer-desktop-canary-windows-x64-windows-game-capture-signed"), - ) - .unwrap(); fs::create_dir_all(artifacts.join("fluxer-desktop-canary-linux-x64")).unwrap(); fs::create_dir_all(artifacts.join("unrelated")).unwrap(); @@ -5544,7 +4510,6 @@ export const CHANNEL_DISPLAY_NAME = BUILD_CHANNEL;\n" ArtifactIdentity { platform: "linux".to_string(), arch: "x64".to_string(), - desktop_variant: DEFAULT_DESKTOP_VARIANT.to_string(), signed: false, }, ), @@ -5553,16 +4518,6 @@ export const CHANNEL_DISPLAY_NAME = BUILD_CHANNEL;\n" ArtifactIdentity { platform: "windows".to_string(), arch: "x64".to_string(), - desktop_variant: DEFAULT_DESKTOP_VARIANT.to_string(), - signed: true, - }, - ), - ( - "fluxer-desktop-canary-windows-x64-windows-game-capture-signed".to_string(), - ArtifactIdentity { - platform: "windows".to_string(), - arch: "x64".to_string(), - desktop_variant: WINDOWS_GAME_CAPTURE_DESKTOP_VARIANT.to_string(), signed: true, }, ), @@ -5587,7 +4542,6 @@ export const CHANNEL_DISPLAY_NAME = BUILD_CHANNEL;\n" channel: "canary".to_string(), platform: "linux".to_string(), arch: "x64".to_string(), - desktop_variant: DEFAULT_DESKTOP_VARIANT.to_string(), version: "2026.520.1".to_string(), pub_date: "2026-05-20T01:02:03Z".to_string(), }, @@ -5616,7 +4570,6 @@ export const CHANNEL_DISPLAY_NAME = BUILD_CHANNEL;\n" channel: "canary".to_string(), platform: "darwin".to_string(), arch: "arm64".to_string(), - variant: None, version: "2026.520.1".to_string(), pub_date: "2026-05-20T01:02:03Z".to_string(), minimum_system_version: Some(MACOS_MINIMUM_SYSTEM_VERSION.to_string()), @@ -5626,14 +4579,14 @@ export const CHANNEL_DISPLAY_NAME = BUILD_CHANNEL;\n" )]), }; - write_macos_releases(temp.path(), "desktop-test", "canary", &manifest).unwrap(); + write_macos_releases(temp.path(), "canary", &manifest).unwrap(); let releases: Value = serde_json::from_str(&fs::read_to_string(temp.path().join("RELEASES.json")).unwrap()) .unwrap(); assert_eq!( releases["releases"][0]["updateTo"]["url"], - "https://api.fluxer.app/dl/desktop-test/canary/darwin/arm64/Fluxer-2026.520.1-arm64.zip" + "https://pkgs.fluxer.com/desktop/canary/darwin/arm64/Fluxer-2026.520.1-arm64.zip" ); assert!(temp.path().join("releases.json").exists()); } @@ -5650,7 +4603,6 @@ export const CHANNEL_DISPLAY_NAME = BUILD_CHANNEL;\n" channel: "stable".to_string(), platform: "darwin".to_string(), arch: "arm64".to_string(), - desktop_variant: DEFAULT_DESKTOP_VARIANT.to_string(), version: "2026.520.1".to_string(), pub_date: "2026-05-20T01:02:03Z".to_string(), }, @@ -5669,7 +4621,6 @@ export const CHANNEL_DISPLAY_NAME = BUILD_CHANNEL;\n" channel: "stable".to_string(), platform: "linux".to_string(), arch: "x64".to_string(), - desktop_variant: DEFAULT_DESKTOP_VARIANT.to_string(), version: "2026.520.1".to_string(), pub_date: "2026-05-20T01:02:03Z".to_string(), }, @@ -5685,7 +4636,6 @@ export const CHANNEL_DISPLAY_NAME = BUILD_CHANNEL;\n" channel: "stable".to_string(), platform: "win32".to_string(), arch: "x64".to_string(), - desktop_variant: DEFAULT_DESKTOP_VARIANT.to_string(), version: "2026.520.1".to_string(), pub_date: "2026-05-20T01:02:03Z".to_string(), }, diff --git a/tools/ci/src/release.rs b/tools/ci/src/release.rs index 34d6680b0..d9b237208 100644 --- a/tools/ci/src/release.rs +++ b/tools/ci/src/release.rs @@ -2,7 +2,7 @@ use crate::common::{CommandSpec, output_text, parse_version_instant, run_command}; use crate::functions::sha256_reader; -use anyhow::{Context, Result, bail, ensure}; +use anyhow::{Context, Result, anyhow, bail, ensure}; use chrono::{DateTime, Utc}; use clap::{Args, Subcommand}; use serde::{Deserialize, Serialize}; @@ -15,8 +15,125 @@ use std::time::Duration; pub(crate) const RELEASE_REPOSITORY: &str = "fluxerapp/fluxer"; const RELEASE_COMPARE_URL: &str = "https://github.com/fluxerapp/fluxer/compare"; 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 = 24; +const DESKTOP_RELEASE_ARCHES: [&str; 2] = ["x64", "arm64"]; + +struct DesktopReleasePlatform { + platform: &'static str, + shipped_formats: &'static [&'static str], + updater_feeds: &'static [&'static str], + update_payload_suffix: Option<&'static str>, + one_build_serves_every_arch: bool, +} + +const DESKTOP_RELEASE_PLATFORMS: [DesktopReleasePlatform; 3] = [ + DesktopReleasePlatform { + platform: "win32", + shipped_formats: &["portable", "setup"], + updater_feeds: &["RELEASES", "releases.win.json", "assets.win.json"], + update_payload_suffix: Some("-full.nupkg"), + one_build_serves_every_arch: false, + }, + DesktopReleasePlatform { + platform: "darwin", + shipped_formats: &["dmg", "zip"], + updater_feeds: &["RELEASES.json", "releases.json"], + update_payload_suffix: None, + one_build_serves_every_arch: true, + }, + DesktopReleasePlatform { + platform: "linux", + shipped_formats: &["appimage", "deb", "rpm", "tar_gz"], + updater_feeds: &[], + update_payload_suffix: None, + one_build_serves_every_arch: false, + }, +]; + +fn desktop_release_platform(platform: &str) -> Result<&'static DesktopReleasePlatform> { + DESKTOP_RELEASE_PLATFORMS + .iter() + .find(|entry| entry.platform == platform) + .ok_or_else(|| anyhow!("Unsupported desktop release platform {platform:?}")) +} + +pub(crate) fn desktop_release_coordinates() -> Vec<(&'static str, &'static str)> { + DESKTOP_RELEASE_PLATFORMS + .iter() + .flat_map(|entry| { + DESKTOP_RELEASE_ARCHES + .iter() + .map(move |arch| (entry.platform, *arch)) + }) + .collect() +} + +pub(crate) fn desktop_release_shipped_formats(platform: &str) -> Result<&'static [&'static str]> { + Ok(desktop_release_platform(platform)?.shipped_formats) +} + +pub(crate) fn desktop_release_updater_feeds(platform: &str) -> Result<&'static [&'static str]> { + Ok(desktop_release_platform(platform)?.updater_feeds) +} + +pub(crate) fn desktop_release_update_payload_suffix( + platform: &str, +) -> Result> { + Ok(desktop_release_platform(platform)?.update_payload_suffix) +} + +fn desktop_release_coordinate_routes(entry: &DesktopReleasePlatform) -> usize { + entry.shipped_formats.len() + + entry.updater_feeds.len() + + usize::from(entry.update_payload_suffix.is_some()) +} + +fn desktop_release_route_inventory() -> BTreeMap { + DESKTOP_RELEASE_PLATFORMS + .iter() + .flat_map(|entry| { + DESKTOP_RELEASE_ARCHES.iter().map(move |arch| { + ( + format!("{}/{arch}", entry.platform), + desktop_release_coordinate_routes(entry), + ) + }) + }) + .collect() +} + +fn desktop_release_route_count() -> usize { + desktop_release_route_inventory().values().sum() +} + +fn desktop_release_asset_count() -> usize { + DESKTOP_RELEASE_PLATFORMS + .iter() + .map(|entry| { + let builds = if entry.one_build_serves_every_arch { + 1 + } else { + DESKTOP_RELEASE_ARCHES.len() + }; + let feeds = entry + .updater_feeds + .iter() + .map(|name| desktop_release_asset_basename(entry.platform, name)) + .collect::>() + .len(); + entry.shipped_formats.len() * builds + + (feeds + usize::from(entry.update_payload_suffix.is_some())) + * DESKTOP_RELEASE_ARCHES.len() + }) + .sum() +} + +fn desktop_release_asset_basename<'a>(platform: &str, storage_filename: &'a str) -> &'a str { + if platform == "darwin" && storage_filename.eq_ignore_ascii_case("releases.json") { + "releases.json" + } else { + storage_filename + } +} #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub(crate) struct DesktopReleaseAsset { @@ -72,12 +189,7 @@ pub(crate) fn desktop_release_asset_name( if storage_filename.starts_with(&release_prefix) { return Ok(storage_filename.to_string()); } - let release_filename = - if platform == "darwin" && storage_filename.eq_ignore_ascii_case("releases.json") { - "releases.json" - } else { - storage_filename - }; + let release_filename = desktop_release_asset_basename(platform, storage_filename); Ok(format!( "{release_prefix}{platform_token}-{arch}-{release_filename}" )) @@ -123,9 +235,10 @@ pub(crate) fn validate_desktop_release_descriptor( ); parse_version_instant(version) .with_context(|| format!("Invalid desktop release descriptor version {version:?}"))?; + let route_count = desktop_release_route_count(); ensure!( - descriptor.assets.len() == DESKTOP_RELEASE_ROUTE_COUNT, - "Desktop release descriptor must contain {DESKTOP_RELEASE_ROUTE_COUNT} routes, found {}", + descriptor.assets.len() == route_count, + "Desktop release descriptor must contain {route_count} routes, found {}", descriptor.assets.len() ); let storage_prefix = format!("desktop/{channel}/"); @@ -216,19 +329,13 @@ pub(crate) fn validate_desktop_release_descriptor( ); } } + let asset_count = desktop_release_asset_count(); ensure!( - release_assets.len() == DESKTOP_RELEASE_ASSET_COUNT, - "Desktop release descriptor must contain {DESKTOP_RELEASE_ASSET_COUNT} unique release assets, found {}", + release_assets.len() == asset_count, + "Desktop release descriptor must contain {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), - ]); + let expected_route_counts = desktop_release_route_inventory(); ensure!( route_counts == expected_route_counts, "Desktop release descriptor route inventory mismatch: expected {expected_route_counts:?}, found {route_counts:?}" @@ -1027,6 +1134,181 @@ mod tests { use super::*; use anyhow::anyhow; + const SAMPLE_CHANNEL: &str = "canary"; + const SAMPLE_VERSION: &str = "2026.913.210037"; + const SAMPLE_SOURCE_SHA: &str = "0123456789abcdef0123456789abcdef01234567"; + + fn sample_storage_filenames(platform: &str, arch: &str, product: &str) -> Vec { + let prefix = format!("{product}-{SAMPLE_VERSION}"); + match platform { + "win32" => vec![ + format!("{prefix}-portable-win-{arch}.zip"), + format!("{prefix}-win-{arch}.exe"), + "RELEASES".to_string(), + "releases.win.json".to_string(), + "assets.win.json".to_string(), + format!("{prefix}-win-{arch}-full.nupkg"), + ], + "darwin" => vec![ + format!("{prefix}-mac-universal.dmg"), + format!("{prefix}-mac-universal.zip"), + "RELEASES.json".to_string(), + "releases.json".to_string(), + ], + "linux" => vec![ + format!("{prefix}-linux-{arch}.AppImage"), + format!("{prefix}-linux-{arch}.deb"), + format!("{prefix}-linux-{arch}.rpm"), + format!("{prefix}-linux-{arch}.tar.gz"), + ], + other => panic!("unsupported desktop release platform {other:?}"), + } + } + + fn sample_descriptor() -> DesktopReleaseDescriptor { + let product = desktop_release_product(SAMPLE_CHANNEL).unwrap(); + let mut contents = BTreeMap::::new(); + let mut assets = Vec::new(); + for (platform, arch) in desktop_release_coordinates() { + for filename in sample_storage_filenames(platform, arch, product) { + let release_asset = desktop_release_asset_name( + SAMPLE_CHANNEL, + SAMPLE_VERSION, + platform, + arch, + &filename, + ) + .unwrap(); + let ordinal = contents.len() as u64 + 1; + let (sha256, size) = contents + .entry(release_asset.clone()) + .or_insert_with(|| (format!("{ordinal:064x}"), ordinal * 1024)) + .clone(); + assets.push(DesktopReleaseAsset { + storage_key: format!("desktop/{SAMPLE_CHANNEL}/{platform}/{arch}/{filename}"), + release_asset, + sha256, + size, + }); + } + } + DesktopReleaseDescriptor { + schema_version: DESKTOP_RELEASE_DESCRIPTOR_SCHEMA_VERSION, + channel: SAMPLE_CHANNEL.to_string(), + version: SAMPLE_VERSION.to_string(), + release_tag: format!("fluxer-desktop-{SAMPLE_CHANNEL}@{SAMPLE_VERSION}"), + source_sha: SAMPLE_SOURCE_SHA.to_string(), + assets, + } + } + + fn validate_sample(descriptor: &DesktopReleaseDescriptor) -> Result<()> { + validate_desktop_release_descriptor( + descriptor, + SAMPLE_CHANNEL, + SAMPLE_VERSION, + SAMPLE_SOURCE_SHA, + ) + } + + #[test] + fn the_release_inventory_is_the_one_the_publisher_stages() { + assert_eq!( + desktop_release_route_inventory(), + 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), + ]) + ); + assert_eq!(desktop_release_route_count(), 28); + assert_eq!(desktop_release_asset_count(), 24); + } + + #[test] + fn a_complete_desktop_release_validates() { + let descriptor = sample_descriptor(); + assert_eq!(descriptor.assets.len(), desktop_release_route_count()); + assert_eq!( + descriptor + .assets + .iter() + .map(|asset| asset.release_asset.as_str()) + .collect::>() + .len(), + desktop_release_asset_count() + ); + validate_sample(&descriptor).unwrap(); + } + + #[test] + fn the_two_macos_feed_names_and_the_universal_build_share_one_release_asset() { + let descriptor = sample_descriptor(); + let asset_for = |storage_key_suffix: &str| { + descriptor + .assets + .iter() + .find(|asset| asset.storage_key.ends_with(storage_key_suffix)) + .map(|asset| asset.release_asset.clone()) + .unwrap() + }; + assert_eq!( + asset_for("darwin/x64/RELEASES.json"), + asset_for("darwin/x64/releases.json") + ); + assert_eq!( + asset_for("darwin/x64/Fluxer-Canary-2026.913.210037-mac-universal.dmg"), + asset_for("darwin/arm64/Fluxer-Canary-2026.913.210037-mac-universal.dmg") + ); + assert_ne!( + asset_for("darwin/x64/RELEASES.json"), + asset_for("darwin/arm64/RELEASES.json") + ); + } + + #[test] + fn a_release_missing_a_route_is_refused() { + let mut descriptor = sample_descriptor(); + descriptor.assets.pop().unwrap(); + assert_eq!( + validate_sample(&descriptor).unwrap_err().to_string(), + "Desktop release descriptor must contain 28 routes, found 27" + ); + } + + #[test] + fn a_release_carrying_an_extra_route_is_refused() { + let mut descriptor = sample_descriptor(); + let extra = DesktopReleaseAsset { + storage_key: format!("desktop/{SAMPLE_CHANNEL}/linux/x64/latest-linux.yml"), + release_asset: format!("Fluxer-Canary-{SAMPLE_VERSION}-linux-x64-latest-linux.yml"), + sha256: format!("{:064x}", 99u64), + size: 4096, + }; + descriptor.assets.push(extra); + assert_eq!( + validate_sample(&descriptor).unwrap_err().to_string(), + "Desktop release descriptor must contain 28 routes, found 29" + ); + } + + #[test] + fn the_release_publishes_no_per_coordinate_manifest() { + for (platform, _) in desktop_release_coordinates() { + assert!( + !desktop_release_updater_feeds(platform) + .unwrap() + .contains(&"manifest.json") + ); + } + for asset in sample_descriptor().assets { + assert!(!asset.storage_key.ends_with("/manifest.json")); + } + } + #[test] fn retry_publish_retries_until_a_publish_succeeds() { let mut calls = 0;