LCOV - code coverage report
Current view: top level - dashql-pack/src - publish_command.rs Coverage Total Hit
Test: DashQL 6e74948 Lines: 100.0 % 193 193
Test Date: 2026-11-06 17:46:50

            Line data    Source code
       1              : use crate::{
       2              :     git_info::collect_git_info,
       3              :     release::{Release, ReleaseArgs},
       4              :     release_metadata::ReleaseSummary,
       5              :     release_version::{build_release_version, read_manifest_version, ReleaseChannel},
       6              :     remote_access::RemoteAccess,
       7              : };
       8              : 
       9              : use anyhow::{anyhow, Result};
      10              : use clap::Parser;
      11              : use std::io::Write;
      12              : use std::path::PathBuf;
      13              : 
      14              : /// Publish the macOS Electron artifacts of a release to R2
      15              : #[derive(Parser, Debug)]
      16              : pub struct PublishArgs {
      17              :     /// Build the release without uploading anything to R2
      18              :     #[arg(long, required = false, default_value = "false")]
      19              :     dry_run: bool,
      20              :     /// Write a JSON summary of the release to this path
      21              :     #[arg(long, required = false)]
      22              :     save_summary: Option<PathBuf>,
      23              :     /// The release channel to move to this release
      24              :     #[arg(long, value_enum, required = true)]
      25              :     channel: ReleaseChannel,
      26              :     /// The git repository of the released commit
      27              :     #[arg(long, required = true)]
      28              :     source_dir: PathBuf,
      29              : 
      30              :     /// The arm64 macOS DMG
      31              :     #[arg(long, required = true)]
      32              :     macos_arm64_dmg_path: PathBuf,
      33              :     /// The arm64 macOS ZIP used for auto updates
      34              :     #[arg(long, required = true)]
      35              :     macos_arm64_zip_path: PathBuf,
      36              :     /// The blockmap of the arm64 macOS ZIP
      37              :     #[arg(long, required = true)]
      38              :     macos_arm64_zip_blockmap_path: PathBuf,
      39              :     /// The electron-builder update manifest (latest-mac.yml) for arm64
      40              :     #[arg(long, required = true)]
      41              :     macos_arm64_update_manifest_path: PathBuf,
      42              :     /// The x64 macOS DMG
      43              :     #[arg(long, required = true)]
      44              :     macos_x64_dmg_path: PathBuf,
      45              :     /// The x64 macOS ZIP used for auto updates
      46              :     #[arg(long, required = true)]
      47              :     macos_x64_zip_path: PathBuf,
      48              :     /// The blockmap of the x64 macOS ZIP
      49              :     #[arg(long, required = true)]
      50              :     macos_x64_zip_blockmap_path: PathBuf,
      51              :     /// The electron-builder update manifest (latest-mac.yml) for x64
      52              :     #[arg(long, required = true)]
      53              :     macos_x64_update_manifest_path: PathBuf,
      54              : }
      55              : 
      56            9 : pub async fn publish(args: PublishArgs) -> Result<()> {
      57              :     // Is a dry-run?
      58            9 :     if args.dry_run {
      59            1 :         log::info!("DRY RUN, no persistent changes will be made");
      60            8 :     }
      61              : 
      62              :     // The artifacts know their version, a tag that was pushed after the build must not change it
      63            9 :     let artifact_version = read_manifest_version(&std::fs::read_to_string(&args.macos_arm64_update_manifest_path)?)?;
      64            9 :     let x64_version = read_manifest_version(&std::fs::read_to_string(&args.macos_x64_update_manifest_path)?)?;
      65            9 :     if artifact_version != x64_version {
      66            1 :         return Err(anyhow!("the artifacts differ in their version: arm64 {}, x64 {}", artifact_version, x64_version));
      67            8 :     }
      68            8 :     match (args.channel, artifact_version.pre.is_empty()) {
      69              :         (ReleaseChannel::Stable, false) => {
      70            1 :             return Err(anyhow!("refusing to publish the prerelease {} to the stable channel", artifact_version));
      71              :         }
      72              :         (ReleaseChannel::Latest, true) => {
      73              :             // The run of the tag owns this version, a second upload could replace the artifacts its feed points to
      74            1 :             log::warn!("{} was built from a tagged commit, leaving the publish to the run of the tag", artifact_version);
      75            1 :             return Ok(());
      76              :         }
      77            6 :         _ => {}
      78              :     }
      79              : 
      80              :     // Resolve the git version
      81            6 :     let git_repo = collect_git_info(&args.source_dir)?;
      82            5 :     log::info!("git description: {}", &git_repo.version.description);
      83              : 
      84            5 :     let version = build_release_version(artifact_version, args.channel);
      85            5 :     log::info!("release version: {}", &version.version);
      86            5 :     log::info!("release id: {}", &version.id);
      87            5 :     log::info!("release channel: {}", &version.channel);
      88              : 
      89              :     // Build the release
      90            5 :     let url = "https://get.dashql.app".to_string();
      91            5 :     let rel = Release::build(ReleaseArgs {
      92            5 :         remote_base_url: url.clone(),
      93            5 :         git_repo: git_repo.clone(),
      94            5 :         release_version: version,
      95            5 :         macos_arm64_dmg_path: args.macos_arm64_dmg_path,
      96            5 :         macos_arm64_zip_path: args.macos_arm64_zip_path,
      97            5 :         macos_arm64_zip_blockmap_path: args.macos_arm64_zip_blockmap_path,
      98            5 :         macos_arm64_update_manifest_path: args.macos_arm64_update_manifest_path,
      99            5 :         macos_x64_dmg_path: args.macos_x64_dmg_path,
     100            5 :         macos_x64_zip_path: args.macos_x64_zip_path,
     101            5 :         macos_x64_zip_blockmap_path: args.macos_x64_zip_blockmap_path,
     102            5 :         macos_x64_update_manifest_path: args.macos_x64_update_manifest_path,
     103            5 :     })
     104            5 :     .await?;
     105              : 
     106              :     // Check R2 credentials
     107            5 :     let remote_access = RemoteAccess::from_env()?;
     108            4 :     log::info!("r2 bucket: **** (from environment)");
     109            4 :     log::info!("r2 access key id: **** (from environment)");
     110            4 :     log::info!("r2 secret access key: **** (from environment)");
     111              : 
     112            4 :     let r2_client = remote_access.r2_client();
     113              : 
     114              :     // Write the metadata (if requested)
     115            4 :     if let Some(path) = &args.save_summary {
     116            1 :         let summary = ReleaseSummary {
     117            1 :             release_id: rel.release_metadata.release_id.clone(),
     118            1 :             pub_date: rel.release_metadata.pub_date.clone(),
     119            1 :             version: rel.release_metadata.version.clone(),
     120            1 :             git_commit_hash: git_repo.version.short_hash.clone(),
     121            1 :             git_commit_url: format!(
     122            1 :                 "https://github.com/ankoh/dashql/tree/{}",
     123            1 :                 &git_repo.version.short_hash
     124            1 :             ),
     125            1 :             bundle_macos_dmg_url: rel.release_metadata.bundles[0].url.clone(),
     126            1 :             release_metadata_url: format!("{}/{}", &url, &rel.release_metadata_path),
     127            1 :             update_manifest_url: rel.release_metadata.update_manifest_url.clone(),
     128            1 :         };
     129            1 :         let file = std::fs::File::create(path)?;
     130            1 :         let mut writer = std::io::BufWriter::new(file);
     131            1 :         serde_json::to_writer_pretty(&mut writer, &summary)?;
     132            1 :         writer.flush()?;
     133            1 :         log::info!("wrote release metadata to: {:?}", &path.as_path());
     134            3 :     }
     135              : 
     136              :     // Publish the release
     137            4 :     if !args.dry_run {
     138            3 :         rel.publish(&r2_client).await?;
     139            1 :     }
     140            4 :     Ok(())
     141            9 : }
     142              : 
     143              : #[cfg(test)]
     144              : mod tests {
     145              :     use super::*;
     146              :     use crate::test_s3::{git_repository, FakeS3, TempDir, ENV_LOCK};
     147              : 
     148              :     /// The arguments of the publish workflow for a repository and its artifacts
     149            9 :     fn args(source: &TempDir, artifacts: &TempDir, version: &str, channel: &str, extra: &[String]) -> PublishArgs {
     150            9 :         let manifest = format!("version: {version}\npath: DashQL.zip\n").into_bytes();
     151            9 :         let mut argv: Vec<String> = vec![
     152            9 :             "publish".into(),
     153            9 :             "--channel".into(),
     154            9 :             channel.into(),
     155            9 :             "--source-dir".into(),
     156            9 :             source.path().display().to_string(),
     157              :         ];
     158           72 :         for (flag, name, content) in [
     159            9 :             ("--macos-arm64-dmg-path", "arm64.dmg", &b"dmg"[..]),
     160            9 :             ("--macos-arm64-zip-path", "arm64.zip", &b"zip"[..]),
     161            9 :             ("--macos-arm64-zip-blockmap-path", "arm64.zip.blockmap", &b"map"[..]),
     162            9 :             ("--macos-arm64-update-manifest-path", "arm64.yml", &manifest[..]),
     163            9 :             ("--macos-x64-dmg-path", "x64.dmg", &b"dmg"[..]),
     164            9 :             ("--macos-x64-zip-path", "x64.zip", &b"zip"[..]),
     165            9 :             ("--macos-x64-zip-blockmap-path", "x64.zip.blockmap", &b"map"[..]),
     166            9 :             ("--macos-x64-update-manifest-path", "x64.yml", &manifest[..]),
     167           72 :         ] {
     168           72 :             argv.push(flag.into());
     169           72 :             argv.push(artifacts.write(name, content).display().to_string());
     170           72 :         }
     171            9 :         argv.extend_from_slice(extra);
     172            9 :         PublishArgs::try_parse_from(argv).unwrap()
     173            9 :     }
     174              : 
     175            8 :     fn point_to(s3: &FakeS3) {
     176            8 :         std::env::set_var("DASHQL_GET_R2_ENDPOINT", s3.endpoint());
     177            8 :         std::env::set_var("DASHQL_GET_R2_ACCESS_KEY_ID", "key");
     178            8 :         std::env::set_var("DASHQL_GET_R2_SECRET_ACCESS_KEY", "secret");
     179            8 :     }
     180              : 
     181              :     #[tokio::test]
     182            1 :     async fn publishes_a_stable_release_to_the_stable_channel_only() {
     183            1 :         let _guard = ENV_LOCK.lock().await;
     184            1 :         let (source, artifacts) = (TempDir::new(), TempDir::new());
     185            1 :         git_repository(source.path(), "v1.2.3", 0);
     186            1 :         let s3 = FakeS3::start().await;
     187            1 :         point_to(&s3);
     188            1 :         publish(args(&source, &artifacts, "1.2.3", "stable", &[])).await.unwrap();
     189            1 :         let pointer: serde_json::Value = serde_json::from_slice(&s3.object("stable.json").unwrap()).unwrap();
     190            1 :         assert_eq!(pointer["version"], "1.2.3");
     191            1 :         assert_eq!(s3.object("latest.json"), None);
     192            1 :     }
     193              : 
     194              :     #[tokio::test]
     195            1 :     async fn publishes_a_prerelease_to_the_latest_channel_only() {
     196            1 :         let _guard = ENV_LOCK.lock().await;
     197            1 :         let (source, artifacts) = (TempDir::new(), TempDir::new());
     198            1 :         git_repository(source.path(), "v1.2.3", 2);
     199            1 :         let s3 = FakeS3::start().await;
     200            1 :         point_to(&s3);
     201            1 :         publish(args(&source, &artifacts, "1.2.4-dev.2", "latest", &[])).await.unwrap();
     202            1 :         let pointer: serde_json::Value = serde_json::from_slice(&s3.object("latest.json").unwrap()).unwrap();
     203            1 :         assert_eq!(pointer["version"], "1.2.4-dev.2");
     204            1 :         assert_eq!(s3.object("stable.json"), None);
     205            1 :     }
     206              : 
     207              :     #[tokio::test]
     208            1 :     async fn a_tag_pushed_after_the_build_does_not_change_the_release() {
     209            1 :         let _guard = ENV_LOCK.lock().await;
     210            1 :         let (source, artifacts) = (TempDir::new(), TempDir::new());
     211            1 :         git_repository(source.path(), "v1.2.3", 0);
     212            1 :         let s3 = FakeS3::start().await;
     213            1 :         point_to(&s3);
     214            1 :         publish(args(&source, &artifacts, "1.2.3-dev.7", "latest", &[])).await.unwrap();
     215            1 :         let pointer: serde_json::Value = serde_json::from_slice(&s3.object("latest.json").unwrap()).unwrap();
     216            1 :         assert_eq!(pointer["version"], "1.2.3-dev.7");
     217            1 :         assert_eq!(s3.object("stable.json"), None);
     218            1 :         assert_eq!(s3.object("releases/1.2.3/metadata.json"), None);
     219            1 :     }
     220              : 
     221              :     #[tokio::test]
     222            1 :     async fn leaves_a_build_of_a_tagged_commit_to_the_run_of_the_tag() {
     223            1 :         let _guard = ENV_LOCK.lock().await;
     224            1 :         let (source, artifacts) = (TempDir::new(), TempDir::new());
     225            1 :         git_repository(source.path(), "v1.2.3", 0);
     226            1 :         let s3 = FakeS3::start().await;
     227            1 :         point_to(&s3);
     228            1 :         publish(args(&source, &artifacts, "1.2.3", "latest", &[])).await.unwrap();
     229            1 :         assert!(s3.requests().is_empty());
     230            1 :     }
     231              : 
     232              :     #[tokio::test]
     233            1 :     async fn refuses_a_prerelease_on_the_stable_channel() {
     234            1 :         let _guard = ENV_LOCK.lock().await;
     235            1 :         let (source, artifacts) = (TempDir::new(), TempDir::new());
     236            1 :         git_repository(source.path(), "v1.2.3", 2);
     237            1 :         let s3 = FakeS3::start().await;
     238            1 :         point_to(&s3);
     239            1 :         let error = publish(args(&source, &artifacts, "1.2.4-dev.2", "stable", &[])).await.unwrap_err();
     240            1 :         assert!(error.to_string().contains("refusing to publish the prerelease"), "{error}");
     241            1 :         assert!(s3.requests().is_empty());
     242            1 :     }
     243              : 
     244              :     #[tokio::test]
     245            1 :     async fn refuses_artifacts_of_different_versions() {
     246            1 :         let _guard = ENV_LOCK.lock().await;
     247            1 :         let (source, artifacts) = (TempDir::new(), TempDir::new());
     248            1 :         git_repository(source.path(), "v1.2.3", 2);
     249            1 :         let s3 = FakeS3::start().await;
     250            1 :         point_to(&s3);
     251            1 :         let args = args(&source, &artifacts, "1.2.4-dev.2", "latest", &[]);
     252            1 :         artifacts.write("x64.yml", b"version: 1.2.4-dev.1\npath: DashQL.zip\n");
     253            1 :         let error = publish(args).await.unwrap_err();
     254            1 :         assert!(error.to_string().contains("differ in their version"), "{error}");
     255            1 :         assert!(s3.requests().is_empty());
     256            1 :     }
     257              : 
     258              :     #[tokio::test]
     259            1 :     async fn a_dry_run_writes_the_summary_and_uploads_nothing() {
     260            1 :         let _guard = ENV_LOCK.lock().await;
     261            1 :         let (source, artifacts) = (TempDir::new(), TempDir::new());
     262            1 :         git_repository(source.path(), "v1.2.3", 0);
     263            1 :         let s3 = FakeS3::start().await;
     264            1 :         point_to(&s3);
     265            1 :         let summary = artifacts.path().join("summary.json");
     266            1 :         let extra = ["--dry-run".to_string(), "--save-summary".to_string(), summary.display().to_string()];
     267            1 :         publish(args(&source, &artifacts, "1.2.3", "stable", &extra)).await.unwrap();
     268            1 :         assert!(s3.requests().is_empty());
     269              : 
     270            1 :         let summary: serde_json::Value = serde_json::from_slice(&std::fs::read(summary).unwrap()).unwrap();
     271            1 :         assert_eq!(summary["version"], "1.2.3");
     272            1 :         assert_eq!(summary["release_metadata_url"], "https://get.dashql.app/releases/1.2.3/metadata.json");
     273            1 :         assert_eq!(summary["update_manifest_url"], "https://get.dashql.app/releases/1.2.3/update.json");
     274            1 :         assert_eq!(summary["bundle_macos_dmg_url"], "https://get.dashql.app/releases/1.2.3/macos/arm64/arm64.dmg");
     275            1 :     }
     276              : 
     277              :     #[tokio::test]
     278            1 :     async fn stops_before_uploading_without_a_repository() {
     279            1 :         let _guard = ENV_LOCK.lock().await;
     280            1 :         let (source, artifacts) = (TempDir::new(), TempDir::new());
     281            1 :         let s3 = FakeS3::start().await;
     282            1 :         point_to(&s3);
     283            1 :         assert!(publish(args(&source, &artifacts, "1.2.3", "stable", &[])).await.is_err());
     284            1 :         assert!(s3.requests().is_empty());
     285            1 :     }
     286              : 
     287              :     #[tokio::test]
     288            1 :     async fn needs_credentials() {
     289            1 :         let _guard = ENV_LOCK.lock().await;
     290            1 :         let (source, artifacts) = (TempDir::new(), TempDir::new());
     291            1 :         git_repository(source.path(), "v1.2.3", 0);
     292            1 :         std::env::remove_var("DASHQL_GET_R2_SECRET_ACCESS_KEY");
     293            1 :         std::env::set_var("DASHQL_GET_R2_ENDPOINT", "http://127.0.0.1:1");
     294            1 :         std::env::set_var("DASHQL_GET_R2_ACCESS_KEY_ID", "key");
     295            1 :         let error = publish(args(&source, &artifacts, "1.2.3", "stable", &[])).await.unwrap_err();
     296            1 :         assert!(error.to_string().contains("DASHQL_GET_R2_SECRET_ACCESS_KEY"), "{error}");
     297            1 :     }
     298              : }
        

Generated by: LCOV version 2.0-1