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 : }
|