electron-cli 0.3.0-alpha.10 → 0.3.0-alpha.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,15 +1,16 @@
1
1
  use std::{
2
2
  fs,
3
+ fs::File,
3
4
  path::{Path, PathBuf},
4
- time::{SystemTime, UNIX_EPOCH},
5
+ time::{Duration, SystemTime, UNIX_EPOCH},
5
6
  };
6
7
 
7
8
  use anyhow::{bail, Context, Result};
8
9
  use camino::Utf8PathBuf;
9
- use serde::Serialize;
10
+ use serde::{de::DeserializeOwned, Deserialize, Serialize};
10
11
 
11
12
  use crate::{
12
- cli::{MakeArgs, PublishArgs},
13
+ cli::{MakeArgs, PublishArgs, PublishTarget},
13
14
  commands::make::{self, MakeReport},
14
15
  output,
15
16
  };
@@ -19,9 +20,8 @@ struct PublishReport {
19
20
  make: MakeReport,
20
21
  publisher: String,
21
22
  channel: String,
22
- destination_dir: Utf8PathBuf,
23
- destination_artifact: Utf8PathBuf,
24
- manifest: Utf8PathBuf,
23
+ local: Option<LocalPublishPlan>,
24
+ github: Option<GithubPublishPlan>,
25
25
  skip_make: bool,
26
26
  dry_run: bool,
27
27
  status: PublishStatus,
@@ -29,6 +29,26 @@ struct PublishReport {
29
29
  warnings: Vec<String>,
30
30
  }
31
31
 
32
+ #[derive(Debug, Serialize)]
33
+ struct LocalPublishPlan {
34
+ destination_dir: Utf8PathBuf,
35
+ destination_artifact: Utf8PathBuf,
36
+ manifest: Utf8PathBuf,
37
+ }
38
+
39
+ #[derive(Debug, Serialize)]
40
+ struct GithubPublishPlan {
41
+ repo: String,
42
+ tag: String,
43
+ release_name: String,
44
+ draft: bool,
45
+ prerelease: bool,
46
+ api_url: String,
47
+ artifact_name: String,
48
+ release_url: Option<String>,
49
+ asset_url: Option<String>,
50
+ }
51
+
32
52
  #[derive(Debug, Serialize)]
33
53
  #[serde(rename_all = "kebab-case")]
34
54
  enum PublishStatus {
@@ -86,17 +106,11 @@ fn build_report(args: &PublishArgs) -> Result<PublishReport> {
86
106
  };
87
107
  let make = make::build_report(&make_args)?;
88
108
  let root = Path::new(make.package().project().root.as_str());
89
- let publish_root = resolve_destination(root, &args.to);
90
- let destination_dir = publish_root
91
- .join(&args.channel)
92
- .join(make.package().platform())
93
- .join(make.package().arch());
94
109
  let artifact_name = make
95
110
  .artifact()
96
111
  .file_name()
97
- .context("Make artifact path has no file name")?;
98
- let destination_artifact = destination_dir.join(artifact_name);
99
- let manifest = destination_dir.join("manifest.json");
112
+ .context("Make artifact path has no UTF-8 file name")?
113
+ .to_string();
100
114
 
101
115
  let mut warnings = make.warnings().to_vec();
102
116
  if args.skip_make && !Path::new(make.artifact().as_str()).exists() {
@@ -105,26 +119,35 @@ fn build_report(args: &PublishArgs) -> Result<PublishReport> {
105
119
  make.artifact()
106
120
  ));
107
121
  }
108
- if destination_artifact.exists() && !args.force {
109
- warnings.push(format!(
110
- "Publish artifact already exists: {}. Use --force to overwrite it.",
111
- destination_artifact.display()
112
- ));
113
- }
114
- if manifest.exists() && !args.force {
115
- warnings.push(format!(
116
- "Publish manifest already exists: {}. Use --force to overwrite it.",
117
- manifest.display()
118
- ));
119
- }
122
+ let (local, github) = match args.publisher {
123
+ PublishTarget::Local => {
124
+ let local = build_local_plan(root, args, &make, &artifact_name)?;
125
+ if Path::new(local.destination_artifact.as_str()).exists() && !args.force {
126
+ warnings.push(format!(
127
+ "Publish artifact already exists: {}. Use --force to overwrite it.",
128
+ local.destination_artifact
129
+ ));
130
+ }
131
+ if Path::new(local.manifest.as_str()).exists() && !args.force {
132
+ warnings.push(format!(
133
+ "Publish manifest already exists: {}. Use --force to overwrite it.",
134
+ local.manifest
135
+ ));
136
+ }
137
+ (Some(local), None)
138
+ }
139
+ PublishTarget::Github => {
140
+ let github = build_github_plan(args, &make, &artifact_name, &mut warnings)?;
141
+ (None, Some(github))
142
+ }
143
+ };
120
144
 
121
145
  Ok(PublishReport {
122
146
  make,
123
147
  publisher: args.publisher.as_str().to_string(),
124
148
  channel: args.channel.clone(),
125
- destination_dir: utf8_path(destination_dir)?,
126
- destination_artifact: utf8_path(destination_artifact)?,
127
- manifest: utf8_path(manifest)?,
149
+ local,
150
+ github,
128
151
  skip_make: args.skip_make,
129
152
  dry_run: args.dry_run,
130
153
  status: PublishStatus::Planned,
@@ -133,6 +156,77 @@ fn build_report(args: &PublishArgs) -> Result<PublishReport> {
133
156
  })
134
157
  }
135
158
 
159
+ fn build_local_plan(
160
+ root: &Path,
161
+ args: &PublishArgs,
162
+ make: &MakeReport,
163
+ artifact_name: &str,
164
+ ) -> Result<LocalPublishPlan> {
165
+ let publish_root = resolve_destination(root, &args.to);
166
+ let destination_dir = publish_root
167
+ .join(&args.channel)
168
+ .join(make.package().platform())
169
+ .join(make.package().arch());
170
+ let destination_artifact = destination_dir.join(artifact_name);
171
+ let manifest = destination_dir.join("manifest.json");
172
+
173
+ Ok(LocalPublishPlan {
174
+ destination_dir: utf8_path(destination_dir)?,
175
+ destination_artifact: utf8_path(destination_artifact)?,
176
+ manifest: utf8_path(manifest)?,
177
+ })
178
+ }
179
+
180
+ fn build_github_plan(
181
+ args: &PublishArgs,
182
+ make: &MakeReport,
183
+ artifact_name: &str,
184
+ warnings: &mut Vec<String>,
185
+ ) -> Result<GithubPublishPlan> {
186
+ let repo = args
187
+ .github_repo
188
+ .clone()
189
+ .or_else(|| {
190
+ make.package()
191
+ .project()
192
+ .repository
193
+ .as_deref()
194
+ .and_then(github_repo_from_repository)
195
+ })
196
+ .unwrap_or_default();
197
+ if repo.is_empty() {
198
+ warnings.push(
199
+ "GitHub repository is not configured. Pass --github-repo OWNER/REPO or set package.json repository.".to_string(),
200
+ );
201
+ } else if !valid_github_repo(&repo) {
202
+ warnings.push(format!(
203
+ "GitHub repository should use OWNER/REPO form: {repo}."
204
+ ));
205
+ }
206
+
207
+ let tag = args
208
+ .github_tag
209
+ .clone()
210
+ .unwrap_or_else(|| default_github_tag(make, &args.channel));
211
+ let release_name = args
212
+ .github_release_name
213
+ .clone()
214
+ .unwrap_or_else(|| tag.clone());
215
+ let prerelease = args.github_prerelease || tag.contains('-');
216
+
217
+ Ok(GithubPublishPlan {
218
+ repo,
219
+ tag,
220
+ release_name,
221
+ draft: args.github_draft,
222
+ prerelease,
223
+ api_url: args.github_api_url.trim_end_matches('/').to_string(),
224
+ artifact_name: artifact_name.to_string(),
225
+ release_url: None,
226
+ asset_url: None,
227
+ })
228
+ }
229
+
136
230
  fn execute_publish(report: &mut PublishReport, args: &PublishArgs) -> Result<()> {
137
231
  if !args.skip_make {
138
232
  let make_args = MakeArgs {
@@ -156,8 +250,26 @@ fn execute_publish(report: &mut PublishReport, args: &PublishArgs) -> Result<()>
156
250
  );
157
251
  }
158
252
 
159
- let destination_artifact = Path::new(report.destination_artifact.as_str());
160
- let manifest = Path::new(report.manifest.as_str());
253
+ let published_at_unix_seconds = now_unix_seconds()?;
254
+ report.published_at_unix_seconds = Some(published_at_unix_seconds);
255
+
256
+ match args.publisher {
257
+ PublishTarget::Local => execute_local_publish(report, args, published_at_unix_seconds),
258
+ PublishTarget::Github => execute_github_publish(report, args),
259
+ }
260
+ }
261
+
262
+ fn execute_local_publish(
263
+ report: &PublishReport,
264
+ args: &PublishArgs,
265
+ published_at_unix_seconds: u64,
266
+ ) -> Result<()> {
267
+ let local = report
268
+ .local
269
+ .as_ref()
270
+ .context("Local publish plan was not built")?;
271
+ let destination_artifact = Path::new(local.destination_artifact.as_str());
272
+ let manifest = Path::new(local.manifest.as_str());
161
273
 
162
274
  for path in [destination_artifact, manifest] {
163
275
  if path.exists() {
@@ -173,8 +285,8 @@ fn execute_publish(report: &mut PublishReport, args: &PublishArgs) -> Result<()>
173
285
  }
174
286
  }
175
287
 
176
- fs::create_dir_all(report.destination_dir.as_str())
177
- .with_context(|| format!("Could not create {}", report.destination_dir))?;
288
+ fs::create_dir_all(local.destination_dir.as_str())
289
+ .with_context(|| format!("Could not create {}", local.destination_dir))?;
178
290
  fs::copy(report.make.artifact().as_str(), destination_artifact).with_context(|| {
179
291
  format!(
180
292
  "Could not publish {} to {}",
@@ -183,8 +295,6 @@ fn execute_publish(report: &mut PublishReport, args: &PublishArgs) -> Result<()>
183
295
  )
184
296
  })?;
185
297
 
186
- let published_at_unix_seconds = now_unix_seconds()?;
187
- report.published_at_unix_seconds = Some(published_at_unix_seconds);
188
298
  let manifest_json =
189
299
  serde_json::to_string_pretty(&build_manifest(report, published_at_unix_seconds)?)?;
190
300
  fs::write(manifest, format!("{manifest_json}\n"))
@@ -193,11 +303,229 @@ fn execute_publish(report: &mut PublishReport, args: &PublishArgs) -> Result<()>
193
303
  Ok(())
194
304
  }
195
305
 
306
+ fn execute_github_publish(report: &mut PublishReport, args: &PublishArgs) -> Result<()> {
307
+ let token = github_token()?;
308
+ let agent = github_agent();
309
+ publish_to_github(report, args, &token, &agent)
310
+ }
311
+
312
+ #[derive(Debug, Serialize)]
313
+ struct CreateGithubRelease<'a> {
314
+ tag_name: &'a str,
315
+ name: &'a str,
316
+ body: &'a str,
317
+ draft: bool,
318
+ prerelease: bool,
319
+ }
320
+
321
+ #[derive(Debug, Deserialize)]
322
+ struct GithubRelease {
323
+ html_url: String,
324
+ upload_url: String,
325
+ #[serde(default)]
326
+ assets: Vec<GithubAsset>,
327
+ }
328
+
329
+ #[derive(Debug, Deserialize)]
330
+ struct GithubAsset {
331
+ id: u64,
332
+ name: String,
333
+ browser_download_url: String,
334
+ }
335
+
336
+ #[derive(Debug, Deserialize)]
337
+ struct GithubErrorBody {
338
+ message: Option<String>,
339
+ }
340
+
341
+ fn publish_to_github(
342
+ report: &mut PublishReport,
343
+ args: &PublishArgs,
344
+ token: &str,
345
+ agent: &ureq::Agent,
346
+ ) -> Result<()> {
347
+ let artifact_path = Path::new(report.make.artifact().as_str());
348
+ let github = report
349
+ .github
350
+ .as_mut()
351
+ .context("GitHub publish plan was not built")?;
352
+ if github.repo.is_empty() || !valid_github_repo(&github.repo) {
353
+ bail!("GitHub repository must be configured as OWNER/REPO.");
354
+ }
355
+
356
+ let release = get_or_create_github_release(agent, token, github)?;
357
+ if let Some(asset) = release
358
+ .assets
359
+ .iter()
360
+ .find(|asset| asset.name == github.artifact_name)
361
+ {
362
+ if args.force {
363
+ delete_github_asset(agent, token, github, asset.id)?;
364
+ } else {
365
+ bail!(
366
+ "GitHub release asset already exists: {}. Use --force to replace it.",
367
+ github.artifact_name
368
+ );
369
+ }
370
+ }
371
+
372
+ let asset = upload_github_asset(agent, token, github, &release.upload_url, artifact_path)?;
373
+ github.release_url = Some(release.html_url);
374
+ github.asset_url = Some(asset.browser_download_url);
375
+
376
+ Ok(())
377
+ }
378
+
379
+ fn get_or_create_github_release(
380
+ agent: &ureq::Agent,
381
+ token: &str,
382
+ github: &GithubPublishPlan,
383
+ ) -> Result<GithubRelease> {
384
+ let get_url = format!(
385
+ "{}/repos/{}/releases/tags/{}",
386
+ github.api_url,
387
+ github.repo,
388
+ encode_url_component(&github.tag)
389
+ );
390
+ let response = github_request(agent.get(&get_url), token)
391
+ .call()
392
+ .with_context(|| format!("Could not query GitHub release {}", github.tag))?;
393
+ match response.status().as_u16() {
394
+ 200 => parse_github_response(response, "Could not parse GitHub release"),
395
+ 404 => create_github_release(agent, token, github),
396
+ status => github_status_error(response, status, "Could not query GitHub release"),
397
+ }
398
+ }
399
+
400
+ fn create_github_release(
401
+ agent: &ureq::Agent,
402
+ token: &str,
403
+ github: &GithubPublishPlan,
404
+ ) -> Result<GithubRelease> {
405
+ let url = format!("{}/repos/{}/releases", github.api_url, github.repo);
406
+ let body = format!(
407
+ "{} {} {} artifact published by electron-cli.",
408
+ github.artifact_name, github.tag, github.repo
409
+ );
410
+ let request = CreateGithubRelease {
411
+ tag_name: &github.tag,
412
+ name: &github.release_name,
413
+ body: &body,
414
+ draft: github.draft,
415
+ prerelease: github.prerelease,
416
+ };
417
+ let response = github_request(agent.post(&url), token)
418
+ .content_type("application/json")
419
+ .send_json(&request)
420
+ .with_context(|| format!("Could not create GitHub release {}", github.tag))?;
421
+ let status = response.status().as_u16();
422
+ if status == 201 {
423
+ parse_github_response(response, "Could not parse created GitHub release")
424
+ } else {
425
+ github_status_error(response, status, "Could not create GitHub release")
426
+ }
427
+ }
428
+
429
+ fn delete_github_asset(
430
+ agent: &ureq::Agent,
431
+ token: &str,
432
+ github: &GithubPublishPlan,
433
+ asset_id: u64,
434
+ ) -> Result<()> {
435
+ let url = format!(
436
+ "{}/repos/{}/releases/assets/{}",
437
+ github.api_url, github.repo, asset_id
438
+ );
439
+ let response = github_request(agent.delete(&url), token)
440
+ .call()
441
+ .with_context(|| format!("Could not delete GitHub release asset {asset_id}"))?;
442
+ let status = response.status().as_u16();
443
+ if status == 204 {
444
+ Ok(())
445
+ } else {
446
+ github_status_error(response, status, "Could not delete GitHub release asset")
447
+ }
448
+ }
449
+
450
+ fn upload_github_asset(
451
+ agent: &ureq::Agent,
452
+ token: &str,
453
+ github: &GithubPublishPlan,
454
+ upload_url: &str,
455
+ artifact_path: &Path,
456
+ ) -> Result<GithubAsset> {
457
+ let url = github_asset_upload_url(upload_url, &github.artifact_name);
458
+ let file = File::open(artifact_path)
459
+ .with_context(|| format!("Could not open {}", artifact_path.display()))?;
460
+ let response = github_request(agent.post(&url), token)
461
+ .content_type("application/octet-stream")
462
+ .send(file)
463
+ .with_context(|| format!("Could not upload {}", artifact_path.display()))?;
464
+ let status = response.status().as_u16();
465
+ if status == 201 {
466
+ parse_github_response(response, "Could not parse uploaded GitHub asset")
467
+ } else {
468
+ github_status_error(response, status, "Could not upload GitHub release asset")
469
+ }
470
+ }
471
+
472
+ fn github_request<T>(request: ureq::RequestBuilder<T>, token: &str) -> ureq::RequestBuilder<T> {
473
+ request
474
+ .header("Accept", "application/vnd.github+json")
475
+ .header("Authorization", format!("Bearer {token}"))
476
+ .header(
477
+ "User-Agent",
478
+ format!("electron-cli/{}", env!("CARGO_PKG_VERSION")),
479
+ )
480
+ }
481
+
482
+ fn parse_github_response<T: DeserializeOwned>(
483
+ mut response: ureq::http::Response<ureq::Body>,
484
+ context: &str,
485
+ ) -> Result<T> {
486
+ response
487
+ .body_mut()
488
+ .read_json::<T>()
489
+ .with_context(|| context.to_string())
490
+ }
491
+
492
+ fn github_status_error<T>(
493
+ mut response: ureq::http::Response<ureq::Body>,
494
+ status: u16,
495
+ context: &str,
496
+ ) -> Result<T> {
497
+ let body = response.body_mut().read_to_string().unwrap_or_default();
498
+ let message = serde_json::from_str::<GithubErrorBody>(&body)
499
+ .ok()
500
+ .and_then(|body| body.message)
501
+ .filter(|message| !message.trim().is_empty())
502
+ .unwrap_or(body);
503
+ bail!("{context}: HTTP {status}: {message}")
504
+ }
505
+
506
+ fn github_agent() -> ureq::Agent {
507
+ ureq::Agent::config_builder()
508
+ .http_status_as_error(false)
509
+ .timeout_global(Some(Duration::from_secs(300)))
510
+ .build()
511
+ .into()
512
+ }
513
+
514
+ fn github_token() -> Result<String> {
515
+ std::env::var("GITHUB_TOKEN")
516
+ .or_else(|_| std::env::var("GH_TOKEN"))
517
+ .context("GitHub publisher requires GITHUB_TOKEN or GH_TOKEN")
518
+ }
519
+
196
520
  fn build_manifest(
197
521
  report: &PublishReport,
198
522
  published_at_unix_seconds: u64,
199
523
  ) -> Result<PublishManifest> {
200
- let destination_artifact = Path::new(report.destination_artifact.as_str());
524
+ let local = report
525
+ .local
526
+ .as_ref()
527
+ .context("Local publish manifest requires a local publish plan")?;
528
+ let destination_artifact = Path::new(local.destination_artifact.as_str());
201
529
  let artifact_size = fs::metadata(destination_artifact)
202
530
  .with_context(|| format!("Could not stat {}", destination_artifact.display()))?
203
531
  .len();
@@ -220,7 +548,7 @@ fn build_manifest(
220
548
  published_at_unix_seconds,
221
549
  artifacts: vec![PublishedArtifact {
222
550
  file: artifact_file,
223
- path: report.destination_artifact.clone(),
551
+ path: local.destination_artifact.clone(),
224
552
  size: artifact_size,
225
553
  }],
226
554
  })
@@ -252,8 +580,22 @@ fn print_report(report: &PublishReport, json: bool) -> Result<()> {
252
580
 
253
581
  println!();
254
582
  println!("Publish");
255
- println!(" artifact: {}", report.destination_artifact);
256
- println!(" manifest: {}", report.manifest);
583
+ if let Some(local) = &report.local {
584
+ println!(" artifact: {}", local.destination_artifact);
585
+ println!(" manifest: {}", local.manifest);
586
+ }
587
+ if let Some(github) = &report.github {
588
+ println!(" repository: {}", github.repo);
589
+ println!(" tag: {}", github.tag);
590
+ println!(" release: {}", github.release_name);
591
+ println!(" artifact: {}", github.artifact_name);
592
+ if let Some(url) = &github.release_url {
593
+ println!(" release url: {url}");
594
+ }
595
+ if let Some(url) = &github.asset_url {
596
+ println!(" asset url: {url}");
597
+ }
598
+ }
257
599
 
258
600
  if !report.warnings.is_empty() {
259
601
  println!();
@@ -274,6 +616,80 @@ fn resolve_destination(root: &Path, destination: &Path) -> PathBuf {
274
616
  }
275
617
  }
276
618
 
619
+ fn default_github_tag(make: &MakeReport, channel: &str) -> String {
620
+ make.package()
621
+ .project()
622
+ .version
623
+ .as_deref()
624
+ .map(|version| {
625
+ if version.starts_with('v') {
626
+ version.to_string()
627
+ } else {
628
+ format!("v{version}")
629
+ }
630
+ })
631
+ .unwrap_or_else(|| channel.to_string())
632
+ }
633
+
634
+ fn valid_github_repo(repo: &str) -> bool {
635
+ let mut parts = repo.split('/');
636
+ let Some(owner) = parts.next() else {
637
+ return false;
638
+ };
639
+ let Some(name) = parts.next() else {
640
+ return false;
641
+ };
642
+ parts.next().is_none() && valid_github_path_part(owner) && valid_github_path_part(name)
643
+ }
644
+
645
+ fn valid_github_path_part(part: &str) -> bool {
646
+ !part.is_empty()
647
+ && part
648
+ .chars()
649
+ .all(|char| char.is_ascii_alphanumeric() || matches!(char, '-' | '_' | '.'))
650
+ }
651
+
652
+ fn github_repo_from_repository(repository: &str) -> Option<String> {
653
+ let mut value = repository.trim().trim_start_matches("git+").to_string();
654
+ if let Some(fragment) = value.find('#') {
655
+ value.truncate(fragment);
656
+ }
657
+ let value = value.trim_end_matches(".git");
658
+ let path = value
659
+ .split_once("github.com:")
660
+ .map(|(_, path)| path)
661
+ .or_else(|| value.split_once("github.com/").map(|(_, path)| path))?;
662
+ let mut parts = path.trim_matches('/').split('/');
663
+ let owner = parts.next()?.trim();
664
+ let repo = parts.next()?.trim();
665
+ if owner.is_empty() || repo.is_empty() {
666
+ None
667
+ } else {
668
+ Some(format!("{owner}/{repo}"))
669
+ }
670
+ }
671
+
672
+ fn github_asset_upload_url(upload_url: &str, artifact_name: &str) -> String {
673
+ let base = upload_url.split('{').next().unwrap_or(upload_url);
674
+ let separator = if base.contains('?') { '&' } else { '?' };
675
+ format!(
676
+ "{base}{separator}name={}",
677
+ encode_url_component(artifact_name)
678
+ )
679
+ }
680
+
681
+ fn encode_url_component(value: &str) -> String {
682
+ let mut encoded = String::new();
683
+ for byte in value.bytes() {
684
+ if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
685
+ encoded.push(byte as char);
686
+ } else {
687
+ encoded.push_str(&format!("%{byte:02X}"));
688
+ }
689
+ }
690
+ encoded
691
+ }
692
+
277
693
  fn now_unix_seconds() -> Result<u64> {
278
694
  Ok(SystemTime::now()
279
695
  .duration_since(UNIX_EPOCH)
@@ -302,6 +718,12 @@ impl PublishStatus {
302
718
  #[cfg(test)]
303
719
  mod tests {
304
720
  use super::*;
721
+ use std::{
722
+ io::{Read, Write},
723
+ net::{TcpListener, TcpStream},
724
+ sync::{Arc, Mutex},
725
+ thread,
726
+ };
305
727
 
306
728
  #[test]
307
729
  fn builds_local_publish_report() {
@@ -315,7 +737,8 @@ mod tests {
315
737
 
316
738
  assert_eq!(report.publisher, "local");
317
739
  assert_eq!(report.channel, "default");
318
- assert!(Path::new(report.destination_artifact.as_str()).ends_with(
740
+ let local = report.local.as_ref().expect("local plan should exist");
741
+ assert!(Path::new(local.destination_artifact.as_str()).ends_with(
319
742
  PathBuf::from("out")
320
743
  .join("publish")
321
744
  .join("local")
@@ -344,16 +767,103 @@ mod tests {
344
767
 
345
768
  execute_publish(&mut report, &args).expect("publish should succeed");
346
769
 
347
- assert!(Path::new(report.destination_artifact.as_str()).exists());
348
- assert!(Path::new(report.manifest.as_str()).exists());
349
- let manifest =
350
- fs::read_to_string(report.manifest.as_str()).expect("manifest should be readable");
770
+ let local = report.local.as_ref().expect("local plan should exist");
771
+ assert!(Path::new(local.destination_artifact.as_str()).exists());
772
+ assert!(Path::new(local.manifest.as_str()).exists());
773
+ let manifest = fs::read_to_string(local.manifest.as_str()).expect("manifest should read");
351
774
  assert!(manifest.contains("\"publisher\": \"local\""));
352
775
  assert!(manifest.contains("\"app_name\": \"starter-app\""));
353
776
 
354
777
  let _ = fs::remove_dir_all(root);
355
778
  }
356
779
 
780
+ #[test]
781
+ fn builds_github_publish_report_from_package_repository() {
782
+ let root = unique_temp_dir("github-plan");
783
+ write_github_package_json(&root);
784
+ write_app_file(&root);
785
+ write_fake_electron_dist(&root);
786
+
787
+ let args = github_publish_args(root.clone(), true, "http://127.0.0.1:9");
788
+ let report = build_report(&args).expect("report should build");
789
+
790
+ assert_eq!(report.publisher, "github");
791
+ assert!(report.local.is_none());
792
+ let github = report.github.as_ref().expect("github plan should exist");
793
+ assert_eq!(github.repo, "Ikana/electron-cli");
794
+ assert_eq!(github.tag, "v0.1.0");
795
+ assert_eq!(github.release_name, "v0.1.0");
796
+ assert!(!github.draft);
797
+ assert!(!github.prerelease);
798
+ assert_eq!(
799
+ github.artifact_name,
800
+ format!(
801
+ "starter-app-{}-{}.zip",
802
+ report.make.package().platform(),
803
+ report.make.package().arch()
804
+ )
805
+ );
806
+
807
+ let _ = fs::remove_dir_all(root);
808
+ }
809
+
810
+ #[test]
811
+ fn publishes_make_artifact_to_github_release() {
812
+ let server = MockGithubServer::new(3);
813
+ let root = unique_temp_dir("github-execute");
814
+ write_github_package_json(&root);
815
+ write_app_file(&root);
816
+ write_fake_electron_dist(&root);
817
+
818
+ let mut args = github_publish_args(root.clone(), false, &server.api_url);
819
+ args.skip_make = true;
820
+ let mut report = build_report(&args).expect("report should build");
821
+ let artifact = Path::new(report.make.artifact().as_str());
822
+ fs::create_dir_all(artifact.parent().expect("artifact parent should exist"))
823
+ .expect("artifact parent should be created");
824
+ fs::write(artifact, b"artifact bytes").expect("artifact should be written");
825
+
826
+ let agent = github_agent();
827
+ publish_to_github(&mut report, &args, "test-token", &agent)
828
+ .expect("github publish should succeed");
829
+
830
+ let github = report.github.as_ref().expect("github plan should exist");
831
+ let artifact_name = github.artifact_name.clone();
832
+ assert_eq!(
833
+ github.release_url.as_deref(),
834
+ Some("https://github.com/Ikana/electron-cli/releases/tag/v0.1.0")
835
+ );
836
+ assert_eq!(
837
+ github.asset_url.as_deref(),
838
+ Some("https://github.com/Ikana/electron-cli/releases/download/v0.1.0/starter-app.zip")
839
+ );
840
+
841
+ let requests = server.finish();
842
+ assert_eq!(requests.len(), 3);
843
+ assert_eq!(requests[0].method, "GET");
844
+ assert_eq!(
845
+ requests[0].path,
846
+ "/repos/Ikana/electron-cli/releases/tags/v0.1.0"
847
+ );
848
+ assert_eq!(
849
+ requests[0].header("authorization").as_deref(),
850
+ Some("Bearer test-token")
851
+ );
852
+ assert_eq!(requests[1].method, "POST");
853
+ assert_eq!(requests[1].path, "/repos/Ikana/electron-cli/releases");
854
+ let release_body =
855
+ String::from_utf8(requests[1].body.clone()).expect("release body should be utf-8");
856
+ assert!(release_body.contains("\"tag_name\": \"v0.1.0\""));
857
+ assert_eq!(requests[2].method, "POST");
858
+ assert_eq!(
859
+ requests[2].path,
860
+ format!("/uploads/1?name={}", encode_url_component(&artifact_name))
861
+ );
862
+ assert_eq!(requests[2].body, b"artifact bytes");
863
+
864
+ let _ = fs::remove_dir_all(root);
865
+ }
866
+
357
867
  #[test]
358
868
  fn skip_make_requires_existing_artifact() {
359
869
  let root = unique_temp_dir("skip-make");
@@ -370,6 +880,22 @@ mod tests {
370
880
  let _ = fs::remove_dir_all(root);
371
881
  }
372
882
 
883
+ #[test]
884
+ fn parses_github_repository_urls() {
885
+ assert_eq!(
886
+ github_repo_from_repository("git+https://github.com/Ikana/electron-cli.git"),
887
+ Some("Ikana/electron-cli".to_string())
888
+ );
889
+ assert_eq!(
890
+ github_repo_from_repository("git@github.com:Ikana/electron-cli.git"),
891
+ Some("Ikana/electron-cli".to_string())
892
+ );
893
+ assert_eq!(
894
+ github_repo_from_repository("https://example.com/Ikana/electron-cli.git"),
895
+ None
896
+ );
897
+ }
898
+
373
899
  fn publish_args(root: PathBuf, dry_run: bool) -> PublishArgs {
374
900
  PublishArgs {
375
901
  cwd: root,
@@ -380,6 +906,12 @@ mod tests {
380
906
  target: crate::cli::MakeTarget::Zip,
381
907
  publisher: crate::cli::PublishTarget::Local,
382
908
  to: PathBuf::from("out/publish/local"),
909
+ github_repo: None,
910
+ github_tag: None,
911
+ github_release_name: None,
912
+ github_draft: false,
913
+ github_prerelease: false,
914
+ github_api_url: "https://api.github.com".to_string(),
383
915
  channel: "default".to_string(),
384
916
  skip_make: false,
385
917
  force: false,
@@ -388,6 +920,13 @@ mod tests {
388
920
  }
389
921
  }
390
922
 
923
+ fn github_publish_args(root: PathBuf, dry_run: bool, api_url: &str) -> PublishArgs {
924
+ let mut args = publish_args(root, dry_run);
925
+ args.publisher = crate::cli::PublishTarget::Github;
926
+ args.github_api_url = api_url.to_string();
927
+ args
928
+ }
929
+
391
930
  fn write_package_json(root: &Path) {
392
931
  fs::write(
393
932
  root.join("package.json"),
@@ -396,6 +935,14 @@ mod tests {
396
935
  .expect("package.json should be written");
397
936
  }
398
937
 
938
+ fn write_github_package_json(root: &Path) {
939
+ fs::write(
940
+ root.join("package.json"),
941
+ r#"{"name":"starter-app","version":"0.1.0","repository":{"type":"git","url":"git+https://github.com/Ikana/electron-cli.git"},"main":"src/main.js","devDependencies":{"electron":"30.0.0"}}"#,
942
+ )
943
+ .expect("package.json should be written");
944
+ }
945
+
399
946
  fn write_app_file(root: &Path) {
400
947
  fs::create_dir_all(root.join("src")).expect("src should be created");
401
948
  fs::write(root.join("src/main.js"), "console.log('hello');")
@@ -429,4 +976,151 @@ mod tests {
429
976
  fs::create_dir_all(&path).expect("temp dir should be created");
430
977
  path
431
978
  }
979
+
980
+ #[derive(Debug, Clone)]
981
+ struct RecordedRequest {
982
+ method: String,
983
+ path: String,
984
+ headers: Vec<(String, String)>,
985
+ body: Vec<u8>,
986
+ }
987
+
988
+ impl RecordedRequest {
989
+ fn header(&self, name: &str) -> Option<String> {
990
+ self.headers
991
+ .iter()
992
+ .find(|(key, _)| key.eq_ignore_ascii_case(name))
993
+ .map(|(_, value)| value.clone())
994
+ }
995
+ }
996
+
997
+ struct MockGithubServer {
998
+ api_url: String,
999
+ requests: Arc<Mutex<Vec<RecordedRequest>>>,
1000
+ handle: Option<thread::JoinHandle<()>>,
1001
+ }
1002
+
1003
+ impl MockGithubServer {
1004
+ fn new(request_count: usize) -> Self {
1005
+ let listener = TcpListener::bind("127.0.0.1:0").expect("server should bind");
1006
+ let address = listener.local_addr().expect("server address should read");
1007
+ let api_url = format!("http://{address}");
1008
+ let requests = Arc::new(Mutex::new(Vec::new()));
1009
+ let thread_requests = Arc::clone(&requests);
1010
+ let thread_api_url = api_url.clone();
1011
+ let handle = thread::spawn(move || {
1012
+ for index in 0..request_count {
1013
+ let (mut stream, _) = listener.accept().expect("request should connect");
1014
+ let request = read_http_request(&mut stream);
1015
+ thread_requests
1016
+ .lock()
1017
+ .expect("requests should lock")
1018
+ .push(request);
1019
+
1020
+ match index {
1021
+ 0 => write_http_response(&mut stream, 404, r#"{"message":"Not Found"}"#),
1022
+ 1 => write_http_response(
1023
+ &mut stream,
1024
+ 201,
1025
+ &format!(
1026
+ r#"{{
1027
+ "html_url":"https://github.com/Ikana/electron-cli/releases/tag/v0.1.0",
1028
+ "upload_url":"{}/uploads/1{{?name,label}}",
1029
+ "assets":[]
1030
+ }}"#,
1031
+ thread_api_url
1032
+ ),
1033
+ ),
1034
+ _ => write_http_response(
1035
+ &mut stream,
1036
+ 201,
1037
+ r#"{
1038
+ "id":42,
1039
+ "name":"starter-app",
1040
+ "browser_download_url":"https://github.com/Ikana/electron-cli/releases/download/v0.1.0/starter-app.zip"
1041
+ }"#,
1042
+ ),
1043
+ }
1044
+ }
1045
+ });
1046
+
1047
+ Self {
1048
+ api_url,
1049
+ requests,
1050
+ handle: Some(handle),
1051
+ }
1052
+ }
1053
+
1054
+ fn finish(mut self) -> Vec<RecordedRequest> {
1055
+ if let Some(handle) = self.handle.take() {
1056
+ handle.join().expect("server thread should finish");
1057
+ }
1058
+ self.requests.lock().expect("requests should lock").clone()
1059
+ }
1060
+ }
1061
+
1062
+ fn read_http_request(stream: &mut TcpStream) -> RecordedRequest {
1063
+ let mut bytes = Vec::new();
1064
+ let header_end = loop {
1065
+ let mut buffer = [0; 1024];
1066
+ let read = stream.read(&mut buffer).expect("request should read");
1067
+ assert!(read > 0, "connection closed before request headers");
1068
+ bytes.extend_from_slice(&buffer[..read]);
1069
+ if let Some(position) = bytes.windows(4).position(|window| window == b"\r\n\r\n") {
1070
+ break position + 4;
1071
+ }
1072
+ };
1073
+
1074
+ let headers_text =
1075
+ String::from_utf8(bytes[..header_end].to_vec()).expect("headers should be utf-8");
1076
+ let mut lines = headers_text.split("\r\n");
1077
+ let request_line = lines.next().expect("request line should exist");
1078
+ let mut request_parts = request_line.split_whitespace();
1079
+ let method = request_parts
1080
+ .next()
1081
+ .expect("request method should exist")
1082
+ .to_string();
1083
+ let path = request_parts
1084
+ .next()
1085
+ .expect("request path should exist")
1086
+ .to_string();
1087
+ let headers = lines
1088
+ .filter_map(|line| line.split_once(':'))
1089
+ .map(|(key, value)| (key.trim().to_string(), value.trim().to_string()))
1090
+ .collect::<Vec<_>>();
1091
+ let content_length = headers
1092
+ .iter()
1093
+ .find(|(key, _)| key.eq_ignore_ascii_case("content-length"))
1094
+ .and_then(|(_, value)| value.parse::<usize>().ok())
1095
+ .unwrap_or(0);
1096
+ while bytes.len() < header_end + content_length {
1097
+ let mut buffer = [0; 1024];
1098
+ let read = stream.read(&mut buffer).expect("request body should read");
1099
+ assert!(read > 0, "connection closed before request body");
1100
+ bytes.extend_from_slice(&buffer[..read]);
1101
+ }
1102
+ let body = bytes[header_end..header_end + content_length].to_vec();
1103
+
1104
+ RecordedRequest {
1105
+ method,
1106
+ path,
1107
+ headers,
1108
+ body,
1109
+ }
1110
+ }
1111
+
1112
+ fn write_http_response(stream: &mut TcpStream, status: u16, body: &str) {
1113
+ let reason = match status {
1114
+ 201 => "Created",
1115
+ 404 => "Not Found",
1116
+ _ => "OK",
1117
+ };
1118
+ write!(
1119
+ stream,
1120
+ "HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
1121
+ body.len(),
1122
+ body
1123
+ )
1124
+ .expect("response should write");
1125
+ }
432
1126
  }