diff --git a/.github/workflows/build-desktop-modules.yaml b/.github/workflows/build-desktop-modules.yaml new file mode 100644 index 000000000..b0da527bf --- /dev/null +++ b/.github/workflows/build-desktop-modules.yaml @@ -0,0 +1,350 @@ +name: build desktop modules + +on: + workflow_dispatch: + inputs: + channel: + description: Release channel to ship renderer modules on. The live shell on that channel stays as it is. + required: true + type: choice + options: + - canary + - stable + default: canary + 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 + default: "" + type: string + allow_shell_drift: + description: Publish even though shell sources changed since the live shell was built. Only when the renderer does not depend on those changes. + required: false + default: false + type: boolean +permissions: + contents: write + id-token: write + actions: read + +concurrency: + group: desktop-modules-${{ inputs.channel }} + cancel-in-progress: false + +env: + CHANNEL: ${{ inputs.channel }} + BUILD_CHANNEL: ${{ inputs.channel == 'canary' && 'canary' || 'stable' }} + +jobs: + meta: + name: Resolve build metadata + runs-on: ubuntu-24.04-arm + environment: desktop-releases + timeout-minutes: 25 + permissions: + contents: read + outputs: + version: ${{ steps.meta.outputs.version }} + pub_date: ${{ steps.meta.outputs.pub_date }} + channel: ${{ steps.meta.outputs.channel }} + build_channel: ${{ steps.meta.outputs.build_channel }} + source_sha: ${{ steps.meta.outputs.source_sha }} + steps: + - name: Checkout source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: main + + - name: Set up Rust toolchain (CI helpers) + uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de + with: + toolchain: "1.98.1" + + - name: Create token + id: create-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 + with: + client-id: ${{ vars.FLUXER_CI_APP_ID }} + private-key: ${{ secrets.FLUXER_CI_APP_KEY }} + owner: fluxerapp + repositories: fluxer + permission-contents: read + - name: Set metadata + id: meta + env: + GH_TOKEN: ${{ steps.create-token.outputs.token }} + FLUXER_BUILD_VERSION: ${{ inputs.build_version }} + run: >- + cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop + --step set_metadata + --channel "${{ inputs.channel }}" + + shared_assets: + name: Build shared renderer assets + needs: + - meta + runs-on: ubuntu-24.04 + environment: desktop-releases + timeout-minutes: 60 + permissions: + contents: read + env: + CHANNEL: ${{ needs.meta.outputs.channel }} + BUILD_CHANNEL: ${{ needs.meta.outputs.build_channel }} + RELEASE_CHANNEL: ${{ needs.meta.outputs.build_channel }} + PUBLIC_RELEASE_CHANNEL: ${{ needs.meta.outputs.build_channel }} + VERSION: ${{ needs.meta.outputs.version }} + BUILD_VERSION: ${{ needs.meta.outputs.version }} + PUBLIC_BUILD_VERSION: ${{ needs.meta.outputs.version }} + SOURCE_SHA: ${{ needs.meta.outputs.source_sha }} + steps: + - name: Checkout CI helpers + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ needs.meta.outputs.source_sha }} + path: _ci + + - name: Checkout source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ needs.meta.outputs.source_sha }} + path: source + + - name: Set up Rust toolchain (renderer wasm) + uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de + with: + toolchain: "1.98.1" + targets: wasm32-unknown-unknown + + - name: Set workdir (Unix) + env: + SUBST_TARGET: ${{ github.workspace }}/source + run: >- + cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop + --step set_workdir_unix + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 + with: + node-version: 26 + + - name: Set up pnpm + run: >- + cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop + --step setup_pnpm + + - name: Resolve pnpm store path (Unix) + run: >- + cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop + --step resolve_pnpm_store_unix + + - name: Cache pnpm store + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 + with: + path: ${{ env.PNPM_STORE_PATH }} + key: ${{ runner.os }}-shared-renderer-pnpm-store-${{ hashFiles('source/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-shared-renderer-pnpm-store- + + - name: Cache cargo registry + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + key: ${{ runner.os }}-shared-renderer-cargo-registry-${{ hashFiles('source/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-shared-renderer-cargo-registry- + + - name: Install dependencies + working-directory: ${{ env.WORKDIR }}/fluxer_desktop + run: >- + cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop + --step install_dependencies + + - name: Update version + working-directory: ${{ env.WORKDIR }}/fluxer_desktop + run: >- + cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop + --step update_version + + - name: Set build channel + working-directory: ${{ env.WORKDIR }}/fluxer_desktop + run: >- + cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop + --step set_build_channel + + - name: Build shared renderer assets + working-directory: ${{ env.WORKDIR }}/fluxer_desktop + run: >- + cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop + --step build_shared_assets + + - name: Prepare shared renderer artifact + run: >- + cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop + --step prepare_shared_assets + + - name: Upload shared renderer artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: desktop-shared-${{ needs.meta.outputs.build_channel }}-${{ needs.meta.outputs.version }}-${{ needs.meta.outputs.source_sha }} + path: source/desktop-shared-assets + if-no-files-found: error + retention-days: 1 + compression-level: 0 + + - name: Split renderer into desktop modules + run: >- + cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop + --step split_modules + + - name: Pack desktop modules + run: >- + cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop + --step pack_modules + + - name: Upload desktop module packages + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: desktop-modules-${{ needs.meta.outputs.build_channel }}-${{ needs.meta.outputs.version }}-${{ needs.meta.outputs.source_sha }} + path: | + source/desktop-modules/classification.json + source/desktop-modules/*/module.json + source/desktop-modules/*/package.br + source/desktop-modules/*/package.br.sha256 + if-no-files-found: error + retention-days: 1 + compression-level: 0 + + assemble: + name: Assemble the modules-only release + if: ${{ !cancelled() && needs.shared_assets.result == 'success' }} + needs: + - meta + - shared_assets + runs-on: ubuntu-24.04-arm + environment: desktop-releases + timeout-minutes: 60 + permissions: + actions: read + contents: read + env: + CHANNEL: ${{ needs.meta.outputs.build_channel }} + VERSION: ${{ needs.meta.outputs.version }} + PUB_DATE: ${{ needs.meta.outputs.pub_date }} + SOURCE_SHA: ${{ needs.meta.outputs.source_sha }} + steps: + - name: Checkout source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ needs.meta.outputs.source_sha }} + fetch-depth: 0 + + - name: Set up Rust toolchain (CI helpers) + uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de + with: + toolchain: "1.98.1" + + - name: Download desktop module packages + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: desktop-modules-${{ needs.meta.outputs.build_channel }}-${{ needs.meta.outputs.version }}-${{ needs.meta.outputs.source_sha }} + path: desktop-modules + + - name: Create token + id: create-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 + with: + client-id: ${{ vars.FLUXER_CI_APP_ID }} + private-key: ${{ secrets.FLUXER_CI_APP_KEY }} + owner: fluxerapp + repositories: fluxer + permission-contents: read + - name: Check the live shell runs these modules + env: + GH_TOKEN: ${{ steps.create-token.outputs.token }} + ALLOW_SHELL_DRIFT: ${{ inputs.allow_shell_drift }} + run: >- + cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop + --step check_shell_drift + + - name: Build the modules-only manifests + run: >- + cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop + --step build_modules_only_manifest + + - name: Prepare GitHub release assets + run: >- + cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop + --step prepare_modules_release_assets + + - name: Upload GitHub release assets + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: fluxer-desktop-modules-release-assets + path: release_assets + if-no-files-found: error + retention-days: 1 + compression-level: 0 + + publish_release: + name: Publish GitHub modules-only release + if: ${{ !cancelled() && needs.assemble.result == 'success' }} + needs: + - meta + - assemble + runs-on: ubuntu-24.04-arm + environment: desktop-releases + timeout-minutes: 60 + permissions: + contents: write + env: + CHANNEL: ${{ needs.meta.outputs.build_channel }} + VERSION: ${{ needs.meta.outputs.version }} + steps: + - name: Checkout source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ needs.meta.outputs.source_sha }} + + - name: Set up Rust toolchain (CI helpers) + uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de + with: + toolchain: "1.98.1" + + - name: Download GitHub release assets + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: fluxer-desktop-modules-release-assets + path: release_assets + + - name: Create token + id: create-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 + with: + client-id: ${{ vars.FLUXER_CI_APP_ID }} + private-key: ${{ secrets.FLUXER_CI_APP_KEY }} + owner: fluxerapp + repositories: fluxer + permission-contents: write + - name: Publish GitHub modules-only release + env: + GH_TOKEN: ${{ steps.create-token.outputs.token }} + CHANNEL: ${{ needs.meta.outputs.build_channel }} + VERSION: ${{ needs.meta.outputs.version }} + SOURCE_SHA: ${{ needs.meta.outputs.source_sha }} + RELEASE_BASELINE_SHA: ${{ vars.RELEASE_BASELINE_SHA }} + run: | + set -euo pipefail + release_args=( + release publish + --component "fluxer-desktop-${CHANNEL}" + --build-version "${VERSION}" + --source-sha "${SOURCE_SHA}" + --previous-sha "${RELEASE_BASELINE_SHA}" + --asset-dir release_assets + ) + if [[ "${CHANNEL}" == "canary" ]]; then + release_args+=(--prerelease) + fi + cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- "${release_args[@]}" diff --git a/tools/ci/src/desktop.rs b/tools/ci/src/desktop.rs index 7f35f599b..f087185ae 100644 --- a/tools/ci/src/desktop.rs +++ b/tools/ci/src/desktop.rs @@ -10,10 +10,10 @@ use crate::common::{ use crate::functions::{sha256_file, write_json_pretty}; use crate::release::{ DESKTOP_RELEASE_DESCRIPTOR_SCHEMA_VERSION, DesktopReleaseAsset, DesktopReleaseDescriptor, - desktop_module_manifest_release_asset_name, desktop_module_manifest_storage_key, - desktop_module_package_release_asset_name, desktop_module_package_storage_key, - desktop_release_asset_name, desktop_release_coordinates, desktop_release_descriptor_filename, - desktop_release_product, desktop_release_shipped_formats, + DesktopReleaseKind, RELEASE_REPOSITORY, desktop_module_manifest_release_asset_name, + desktop_module_manifest_storage_key, desktop_module_package_release_asset_name, + desktop_module_package_storage_key, 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, validate_desktop_release_module_files, }; @@ -166,6 +166,9 @@ enum DesktopStep { BuildModuleManifest, PrepareReleaseAssets, BuildSummary, + CheckShellDrift, + BuildModulesOnlyManifest, + PrepareModulesReleaseAssets, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -286,6 +289,9 @@ pub async fn run(args: BuildDesktopArgs) -> Result<()> { DesktopStep::BuildModuleManifest => build_module_manifest_step(), DesktopStep::PrepareReleaseAssets => prepare_release_assets_step(), DesktopStep::BuildSummary => build_summary_step(), + DesktopStep::CheckShellDrift => check_shell_drift_step().await, + DesktopStep::BuildModulesOnlyManifest => build_modules_only_manifest_step(), + DesktopStep::PrepareModulesReleaseAssets => prepare_modules_release_assets_step(), } } @@ -4595,6 +4601,7 @@ fn prepare_release_assets_step() -> Result<()> { descriptor_modules.sort_by(|left, right| left.storage_key.cmp(&right.storage_key)); let descriptor = DesktopReleaseDescriptor { schema_version: DESKTOP_RELEASE_DESCRIPTOR_SCHEMA_VERSION, + kind: DesktopReleaseKind::Full, channel: channel.clone(), version: version.clone(), release_tag: format!("fluxer-desktop-{channel}@{version}"), @@ -5314,13 +5321,20 @@ fn desktop_channel_manifest_path( } fn write_desktop_channel_manifest(path: &Path, manifest: &DesktopChannelManifest) -> Result<()> { - let mut bytes = serde_json::to_vec(manifest)?; + let bytes = serde_json::to_vec(manifest)?; + write_desktop_channel_manifest_bytes(path, &manifest.platform, &manifest.arch, bytes) +} + +fn write_desktop_channel_manifest_bytes( + path: &Path, + platform: &str, + arch: &str, + mut bytes: Vec, +) -> Result<()> { bytes.push(b'\n'); ensure!( bytes.len() <= DESKTOP_CHANNEL_MANIFEST_MAX_BYTES, - "The module manifest for {} {} is {} bytes, above the {DESKTOP_CHANNEL_MANIFEST_MAX_BYTES} byte client limit", - manifest.platform, - manifest.arch, + "The module manifest for {platform} {arch} is {} bytes, above the {DESKTOP_CHANNEL_MANIFEST_MAX_BYTES} byte client limit", bytes.len() ); if let Some(parent) = path.parent() { @@ -5409,6 +5423,340 @@ fn build_module_manifest_step() -> Result<()> { Ok(()) } +const DESKTOP_ALLOW_SHELL_DRIFT_ENV: &str = "ALLOW_SHELL_DRIFT"; +const DESKTOP_LIVE_SHELL_FILE: &str = "live-shell.json"; +const DESKTOP_LIVE_MANIFEST_TIMEOUT: Duration = Duration::from_secs(30); + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +struct DesktopLiveShell { + channel: String, + shell_version: String, + shell_sha: String, + manifests: BTreeMap, +} + +fn desktop_coordinate_key(platform: &str, arch: &str) -> String { + format!("{platform}/{arch}") +} + +fn desktop_live_manifest_url(channel: &str, platform: &str, arch: &str) -> String { + format!( + "{}/{DESKTOP_PAYLOAD_PREFIX}/{channel}/{platform}/{arch}/{DESKTOP_CHANNEL_MANIFEST_NAME}", + package_origin_base() + ) +} + +async fn fetch_live_channel_manifests(channel: &str) -> Result> { + let client = reqwest::Client::builder() + .timeout(DESKTOP_LIVE_MANIFEST_TIMEOUT) + .build() + .context("Failed to build the HTTP client")?; + let mut manifests = BTreeMap::new(); + for (platform, arch) in desktop_release_coordinates() { + let url = desktop_live_manifest_url(channel, platform, arch); + let manifest: Value = client + .get(&url) + .send() + .await + .with_context(|| format!("Failed to fetch {url}"))? + .error_for_status() + .with_context(|| format!("{url} did not serve a module manifest"))? + .json() + .await + .with_context(|| format!("{url} is not JSON"))?; + ensure!( + manifest["release_channel"] == channel + && manifest["platform"] == platform + && manifest["arch"] == arch, + "{url} describes another channel or coordinate" + ); + manifests.insert(desktop_coordinate_key(platform, arch), manifest); + } + Ok(manifests) +} + +fn live_shell_version(manifest: &Value) -> Option<&str> { + manifest["shell"]["latest_version"].as_str() +} + +fn common_live_shell_version(manifests: &BTreeMap) -> Result { + let versions = manifests + .iter() + .map(|(coordinate, manifest)| { + live_shell_version(manifest) + .map(str::to_string) + .with_context(|| format!("The live {coordinate} manifest names no shell version")) + }) + .collect::>>()?; + let mut iter = versions.iter(); + let first = iter + .next() + .context("No live module manifest was fetched")? + .clone(); + ensure!( + iter.next().is_none(), + "The live feeds disagree on the shell version ({}), finish publishing the last full desktop release before a modules-only release", + versions.iter().cloned().collect::>().join(", ") + ); + crate::common::parse_version_instant(&first) + .with_context(|| format!("The live shell version {first:?} is not a CalVer"))?; + Ok(first) +} + +fn desktop_shell_source_roots(repo_root: &Path) -> Result> { + let package_path = repo_root.join("fluxer_desktop").join("package.json"); + let package: Value = serde_json::from_slice( + &fs::read(&package_path) + .with_context(|| format!("Failed to read {}", package_path.display()))?, + ) + .with_context(|| format!("Failed to parse {}", package_path.display()))?; + let workspace_dependencies = package["dependencies"] + .as_object() + .into_iter() + .flatten() + .filter(|(_, spec)| { + spec.as_str() + .is_some_and(|spec| spec.starts_with("workspace:")) + }) + .map(|(name, _)| name.clone()) + .collect::>(); + let mut roots = vec!["fluxer_desktop/".to_string()]; + for package_dir in sorted_child_directories(&repo_root.join("packages"))? { + let manifest_path = package_dir.join("package.json"); + if !manifest_path.is_file() { + continue; + } + let manifest: Value = serde_json::from_slice(&fs::read(&manifest_path)?) + .with_context(|| format!("Failed to parse {}", manifest_path.display()))?; + if manifest["name"] + .as_str() + .is_some_and(|name| workspace_dependencies.contains(name)) + { + roots.push(format!("packages/{}/", file_name_string(&package_dir)?)); + } + } + Ok(roots) +} + +fn is_shell_irrelevant_path(path: &str) -> bool { + path.contains(".test.") + || path.contains("/__tests__/") + || path.contains("/__fixtures__/") + || path.ends_with(".md") +} + +fn desktop_shell_drift(changed: &[String], roots: &[String]) -> Vec { + changed + .iter() + .filter(|path| roots.iter().any(|root| path.starts_with(root.as_str()))) + .filter(|path| !is_shell_irrelevant_path(path)) + .cloned() + .collect() +} + +async fn check_shell_drift_step() -> Result<()> { + let channel = require_env("CHANNEL")?; + let source_sha = require_env("SOURCE_SHA")?; + let manifests = fetch_live_channel_manifests(&channel).await?; + let shell_version = common_live_shell_version(&manifests)?; + let tag = format!("fluxer-desktop-{channel}@{shell_version}"); + let shell_sha = output_text(CommandSpec::new("gh").args([ + "api", + &format!("repos/{RELEASE_REPOSITORY}/commits/{tag}"), + "--jq", + ".sha", + ])) + .with_context(|| format!("Failed to resolve the live shell release {tag}"))? + .trim() + .to_string(); + ensure!( + shell_sha.len() == 40 && shell_sha.bytes().all(|byte| byte.is_ascii_hexdigit()), + "{tag} resolved to an invalid commit {shell_sha:?}" + ); + let repo_root = env::current_dir().context("Failed to resolve the repository root")?; + let roots = desktop_shell_source_roots(&repo_root)?; + let changed = + output_text(CommandSpec::new("git").args(["diff", "--name-only", &shell_sha, &source_sha])) + .with_context(|| format!("Failed to diff {shell_sha} against {source_sha}"))? + .lines() + .map(str::to_string) + .collect::>(); + let drift = desktop_shell_drift(&changed, &roots); + if !drift.is_empty() { + let listed = drift.join("\n "); + ensure!( + env_bool(DESKTOP_ALLOW_SHELL_DRIFT_ENV), + "The live {channel} shell {shell_version} was built from {shell_sha}, and these shell sources changed since then:\n {listed}\nShip a full desktop release, or set {DESKTOP_ALLOW_SHELL_DRIFT_ENV}=true if the renderer does not depend on these changes" + ); + println!( + "{DESKTOP_ALLOW_SHELL_DRIFT_ENV} is set, publishing modules although these shell sources changed since {shell_sha}:\n {listed}" + ); + } + let live = DesktopLiveShell { + channel: channel.clone(), + shell_version: shell_version.clone(), + shell_sha: shell_sha.clone(), + manifests, + }; + let modules_dir = desktop_modules_dir(); + fs::create_dir_all(&modules_dir) + .with_context(|| format!("Failed to create {}", modules_dir.display()))?; + write_json_pretty(&modules_dir.join(DESKTOP_LIVE_SHELL_FILE), &live)?; + println!( + "The live {channel} shell is {shell_version} from {shell_sha}, the modules from {source_sha} run on it" + ); + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +fn modules_only_channel_manifest( + live: &Value, + channel: &str, + platform: &str, + arch: &str, + build_version: &str, + pub_date: &str, + metadata_version: u64, + packed: &[DesktopPackedModule], +) -> Result { + let mut manifest = live + .as_object() + .cloned() + .with_context(|| format!("The live {platform} {arch} manifest is not an object"))?; + let shell_version = live_shell_version(live) + .with_context(|| format!("The live {platform} {arch} manifest names no shell version"))? + .to_string(); + let modules = desktop_channel_manifest_entries(channel, packed) + .into_iter() + .map(|(module, mut entry)| { + entry.minimum_shell_version = shell_version.clone(); + (module, entry) + }) + .collect::>(); + manifest.insert( + "manifest_version".to_string(), + json!(DESKTOP_CHANNEL_MANIFEST_VERSION), + ); + manifest.insert("release_channel".to_string(), json!(channel)); + manifest.insert("platform".to_string(), json!(platform)); + manifest.insert("arch".to_string(), json!(arch)); + manifest.insert("build_version".to_string(), json!(build_version)); + manifest.insert("pub_date".to_string(), json!(pub_date)); + manifest.insert("metadata_version".to_string(), json!(metadata_version)); + manifest.insert("modules".to_string(), serde_json::to_value(modules)?); + manifest.insert( + "required_modules".to_string(), + json!(DESKTOP_REQUIRED_MODULES), + ); + Ok(Value::Object(manifest)) +} + +fn build_modules_only_manifest_step() -> Result<()> { + let channel = require_env("CHANNEL")?; + let build_version = require_env("VERSION")?; + let pub_date = require_env("PUB_DATE")?; + let modules_dir = desktop_modules_dir(); + let live_path = modules_dir.join(DESKTOP_LIVE_SHELL_FILE); + let live: DesktopLiveShell = + serde_json::from_slice(&fs::read(&live_path).with_context(|| { + format!( + "Failed to read {}, run the check_shell_drift step first", + live_path.display() + ) + })?) + .with_context(|| format!("Failed to parse {}", live_path.display()))?; + ensure!( + live.channel == channel, + "{} describes the {} channel, not {channel}", + live_path.display(), + live.channel + ); + let payload_root = Path::new("payload_tree").join(DESKTOP_PAYLOAD_PREFIX); + remove_dir_if_exists(Path::new("payload_tree"))?; + fs::create_dir_all(&payload_root) + .with_context(|| format!("Failed to create {}", payload_root.display()))?; + let packed = read_packed_desktop_modules(&modules_dir, &channel, &build_version)?; + let metadata_version = desktop_module_metadata_version(&pub_date)?; + stage_desktop_module_packages(&payload_root, &channel, &packed)?; + for (platform, arch) in desktop_release_coordinates() { + let key = desktop_coordinate_key(platform, arch); + let live_manifest = live + .manifests + .get(&key) + .with_context(|| format!("{} holds no live {key} manifest", live_path.display()))?; + let manifest = modules_only_channel_manifest( + live_manifest, + &channel, + platform, + arch, + &build_version, + &pub_date, + metadata_version, + &packed, + )?; + let manifest_path = desktop_channel_manifest_path(&payload_root, &channel, platform, arch); + if let Some(parent) = manifest_path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("Failed to create {}", parent.display()))?; + } + write_desktop_channel_manifest_bytes( + &manifest_path, + platform, + arch, + serde_json::to_vec(&manifest)?, + )?; + println!( + "Wrote the {platform} {arch} modules-only manifest for shell {}", + live.shell_version + ); + } + println!( + "Staged {} module(s) for {build_version} on the live {channel} shell {}", + packed.len(), + live.shell_version + ); + Ok(()) +} + +fn prepare_modules_release_assets_step() -> Result<()> { + let channel = require_env("CHANNEL")?; + let version = require_env("VERSION")?; + let source_sha = require_env("SOURCE_SHA")?; + let product = desktop_release_product(&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(&channel, &version, product, release_assets); + add_desktop_module_release_assets(&mut release_builder, &payload_root)?; + let (descriptor_assets, mut descriptor_modules) = release_builder.finish(); + ensure!( + descriptor_assets.is_empty(), + "A modules-only release staged shell assets" + ); + descriptor_modules.sort_by(|left, right| left.storage_key.cmp(&right.storage_key)); + let descriptor = DesktopReleaseDescriptor { + schema_version: DESKTOP_RELEASE_DESCRIPTOR_SCHEMA_VERSION, + kind: DesktopReleaseKind::Modules, + channel: channel.clone(), + version: version.clone(), + release_tag: format!("fluxer-desktop-{channel}@{version}"), + source_sha, + assets: Vec::new(), + modules: descriptor_modules, + }; + validate_desktop_release_descriptor(&descriptor, &channel, &version, &descriptor.source_sha)?; + validate_desktop_release_module_files(&descriptor, release_assets)?; + 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 build_summary_step() -> Result<()> { let summary = require_env("GITHUB_STEP_SUMMARY")?; let display_channel = env::var("DISPLAY_CHANNEL").unwrap_or_default(); @@ -5592,6 +5940,136 @@ mod tests { .unwrap() } + fn live_manifest(platform: &str, arch: &str, shell: &str) -> Value { + json!({ + "manifest_version": 1, + "release_channel": "canary", + "platform": platform, + "arch": arch, + "build_version": shell, + "pub_date": "2026-10-08T03:23:23Z", + "metadata_version": 1791433225, + "shell": {"latest_version": shell, "minimum_version": "0.0.0"}, + "linux_security_minimum": {"version": "2026.1001.1", "required_modules": ["fluxer_renderer"]}, + "modules": {}, + "required_modules": ["fluxer_renderer"], + }) + } + + #[test] + fn live_shell_version_must_agree_across_coordinates() { + let mut manifests = BTreeMap::new(); + for (platform, arch) in desktop_release_coordinates() { + manifests.insert( + desktop_coordinate_key(platform, arch), + live_manifest(platform, arch, "2026.1008.32323"), + ); + } + assert_eq!( + common_live_shell_version(&manifests).unwrap(), + "2026.1008.32323" + ); + manifests.insert( + "linux/arm64".to_string(), + live_manifest("linux", "arm64", "2026.1008.12411"), + ); + let error = common_live_shell_version(&manifests) + .unwrap_err() + .to_string(); + assert!(error.contains("disagree"), "{error}"); + manifests.insert("linux/arm64".to_string(), json!({"shell": {}})); + assert!(common_live_shell_version(&manifests).is_err()); + } + + #[test] + fn shell_drift_counts_only_shell_sources() { + let roots = vec![ + "fluxer_desktop/".to_string(), + "packages/desktop_ipc/".to_string(), + ]; + let changed = [ + "fluxer_app/src/App.tsx", + "fluxer_desktop/src/main/Updater.ts", + "fluxer_desktop/src/main/Updater.test.mjs", + "packages/desktop_ipc/src/Channels.ts", + "packages/desktop_ipc/README.md", + "packages/schema/src/Thing.ts", + ] + .map(str::to_string); + assert_eq!( + desktop_shell_drift(&changed, &roots), + vec![ + "fluxer_desktop/src/main/Updater.ts".to_string(), + "packages/desktop_ipc/src/Channels.ts".to_string(), + ] + ); + } + + #[test] + fn shell_source_roots_follow_the_desktop_workspace_dependencies() { + let repo = tempfile::tempdir().unwrap(); + let root = repo.path(); + fs::create_dir_all(root.join("fluxer_desktop")).unwrap(); + fs::write( + root.join("fluxer_desktop/package.json"), + r#"{"dependencies":{"@fluxer/desktop_ipc":"workspace:*","electron-log":"5.0.0"}}"#, + ) + .unwrap(); + for (dir, name) in [ + ("desktop_ipc", "@fluxer/desktop_ipc"), + ("schema", "@fluxer/schema"), + ] { + fs::create_dir_all(root.join("packages").join(dir)).unwrap(); + fs::write( + root.join("packages").join(dir).join("package.json"), + format!(r#"{{"name":"{name}"}}"#), + ) + .unwrap(); + } + assert_eq!( + desktop_shell_source_roots(root).unwrap(), + vec![ + "fluxer_desktop/".to_string(), + "packages/desktop_ipc/".to_string() + ] + ); + } + + #[test] + fn modules_only_manifest_keeps_the_live_shell_and_pins_modules_to_it() { + let live = live_manifest("darwin", "arm64", "2026.1008.32323"); + let packed = vec![DesktopPackedModule { + module: "fluxer_renderer".to_string(), + sha256: "a".repeat(64), + bytes: 42, + directory: PathBuf::from("unused"), + }]; + let manifest = modules_only_channel_manifest( + &live, + "canary", + "darwin", + "arm64", + "2026.1008.90000", + "2026-10-08T09:00:00Z", + 1791450000, + &packed, + ) + .unwrap(); + assert_eq!(manifest["shell"], live["shell"]); + assert_eq!( + manifest["linux_security_minimum"], + live["linux_security_minimum"] + ); + assert_eq!(manifest["build_version"], "2026.1008.90000"); + assert_eq!(manifest["metadata_version"], 1791450000); + let renderer = &manifest["modules"]["fluxer_renderer"]; + assert_eq!(renderer["sha256"], "a".repeat(64)); + assert_eq!(renderer["minimum_shell_version"], "2026.1008.32323"); + assert!(renderer["maximum_shell_version"].is_null()); + let parsed: DesktopChannelManifest = serde_json::from_value(manifest).unwrap(); + assert_eq!(parsed.shell.latest_version, "2026.1008.32323"); + } + #[test] fn bare_drive_workdir_resolves_to_the_drive_root() { assert_eq!(workdir_path("W:"), PathBuf::from("W:\\")); @@ -6476,6 +6954,7 @@ export const CHANNEL_DISPLAY_NAME = BUILD_CHANNEL;\n" assert_eq!(on_disk, names); let descriptor = DesktopReleaseDescriptor { schema_version: DESKTOP_RELEASE_DESCRIPTOR_SCHEMA_VERSION, + kind: DesktopReleaseKind::Full, channel: "canary".to_string(), version: LAYOUT_VERSION.to_string(), release_tag: format!("fluxer-desktop-canary@{LAYOUT_VERSION}"), diff --git a/tools/ci/src/release.rs b/tools/ci/src/release.rs index 02e395900..15814fb4d 100644 --- a/tools/ci/src/release.rs +++ b/tools/ci/src/release.rs @@ -148,9 +148,25 @@ pub(crate) struct DesktopReleaseAsset { pub(crate) size: u64, } +#[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub(crate) enum DesktopReleaseKind { + #[default] + Full, + Modules, +} + +impl DesktopReleaseKind { + pub(crate) fn is_full(&self) -> bool { + *self == Self::Full + } +} + #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub(crate) struct DesktopReleaseDescriptor { pub(crate) schema_version: u8, + #[serde(default, skip_serializing_if = "DesktopReleaseKind::is_full")] + pub(crate) kind: DesktopReleaseKind, pub(crate) channel: String, pub(crate) version: String, pub(crate) release_tag: String, @@ -334,6 +350,24 @@ pub(crate) fn validate_desktop_release_descriptor( ); parse_version_instant(version) .with_context(|| format!("Invalid desktop release descriptor version {version:?}"))?; + if descriptor.kind == DesktopReleaseKind::Modules { + ensure!( + descriptor.assets.is_empty(), + "A modules-only desktop release descriptor must not list shell assets, found {}", + descriptor.assets.len() + ); + let descriptor_name = desktop_release_descriptor_filename(channel, version)?; + let mut release_asset_names = BTreeMap::from([( + descriptor_name.to_ascii_lowercase(), + descriptor_name.as_str(), + )]); + return validate_desktop_release_modules( + descriptor, + channel, + version, + &mut release_asset_names, + ); + } let route_count = desktop_release_route_count(); ensure!( descriptor.assets.len() == route_count, @@ -1465,6 +1499,7 @@ mod tests { } DesktopReleaseDescriptor { schema_version: DESKTOP_RELEASE_DESCRIPTOR_SCHEMA_VERSION, + kind: DesktopReleaseKind::Full, channel: SAMPLE_CHANNEL.to_string(), version: SAMPLE_VERSION.to_string(), release_tag: format!("fluxer-desktop-{SAMPLE_CHANNEL}@{SAMPLE_VERSION}"), @@ -1556,6 +1591,50 @@ mod tests { descriptor } + fn sample_modules_only_descriptor() -> DesktopReleaseDescriptor { + let mut descriptor = sample_descriptor(); + descriptor.kind = DesktopReleaseKind::Modules; + descriptor.assets.clear(); + descriptor + } + + #[test] + fn modules_only_descriptor_validates_without_shell_assets() { + validate_sample(&sample_modules_only_descriptor()).unwrap(); + } + + #[test] + fn modules_only_descriptor_rejects_shell_assets() { + let mut descriptor = sample_modules_only_descriptor(); + descriptor.assets = sample_descriptor().assets; + let error = validate_sample(&descriptor).unwrap_err().to_string(); + assert!(error.contains("must not list shell assets"), "{error}"); + } + + #[test] + fn modules_only_descriptor_still_requires_every_coordinate_and_the_renderer() { + let mut descriptor = sample_modules_only_descriptor(); + descriptor + .modules + .retain(|entry| !entry.storage_key.contains("/fluxer_renderer/")); + assert!(validate_sample(&descriptor).is_err()); + let mut descriptor = sample_modules_only_descriptor(); + descriptor + .modules + .retain(|entry| !entry.storage_key.starts_with("desktop/canary/linux/arm64/")); + assert!(validate_sample(&descriptor).is_err()); + } + + #[test] + fn full_descriptor_omits_the_kind_and_modules_only_carries_it() { + let full = serde_json::to_value(sample_descriptor()).unwrap(); + assert!(full.get("kind").is_none()); + let modules_only = serde_json::to_value(sample_modules_only_descriptor()).unwrap(); + assert_eq!(modules_only["kind"], "modules"); + let parsed: DesktopReleaseDescriptor = serde_json::from_value(full).unwrap(); + assert_eq!(parsed.kind, DesktopReleaseKind::Full); + } + fn validate_sample(descriptor: &DesktopReleaseDescriptor) -> Result<()> { validate_desktop_release_descriptor( descriptor,