electron-cli 0.3.0-alpha.13 → 0.3.0-alpha.15

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.
@@ -8,20 +8,28 @@ use std::{
8
8
  use anyhow::{bail, Context, Result};
9
9
  use camino::Utf8PathBuf;
10
10
  use serde::{de::DeserializeOwned, Deserialize, Serialize};
11
+ use serde_json::{Map as JsonMap, Value as JsonValue};
11
12
 
12
13
  use crate::{
13
14
  cli::{MakeArgs, PublishArgs, PublishTarget},
14
15
  commands::make::{self, MakeReport},
15
16
  output,
17
+ project::ProjectSnapshot,
16
18
  };
17
19
 
18
20
  #[derive(Debug, Serialize)]
19
21
  struct PublishReport {
20
22
  make: MakeReport,
21
23
  publisher: String,
24
+ #[serde(skip)]
25
+ publisher_kind: PublishTarget,
22
26
  channel: String,
23
27
  local: Option<LocalPublishPlan>,
24
28
  github: Option<GithubPublishPlan>,
29
+ #[serde(skip)]
30
+ force_publish: bool,
31
+ #[serde(skip)]
32
+ github_auth_token: Option<String>,
25
33
  skip_make: bool,
26
34
  dry_run: bool,
27
35
  status: PublishStatus,
@@ -49,13 +57,21 @@ struct GithubPublishPlan {
49
57
  asset_url: Option<String>,
50
58
  }
51
59
 
52
- #[derive(Debug, Serialize)]
60
+ #[derive(Clone, Copy, Debug, Serialize)]
53
61
  #[serde(rename_all = "kebab-case")]
54
62
  enum PublishStatus {
55
63
  Planned,
56
64
  Published,
57
65
  }
58
66
 
67
+ #[derive(Debug, Serialize)]
68
+ struct PublishRunReport<'a> {
69
+ publishes: &'a [PublishReport],
70
+ dry_run: bool,
71
+ status: PublishStatus,
72
+ warnings: Vec<String>,
73
+ }
74
+
59
75
  #[derive(Debug, Serialize)]
60
76
  struct PublishManifest {
61
77
  schema_version: u8,
@@ -78,33 +94,112 @@ struct PublishedArtifact {
78
94
  size: u64,
79
95
  }
80
96
 
97
+ #[derive(Debug)]
98
+ struct ResolvedPublishers {
99
+ publishers: Vec<ResolvedPublisher>,
100
+ warnings: Vec<String>,
101
+ }
102
+
103
+ #[derive(Clone, Debug)]
104
+ struct ResolvedPublisher {
105
+ target: PublishTarget,
106
+ to: PathBuf,
107
+ channel: String,
108
+ github_repo: Option<String>,
109
+ github_tag: Option<String>,
110
+ github_tag_prefix: Option<String>,
111
+ github_release_name: Option<String>,
112
+ github_draft: bool,
113
+ github_prerelease: bool,
114
+ github_api_url: String,
115
+ github_auth_token: Option<String>,
116
+ force_publish: bool,
117
+ }
118
+
119
+ #[derive(Debug)]
120
+ struct ConfiguredPublisher {
121
+ label: String,
122
+ target: Option<PublishTarget>,
123
+ platforms: Vec<String>,
124
+ to: Option<PathBuf>,
125
+ channel: Option<String>,
126
+ github_repo: Option<String>,
127
+ github_tag: Option<String>,
128
+ github_tag_prefix: Option<String>,
129
+ github_release_name: Option<String>,
130
+ github_draft: Option<bool>,
131
+ github_prerelease: Option<bool>,
132
+ github_api_url: Option<String>,
133
+ github_auth_token: Option<String>,
134
+ force_publish: Option<bool>,
135
+ }
136
+
81
137
  pub fn run(args: PublishArgs) -> Result<()> {
82
- let mut report = build_report(&args)?;
138
+ let mut make_reports = make::build_reports(&make_args(&args))?;
139
+ let mut reports = build_reports_from_make_reports(&args, &make_reports)?;
83
140
 
84
141
  if args.dry_run {
85
- return print_report(&report, args.json);
142
+ return print_reports(&reports, args.json, PublishStatus::Planned);
86
143
  }
87
144
 
88
- execute_publish(&mut report, &args)?;
89
- report.status = PublishStatus::Published;
145
+ execute_publish_reports(&mut reports, &mut make_reports, &args)?;
90
146
 
91
- print_report(&report, args.json)
147
+ print_reports(&reports, args.json, PublishStatus::Published)
92
148
  }
93
149
 
150
+ #[cfg(test)]
94
151
  fn build_report(args: &PublishArgs) -> Result<PublishReport> {
95
- let make_args = MakeArgs {
96
- cwd: args.cwd.clone(),
97
- out_dir: args.out_dir.clone(),
98
- name: args.name.clone(),
99
- platform: args.platform.clone(),
100
- arch: args.arch.clone(),
101
- target: Some(args.target),
102
- skip_package: false,
103
- force: args.force,
104
- dry_run: false,
105
- json: false,
106
- };
107
- let make = make::build_report(&make_args)?;
152
+ let reports = build_reports(args)?;
153
+ if reports.len() != 1 {
154
+ bail!(
155
+ "Expected one publish target, but resolved {}. Pass --target and --publisher to select one target.",
156
+ reports.len()
157
+ );
158
+ }
159
+ Ok(reports
160
+ .into_iter()
161
+ .next()
162
+ .expect("length was checked above"))
163
+ }
164
+
165
+ #[cfg(test)]
166
+ fn build_reports(args: &PublishArgs) -> Result<Vec<PublishReport>> {
167
+ let make_reports = make::build_reports(&make_args(args))?;
168
+ build_reports_from_make_reports(args, &make_reports)
169
+ }
170
+
171
+ fn build_reports_from_make_reports(
172
+ args: &PublishArgs,
173
+ make_reports: &[MakeReport],
174
+ ) -> Result<Vec<PublishReport>> {
175
+ let first_make = make_reports
176
+ .first()
177
+ .context("No make targets were resolved for publish.")?;
178
+ let project = first_make.package().project();
179
+ let platform = first_make.package().platform();
180
+ let resolved = resolve_publishers(project, args, platform)?;
181
+ let mut reports = Vec::new();
182
+
183
+ for publisher in &resolved.publishers {
184
+ for make in make_reports {
185
+ reports.push(build_report_for_publisher(
186
+ args,
187
+ make.clone(),
188
+ publisher,
189
+ &resolved.warnings,
190
+ )?);
191
+ }
192
+ }
193
+
194
+ Ok(reports)
195
+ }
196
+
197
+ fn build_report_for_publisher(
198
+ args: &PublishArgs,
199
+ make: MakeReport,
200
+ publisher: &ResolvedPublisher,
201
+ config_warnings: &[String],
202
+ ) -> Result<PublishReport> {
108
203
  let root = Path::new(make.package().project().root.as_str());
109
204
  let artifact_name = make
110
205
  .artifact()
@@ -113,22 +208,23 @@ fn build_report(args: &PublishArgs) -> Result<PublishReport> {
113
208
  .to_string();
114
209
 
115
210
  let mut warnings = make.warnings().to_vec();
211
+ warnings.extend(config_warnings.iter().cloned());
116
212
  if args.skip_make && !Path::new(make.artifact().as_str()).exists() {
117
213
  warnings.push(format!(
118
214
  "Make artifact does not exist: {}.",
119
215
  make.artifact()
120
216
  ));
121
217
  }
122
- let (local, github) = match args.publisher {
218
+ let (local, github) = match publisher.target {
123
219
  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 {
220
+ let local = build_local_plan(root, publisher, &make, &artifact_name)?;
221
+ if Path::new(local.destination_artifact.as_str()).exists() && !publisher.force_publish {
126
222
  warnings.push(format!(
127
223
  "Publish artifact already exists: {}. Use --force to overwrite it.",
128
224
  local.destination_artifact
129
225
  ));
130
226
  }
131
- if Path::new(local.manifest.as_str()).exists() && !args.force {
227
+ if Path::new(local.manifest.as_str()).exists() && !publisher.force_publish {
132
228
  warnings.push(format!(
133
229
  "Publish manifest already exists: {}. Use --force to overwrite it.",
134
230
  local.manifest
@@ -137,17 +233,20 @@ fn build_report(args: &PublishArgs) -> Result<PublishReport> {
137
233
  (Some(local), None)
138
234
  }
139
235
  PublishTarget::Github => {
140
- let github = build_github_plan(args, &make, &artifact_name, &mut warnings)?;
236
+ let github = build_github_plan(publisher, &make, &artifact_name, &mut warnings)?;
141
237
  (None, Some(github))
142
238
  }
143
239
  };
144
240
 
145
241
  Ok(PublishReport {
146
242
  make,
147
- publisher: args.publisher.as_str().to_string(),
148
- channel: args.channel.clone(),
243
+ publisher: publisher.target.as_str().to_string(),
244
+ publisher_kind: publisher.target,
245
+ channel: publisher.channel.clone(),
149
246
  local,
150
247
  github,
248
+ force_publish: publisher.force_publish,
249
+ github_auth_token: publisher.github_auth_token.clone(),
151
250
  skip_make: args.skip_make,
152
251
  dry_run: args.dry_run,
153
252
  status: PublishStatus::Planned,
@@ -158,13 +257,13 @@ fn build_report(args: &PublishArgs) -> Result<PublishReport> {
158
257
 
159
258
  fn build_local_plan(
160
259
  root: &Path,
161
- args: &PublishArgs,
260
+ publisher: &ResolvedPublisher,
162
261
  make: &MakeReport,
163
262
  artifact_name: &str,
164
263
  ) -> Result<LocalPublishPlan> {
165
- let publish_root = resolve_destination(root, &args.to);
264
+ let publish_root = resolve_destination(root, &publisher.to);
166
265
  let destination_dir = publish_root
167
- .join(&args.channel)
266
+ .join(&publisher.channel)
168
267
  .join(make.package().platform())
169
268
  .join(make.package().arch());
170
269
  let destination_artifact = destination_dir.join(artifact_name);
@@ -178,12 +277,12 @@ fn build_local_plan(
178
277
  }
179
278
 
180
279
  fn build_github_plan(
181
- args: &PublishArgs,
280
+ publisher: &ResolvedPublisher,
182
281
  make: &MakeReport,
183
282
  artifact_name: &str,
184
283
  warnings: &mut Vec<String>,
185
284
  ) -> Result<GithubPublishPlan> {
186
- let repo = args
285
+ let repo = publisher
187
286
  .github_repo
188
287
  .clone()
189
288
  .or_else(|| {
@@ -204,44 +303,350 @@ fn build_github_plan(
204
303
  ));
205
304
  }
206
305
 
207
- let tag = args
208
- .github_tag
209
- .clone()
210
- .unwrap_or_else(|| default_github_tag(make, &args.channel));
211
- let release_name = args
306
+ let tag = publisher.github_tag.clone().unwrap_or_else(|| {
307
+ default_github_tag(
308
+ make,
309
+ &publisher.channel,
310
+ publisher.github_tag_prefix.as_deref(),
311
+ )
312
+ });
313
+ let release_name = publisher
212
314
  .github_release_name
213
315
  .clone()
214
316
  .unwrap_or_else(|| tag.clone());
215
- let prerelease = args.github_prerelease || tag.contains('-');
317
+ let prerelease = publisher.github_prerelease || tag.contains('-');
216
318
 
217
319
  Ok(GithubPublishPlan {
218
320
  repo,
219
321
  tag,
220
322
  release_name,
221
- draft: args.github_draft,
323
+ draft: publisher.github_draft,
222
324
  prerelease,
223
- api_url: args.github_api_url.trim_end_matches('/').to_string(),
325
+ api_url: publisher.github_api_url.trim_end_matches('/').to_string(),
224
326
  artifact_name: artifact_name.to_string(),
225
327
  release_url: None,
226
328
  asset_url: None,
227
329
  })
228
330
  }
229
331
 
332
+ fn make_args(args: &PublishArgs) -> MakeArgs {
333
+ MakeArgs {
334
+ cwd: args.cwd.clone(),
335
+ out_dir: args.out_dir.clone(),
336
+ name: args.name.clone(),
337
+ platform: args.platform.clone(),
338
+ arch: args.arch.clone(),
339
+ target: args.target,
340
+ skip_package: false,
341
+ force: args.force,
342
+ dry_run: false,
343
+ json: false,
344
+ }
345
+ }
346
+
347
+ fn resolve_publishers(
348
+ project: &ProjectSnapshot,
349
+ args: &PublishArgs,
350
+ platform: &str,
351
+ ) -> Result<ResolvedPublishers> {
352
+ if let Some(target) = args.publisher {
353
+ return Ok(ResolvedPublishers {
354
+ publishers: vec![resolved_publisher_from_args(args, target, None)],
355
+ warnings: Vec::new(),
356
+ });
357
+ }
358
+
359
+ let configured = configured_publishers(project)?;
360
+ let mut warnings = Vec::new();
361
+ let mut publishers = Vec::new();
362
+
363
+ for publisher in &configured {
364
+ let Some(target) = publisher.target else {
365
+ warnings.push(format!(
366
+ "Configured publisher is not implemented yet and will be skipped: {}.",
367
+ publisher.label
368
+ ));
369
+ continue;
370
+ };
371
+ if !publisher_applies_to_platform(publisher, platform) {
372
+ continue;
373
+ }
374
+ publishers.push(resolved_publisher_from_args(args, target, Some(publisher)));
375
+ }
376
+
377
+ if publishers.is_empty() {
378
+ if !configured.is_empty() {
379
+ warnings.push(format!(
380
+ "No supported configured publishers apply to {platform}; defaulting to local. Pass --publisher to override."
381
+ ));
382
+ }
383
+ publishers.push(resolved_publisher_from_args(
384
+ args,
385
+ PublishTarget::Local,
386
+ None,
387
+ ));
388
+ }
389
+
390
+ Ok(ResolvedPublishers {
391
+ publishers,
392
+ warnings,
393
+ })
394
+ }
395
+
396
+ fn resolved_publisher_from_args(
397
+ args: &PublishArgs,
398
+ target: PublishTarget,
399
+ configured: Option<&ConfiguredPublisher>,
400
+ ) -> ResolvedPublisher {
401
+ let default_to = PathBuf::from("out/publish/local");
402
+ let default_channel = "default".to_string();
403
+ let default_github_api_url = "https://api.github.com".to_string();
404
+
405
+ ResolvedPublisher {
406
+ target,
407
+ to: args
408
+ .to
409
+ .clone()
410
+ .or_else(|| configured.and_then(|publisher| publisher.to.clone()))
411
+ .unwrap_or(default_to),
412
+ channel: args
413
+ .channel
414
+ .clone()
415
+ .or_else(|| configured.and_then(|publisher| publisher.channel.clone()))
416
+ .unwrap_or(default_channel),
417
+ github_repo: args
418
+ .github_repo
419
+ .clone()
420
+ .or_else(|| configured.and_then(|publisher| publisher.github_repo.clone())),
421
+ github_tag: args
422
+ .github_tag
423
+ .clone()
424
+ .or_else(|| configured.and_then(|publisher| publisher.github_tag.clone())),
425
+ github_tag_prefix: configured.and_then(|publisher| publisher.github_tag_prefix.clone()),
426
+ github_release_name: args
427
+ .github_release_name
428
+ .clone()
429
+ .or_else(|| configured.and_then(|publisher| publisher.github_release_name.clone())),
430
+ github_draft: args.github_draft
431
+ || configured
432
+ .and_then(|publisher| publisher.github_draft)
433
+ .unwrap_or(false),
434
+ github_prerelease: args.github_prerelease
435
+ || configured
436
+ .and_then(|publisher| publisher.github_prerelease)
437
+ .unwrap_or(false),
438
+ github_api_url: args
439
+ .github_api_url
440
+ .clone()
441
+ .or_else(|| configured.and_then(|publisher| publisher.github_api_url.clone()))
442
+ .unwrap_or(default_github_api_url),
443
+ github_auth_token: configured.and_then(|publisher| publisher.github_auth_token.clone()),
444
+ force_publish: args.force
445
+ || configured
446
+ .and_then(|publisher| publisher.force_publish)
447
+ .unwrap_or(false),
448
+ }
449
+ }
450
+
451
+ fn configured_publishers(project: &ProjectSnapshot) -> Result<Vec<ConfiguredPublisher>> {
452
+ let project_config = crate::forge_config::read(project)?;
453
+
454
+ let mut publishers = Vec::new();
455
+ for value in [
456
+ project_config
457
+ .forge()
458
+ .and_then(|forge| forge.get("publishers")),
459
+ project_config
460
+ .electron_cli()
461
+ .and_then(|config| config.get("publishers")),
462
+ ]
463
+ .into_iter()
464
+ .flatten()
465
+ {
466
+ publishers.extend(parse_publisher_list(value));
467
+ }
468
+
469
+ Ok(publishers)
470
+ }
471
+
472
+ fn parse_publisher_list(value: &JsonValue) -> Vec<ConfiguredPublisher> {
473
+ match value {
474
+ JsonValue::Array(values) => values.iter().filter_map(parse_publisher).collect(),
475
+ _ => Vec::new(),
476
+ }
477
+ }
478
+
479
+ fn parse_publisher(value: &JsonValue) -> Option<ConfiguredPublisher> {
480
+ match value {
481
+ JsonValue::String(label) => Some(ConfiguredPublisher {
482
+ label: label.clone(),
483
+ target: publisher_target(label),
484
+ platforms: Vec::new(),
485
+ to: None,
486
+ channel: None,
487
+ github_repo: None,
488
+ github_tag: None,
489
+ github_tag_prefix: None,
490
+ github_release_name: None,
491
+ github_draft: None,
492
+ github_prerelease: None,
493
+ github_api_url: None,
494
+ github_auth_token: None,
495
+ force_publish: None,
496
+ }),
497
+ JsonValue::Object(object) => {
498
+ let label = object
499
+ .get("name")
500
+ .or_else(|| object.get("publisher"))
501
+ .or_else(|| object.get("target"))
502
+ .and_then(JsonValue::as_str)?
503
+ .to_string();
504
+ Some(ConfiguredPublisher {
505
+ target: publisher_target(&label),
506
+ platforms: string_values(object.get("platforms")),
507
+ to: publisher_config_string(object, &["to", "path", "dir", "directory"])
508
+ .map(PathBuf::from),
509
+ channel: publisher_config_string(object, &["channel"]),
510
+ github_repo: publisher_config_github_repo(object),
511
+ github_tag: publisher_config_string(object, &["tag", "tagName", "tag_name"]),
512
+ github_tag_prefix: publisher_config_string(object, &["tagPrefix", "tag_prefix"]),
513
+ github_release_name: publisher_config_string(
514
+ object,
515
+ &["releaseName", "release_name"],
516
+ ),
517
+ github_draft: publisher_config_bool(object, &["draft"]),
518
+ github_prerelease: publisher_config_bool(object, &["prerelease", "preRelease"]),
519
+ github_api_url: publisher_config_api_url(object),
520
+ github_auth_token: publisher_config_string(object, &["authToken", "auth_token"]),
521
+ force_publish: publisher_config_bool(object, &["force"]),
522
+ label,
523
+ })
524
+ }
525
+ _ => None,
526
+ }
527
+ }
528
+
529
+ fn publisher_target(label: &str) -> Option<PublishTarget> {
530
+ let label = label.trim().to_ascii_lowercase();
531
+ let compact = label
532
+ .trim_start_matches("@electron-forge/")
533
+ .trim_start_matches("electron-forge-")
534
+ .trim_start_matches("publisher-");
535
+
536
+ if compact == "github"
537
+ || label.ends_with("/publisher-github")
538
+ || label.ends_with("publisher-github")
539
+ {
540
+ Some(PublishTarget::Github)
541
+ } else if compact == "local"
542
+ || label.ends_with("/publisher-local")
543
+ || label.ends_with("publisher-local")
544
+ {
545
+ Some(PublishTarget::Local)
546
+ } else {
547
+ None
548
+ }
549
+ }
550
+
551
+ fn publisher_applies_to_platform(publisher: &ConfiguredPublisher, platform: &str) -> bool {
552
+ publisher.platforms.is_empty()
553
+ || publisher
554
+ .platforms
555
+ .iter()
556
+ .any(|configured| configured == platform || configured == "*")
557
+ }
558
+
559
+ fn string_values(value: Option<&JsonValue>) -> Vec<String> {
560
+ match value {
561
+ Some(JsonValue::String(value)) => vec![value.clone()],
562
+ Some(JsonValue::Array(values)) => values
563
+ .iter()
564
+ .filter_map(JsonValue::as_str)
565
+ .map(ToOwned::to_owned)
566
+ .collect(),
567
+ _ => Vec::new(),
568
+ }
569
+ }
570
+
571
+ fn publisher_config_string(object: &JsonMap<String, JsonValue>, keys: &[&str]) -> Option<String> {
572
+ keys.iter().find_map(|key| {
573
+ publisher_config_value(object, key)
574
+ .and_then(JsonValue::as_str)
575
+ .map(str::trim)
576
+ .filter(|value| !value.is_empty())
577
+ .map(ToOwned::to_owned)
578
+ })
579
+ }
580
+
581
+ fn publisher_config_bool(object: &JsonMap<String, JsonValue>, keys: &[&str]) -> Option<bool> {
582
+ keys.iter()
583
+ .find_map(|key| publisher_config_value(object, key).and_then(JsonValue::as_bool))
584
+ }
585
+
586
+ fn publisher_config_value<'a>(
587
+ object: &'a JsonMap<String, JsonValue>,
588
+ key: &str,
589
+ ) -> Option<&'a JsonValue> {
590
+ object
591
+ .get("config")
592
+ .and_then(JsonValue::as_object)
593
+ .and_then(|config| config.get(key))
594
+ .or_else(|| object.get(key))
595
+ }
596
+
597
+ fn publisher_config_github_repo(object: &JsonMap<String, JsonValue>) -> Option<String> {
598
+ ["repository", "repo", "githubRepo", "github_repo"]
599
+ .iter()
600
+ .find_map(|key| publisher_config_value(object, key).and_then(github_repo_from_config_value))
601
+ }
602
+
603
+ fn github_repo_from_config_value(value: &JsonValue) -> Option<String> {
604
+ match value {
605
+ JsonValue::String(value) => {
606
+ let value = value.trim();
607
+ (!value.is_empty()).then(|| value.to_string())
608
+ }
609
+ JsonValue::Object(object) => {
610
+ let owner = object
611
+ .get("owner")
612
+ .or_else(|| object.get("user"))
613
+ .and_then(JsonValue::as_str)?
614
+ .trim();
615
+ let name = object
616
+ .get("name")
617
+ .or_else(|| object.get("repo"))
618
+ .and_then(JsonValue::as_str)?
619
+ .trim();
620
+ if owner.is_empty() || name.is_empty() {
621
+ None
622
+ } else {
623
+ Some(format!("{owner}/{name}"))
624
+ }
625
+ }
626
+ _ => None,
627
+ }
628
+ }
629
+
630
+ fn publisher_config_api_url(object: &JsonMap<String, JsonValue>) -> Option<String> {
631
+ publisher_config_string(object, &["apiUrl", "api_url", "baseUrl", "base_url"]).or_else(|| {
632
+ publisher_config_value(object, "octokitOptions")
633
+ .and_then(JsonValue::as_object)
634
+ .and_then(|octokit| {
635
+ octokit
636
+ .get("baseUrl")
637
+ .or_else(|| octokit.get("base_url"))
638
+ .and_then(JsonValue::as_str)
639
+ })
640
+ .map(str::trim)
641
+ .filter(|value| !value.is_empty())
642
+ .map(ToOwned::to_owned)
643
+ })
644
+ }
645
+
646
+ #[cfg(test)]
230
647
  fn execute_publish(report: &mut PublishReport, args: &PublishArgs) -> Result<()> {
231
648
  if !args.skip_make {
232
- let make_args = MakeArgs {
233
- cwd: args.cwd.clone(),
234
- out_dir: args.out_dir.clone(),
235
- name: args.name.clone(),
236
- platform: args.platform.clone(),
237
- arch: args.arch.clone(),
238
- target: Some(args.target),
239
- skip_package: false,
240
- force: args.force,
241
- dry_run: false,
242
- json: false,
243
- };
244
- make::execute_make(&mut report.make, &make_args)?;
649
+ make::execute_make(&mut report.make, &make_args(args))?;
245
650
  report.make.mark_made()?;
246
651
  } else if !Path::new(report.make.artifact().as_str()).exists() {
247
652
  bail!(
@@ -252,18 +657,68 @@ fn execute_publish(report: &mut PublishReport, args: &PublishArgs) -> Result<()>
252
657
 
253
658
  let published_at_unix_seconds = now_unix_seconds()?;
254
659
  report.published_at_unix_seconds = Some(published_at_unix_seconds);
660
+ execute_publish_destination(report, published_at_unix_seconds)?;
661
+ report.status = PublishStatus::Published;
255
662
 
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
- }
663
+ Ok(())
260
664
  }
261
665
 
262
- fn execute_local_publish(
263
- report: &PublishReport,
666
+ fn execute_publish_reports(
667
+ reports: &mut [PublishReport],
668
+ make_reports: &mut [MakeReport],
264
669
  args: &PublishArgs,
670
+ ) -> Result<()> {
671
+ if !args.skip_make {
672
+ make::execute_make_reports(make_reports, &make_args(args))?;
673
+ sync_make_reports(reports, make_reports);
674
+ } else {
675
+ ensure_make_artifacts_exist(make_reports)?;
676
+ }
677
+
678
+ let published_at_unix_seconds = now_unix_seconds()?;
679
+ for report in reports {
680
+ report.published_at_unix_seconds = Some(published_at_unix_seconds);
681
+ execute_publish_destination(report, published_at_unix_seconds)?;
682
+ report.status = PublishStatus::Published;
683
+ }
684
+
685
+ Ok(())
686
+ }
687
+
688
+ fn sync_make_reports(reports: &mut [PublishReport], make_reports: &[MakeReport]) {
689
+ for report in reports {
690
+ if let Some(make) = make_reports
691
+ .iter()
692
+ .find(|make| make.artifact().as_str() == report.make.artifact().as_str())
693
+ {
694
+ report.make = make.clone();
695
+ }
696
+ }
697
+ }
698
+
699
+ fn ensure_make_artifacts_exist(make_reports: &[MakeReport]) -> Result<()> {
700
+ for make in make_reports {
701
+ if !Path::new(make.artifact().as_str()).exists() {
702
+ bail!(
703
+ "Make artifact does not exist: {}. Run without --skip-make or run electron-cli make first.",
704
+ make.artifact()
705
+ );
706
+ }
707
+ }
708
+ Ok(())
709
+ }
710
+
711
+ fn execute_publish_destination(
712
+ report: &mut PublishReport,
265
713
  published_at_unix_seconds: u64,
266
714
  ) -> Result<()> {
715
+ match report.publisher_kind {
716
+ PublishTarget::Local => execute_local_publish(report, published_at_unix_seconds),
717
+ PublishTarget::Github => execute_github_publish(report),
718
+ }
719
+ }
720
+
721
+ fn execute_local_publish(report: &PublishReport, published_at_unix_seconds: u64) -> Result<()> {
267
722
  let local = report
268
723
  .local
269
724
  .as_ref()
@@ -273,7 +728,7 @@ fn execute_local_publish(
273
728
 
274
729
  for path in [destination_artifact, manifest] {
275
730
  if path.exists() {
276
- if args.force {
731
+ if report.force_publish {
277
732
  fs::remove_file(path)
278
733
  .with_context(|| format!("Could not remove {}", path.display()))?;
279
734
  } else {
@@ -303,10 +758,10 @@ fn execute_local_publish(
303
758
  Ok(())
304
759
  }
305
760
 
306
- fn execute_github_publish(report: &mut PublishReport, args: &PublishArgs) -> Result<()> {
307
- let token = github_token()?;
761
+ fn execute_github_publish(report: &mut PublishReport) -> Result<()> {
762
+ let token = github_token(report.github_auth_token.as_deref())?;
308
763
  let agent = github_agent();
309
- publish_to_github(report, args, &token, &agent)
764
+ publish_to_github(report, &token, &agent)
310
765
  }
311
766
 
312
767
  #[derive(Debug, Serialize)]
@@ -338,12 +793,7 @@ struct GithubErrorBody {
338
793
  message: Option<String>,
339
794
  }
340
795
 
341
- fn publish_to_github(
342
- report: &mut PublishReport,
343
- args: &PublishArgs,
344
- token: &str,
345
- agent: &ureq::Agent,
346
- ) -> Result<()> {
796
+ fn publish_to_github(report: &mut PublishReport, token: &str, agent: &ureq::Agent) -> Result<()> {
347
797
  let artifact_path = Path::new(report.make.artifact().as_str());
348
798
  let github = report
349
799
  .github
@@ -359,7 +809,7 @@ fn publish_to_github(
359
809
  .iter()
360
810
  .find(|asset| asset.name == github.artifact_name)
361
811
  {
362
- if args.force {
812
+ if report.force_publish {
363
813
  delete_github_asset(agent, token, github, asset.id)?;
364
814
  } else {
365
815
  bail!(
@@ -511,9 +961,13 @@ fn github_agent() -> ureq::Agent {
511
961
  .into()
512
962
  }
513
963
 
514
- fn github_token() -> Result<String> {
515
- std::env::var("GITHUB_TOKEN")
516
- .or_else(|_| std::env::var("GH_TOKEN"))
964
+ fn github_token(configured_token: Option<&str>) -> Result<String> {
965
+ configured_token
966
+ .map(str::trim)
967
+ .filter(|token| !token.is_empty())
968
+ .map(ToOwned::to_owned)
969
+ .or_else(|| std::env::var("GITHUB_TOKEN").ok())
970
+ .or_else(|| std::env::var("GH_TOKEN").ok())
517
971
  .context("GitHub publisher requires GITHUB_TOKEN or GH_TOKEN")
518
972
  }
519
973
 
@@ -608,6 +1062,85 @@ fn print_report(report: &PublishReport, json: bool) -> Result<()> {
608
1062
  Ok(())
609
1063
  }
610
1064
 
1065
+ fn print_reports(reports: &[PublishReport], json: bool, status: PublishStatus) -> Result<()> {
1066
+ if reports.len() == 1 {
1067
+ return print_report(&reports[0], json);
1068
+ }
1069
+
1070
+ let warnings = combined_warnings(reports);
1071
+ if json {
1072
+ return output::json(&PublishRunReport {
1073
+ publishes: reports,
1074
+ dry_run: reports.iter().any(|report| report.dry_run),
1075
+ status,
1076
+ warnings,
1077
+ });
1078
+ }
1079
+
1080
+ println!("electron-cli publish");
1081
+ println!();
1082
+ if let Some(first) = reports.first() {
1083
+ println!("Project");
1084
+ println!(" root: {}", first.make.package().project().root);
1085
+ match first.make.package().project().package_label() {
1086
+ Some(label) => println!(" package: {label}"),
1087
+ None => println!(" package: not found"),
1088
+ }
1089
+ println!(" app name: {}", first.make.package().app_name());
1090
+ println!(
1091
+ " target platform: {} {}",
1092
+ first.make.package().platform(),
1093
+ first.make.package().arch()
1094
+ );
1095
+ println!(" status: {}", status.as_str());
1096
+ }
1097
+
1098
+ println!();
1099
+ println!("Publishes");
1100
+ for report in reports {
1101
+ println!(
1102
+ " {} {}: {}",
1103
+ report.publisher,
1104
+ report.make.target(),
1105
+ report.make.artifact()
1106
+ );
1107
+ if let Some(local) = &report.local {
1108
+ println!(" artifact: {}", local.destination_artifact);
1109
+ println!(" manifest: {}", local.manifest);
1110
+ }
1111
+ if let Some(github) = &report.github {
1112
+ println!(" repository: {}", github.repo);
1113
+ println!(" tag: {}", github.tag);
1114
+ if let Some(url) = &github.release_url {
1115
+ println!(" release url: {url}");
1116
+ }
1117
+ if let Some(url) = &github.asset_url {
1118
+ println!(" asset url: {url}");
1119
+ }
1120
+ }
1121
+ }
1122
+
1123
+ if !warnings.is_empty() {
1124
+ println!();
1125
+ println!("Warnings");
1126
+ for warning in warnings {
1127
+ println!(" {warning}");
1128
+ }
1129
+ }
1130
+
1131
+ Ok(())
1132
+ }
1133
+
1134
+ fn combined_warnings(reports: &[PublishReport]) -> Vec<String> {
1135
+ let mut warnings = Vec::new();
1136
+ for warning in reports.iter().flat_map(|report| report.warnings.iter()) {
1137
+ if !warnings.contains(warning) {
1138
+ warnings.push(warning.clone());
1139
+ }
1140
+ }
1141
+ warnings
1142
+ }
1143
+
611
1144
  fn resolve_destination(root: &Path, destination: &Path) -> PathBuf {
612
1145
  if destination.is_absolute() {
613
1146
  destination.to_path_buf()
@@ -616,16 +1149,17 @@ fn resolve_destination(root: &Path, destination: &Path) -> PathBuf {
616
1149
  }
617
1150
  }
618
1151
 
619
- fn default_github_tag(make: &MakeReport, channel: &str) -> String {
1152
+ fn default_github_tag(make: &MakeReport, channel: &str, tag_prefix: Option<&str>) -> String {
620
1153
  make.package()
621
1154
  .project()
622
1155
  .version
623
1156
  .as_deref()
624
1157
  .map(|version| {
625
- if version.starts_with('v') {
1158
+ let prefix = tag_prefix.unwrap_or("v");
1159
+ if prefix.is_empty() || version.starts_with(prefix) {
626
1160
  version.to_string()
627
1161
  } else {
628
- format!("v{version}")
1162
+ format!("{prefix}{version}")
629
1163
  }
630
1164
  })
631
1165
  .unwrap_or_else(|| channel.to_string())
@@ -807,6 +1341,146 @@ mod tests {
807
1341
  let _ = fs::remove_dir_all(root);
808
1342
  }
809
1343
 
1344
+ #[test]
1345
+ fn builds_github_publish_report_from_configured_forge_publisher() {
1346
+ let root = unique_temp_dir("configured-github-plan");
1347
+ write_package_json_with_publishers(
1348
+ &root,
1349
+ r#"[
1350
+ {
1351
+ "name":"@electron-forge/publisher-github",
1352
+ "platforms":["*"],
1353
+ "config":{
1354
+ "repository":{"owner":"Ikana","name":"electron-cli"},
1355
+ "draft":true,
1356
+ "prerelease":true,
1357
+ "tagPrefix":"release-",
1358
+ "releaseName":"Configured Release",
1359
+ "baseUrl":"http://127.0.0.1:9"
1360
+ }
1361
+ }
1362
+ ]"#,
1363
+ );
1364
+ write_app_file(&root);
1365
+ write_fake_electron_dist(&root);
1366
+
1367
+ let mut args = publish_args(root.clone(), true);
1368
+ args.target = None;
1369
+ args.publisher = None;
1370
+ args.github_api_url = None;
1371
+ args.channel = None;
1372
+ let report = build_report(&args).expect("report should build");
1373
+
1374
+ assert_eq!(report.publisher, "github");
1375
+ assert!(report.local.is_none());
1376
+ assert_eq!(report.channel, "default");
1377
+ let github = report.github.as_ref().expect("github plan should exist");
1378
+ assert_eq!(github.repo, "Ikana/electron-cli");
1379
+ assert_eq!(github.tag, "release-0.1.0");
1380
+ assert_eq!(github.release_name, "Configured Release");
1381
+ assert!(github.draft);
1382
+ assert!(github.prerelease);
1383
+ assert_eq!(github.api_url, "http://127.0.0.1:9");
1384
+
1385
+ let _ = fs::remove_dir_all(root);
1386
+ }
1387
+
1388
+ #[test]
1389
+ fn builds_github_publish_report_from_static_forge_config_js() {
1390
+ let root = unique_temp_dir("configured-github-js-plan");
1391
+ write_package_json(&root);
1392
+ fs::write(
1393
+ root.join("forge.config.js"),
1394
+ r#"
1395
+ module.exports = {
1396
+ publishers: [
1397
+ {
1398
+ name: '@electron-forge/publisher-github',
1399
+ config: {
1400
+ repository: { owner: 'Ikana', name: 'electron-cli' },
1401
+ tagPrefix: 'release-',
1402
+ prerelease: true,
1403
+ },
1404
+ },
1405
+ ],
1406
+ };
1407
+ "#,
1408
+ )
1409
+ .expect("forge config should be written");
1410
+ write_app_file(&root);
1411
+ write_fake_electron_dist(&root);
1412
+
1413
+ let mut args = publish_args(root.clone(), true);
1414
+ args.publisher = None;
1415
+ args.github_api_url = None;
1416
+ args.channel = None;
1417
+ let report = build_report(&args).expect("report should build");
1418
+
1419
+ assert_eq!(report.publisher, "github");
1420
+ let github = report.github.as_ref().expect("github plan should exist");
1421
+ assert_eq!(github.repo, "Ikana/electron-cli");
1422
+ assert_eq!(github.tag, "release-0.1.0");
1423
+ assert!(github.prerelease);
1424
+
1425
+ let _ = fs::remove_dir_all(root);
1426
+ }
1427
+
1428
+ #[test]
1429
+ fn builds_publish_reports_from_configured_makers_and_publishers() {
1430
+ let root = unique_temp_dir("configured-publishers");
1431
+ write_package_json_with_makers_and_publishers(
1432
+ &root,
1433
+ r#"[
1434
+ {"name":"@electron-forge/maker-zip"},
1435
+ {"name":"@electron-forge/maker-deb","platforms":["linux"]}
1436
+ ]"#,
1437
+ r#"[
1438
+ {"name":"@electron-forge/publisher-github","config":{"repository":"Ikana/electron-cli"}},
1439
+ {"name":"local","config":{"to":"dist/publish","channel":"beta"}},
1440
+ {"name":"@electron-forge/publisher-s3"}
1441
+ ]"#,
1442
+ );
1443
+ write_app_file(&root);
1444
+ write_fake_electron_dist(&root);
1445
+
1446
+ let mut args = publish_args(root.clone(), true);
1447
+ args.platform = Some("linux".to_string());
1448
+ args.arch = Some("x64".to_string());
1449
+ args.target = None;
1450
+ args.publisher = None;
1451
+ args.to = None;
1452
+ args.channel = None;
1453
+ args.github_api_url = None;
1454
+ let reports = build_reports(&args).expect("reports should build");
1455
+
1456
+ assert_eq!(reports.len(), 4);
1457
+ assert_eq!(reports[0].publisher, "github");
1458
+ assert_eq!(reports[0].make.target(), "zip");
1459
+ assert_eq!(reports[1].publisher, "github");
1460
+ assert_eq!(reports[1].make.target(), "deb");
1461
+ assert_eq!(reports[2].publisher, "local");
1462
+ assert_eq!(reports[2].make.target(), "zip");
1463
+ assert_eq!(reports[3].publisher, "local");
1464
+ assert_eq!(reports[3].make.target(), "deb");
1465
+ let local = reports[2].local.as_ref().expect("local plan should exist");
1466
+ let local_parent = Path::new(local.destination_artifact.as_str())
1467
+ .parent()
1468
+ .expect("local artifact should have parent");
1469
+ assert!(local_parent.ends_with(
1470
+ PathBuf::from("dist")
1471
+ .join("publish")
1472
+ .join("beta")
1473
+ .join("linux")
1474
+ .join("x64")
1475
+ ));
1476
+ assert!(reports[0]
1477
+ .warnings
1478
+ .iter()
1479
+ .any(|warning| warning.contains("@electron-forge/publisher-s3")));
1480
+
1481
+ let _ = fs::remove_dir_all(root);
1482
+ }
1483
+
810
1484
  #[test]
811
1485
  fn publishes_make_artifact_to_github_release() {
812
1486
  let server = MockGithubServer::new(3);
@@ -824,7 +1498,7 @@ mod tests {
824
1498
  fs::write(artifact, b"artifact bytes").expect("artifact should be written");
825
1499
 
826
1500
  let agent = github_agent();
827
- publish_to_github(&mut report, &args, "test-token", &agent)
1501
+ publish_to_github(&mut report, "test-token", &agent)
828
1502
  .expect("github publish should succeed");
829
1503
 
830
1504
  let github = report.github.as_ref().expect("github plan should exist");
@@ -903,16 +1577,16 @@ mod tests {
903
1577
  name: None,
904
1578
  platform: None,
905
1579
  arch: None,
906
- target: crate::cli::MakeTarget::Zip,
907
- publisher: crate::cli::PublishTarget::Local,
908
- to: PathBuf::from("out/publish/local"),
1580
+ target: Some(crate::cli::MakeTarget::Zip),
1581
+ publisher: Some(crate::cli::PublishTarget::Local),
1582
+ to: Some(PathBuf::from("out/publish/local")),
909
1583
  github_repo: None,
910
1584
  github_tag: None,
911
1585
  github_release_name: None,
912
1586
  github_draft: false,
913
1587
  github_prerelease: false,
914
- github_api_url: "https://api.github.com".to_string(),
915
- channel: "default".to_string(),
1588
+ github_api_url: Some("https://api.github.com".to_string()),
1589
+ channel: Some("default".to_string()),
916
1590
  skip_make: false,
917
1591
  force: false,
918
1592
  dry_run,
@@ -922,8 +1596,8 @@ mod tests {
922
1596
 
923
1597
  fn github_publish_args(root: PathBuf, dry_run: bool, api_url: &str) -> PublishArgs {
924
1598
  let mut args = publish_args(root, dry_run);
925
- args.publisher = crate::cli::PublishTarget::Github;
926
- args.github_api_url = api_url.to_string();
1599
+ args.publisher = Some(crate::cli::PublishTarget::Github);
1600
+ args.github_api_url = Some(api_url.to_string());
927
1601
  args
928
1602
  }
929
1603
 
@@ -943,6 +1617,38 @@ mod tests {
943
1617
  .expect("package.json should be written");
944
1618
  }
945
1619
 
1620
+ fn write_package_json_with_publishers(root: &Path, publishers: &str) {
1621
+ fs::write(
1622
+ root.join("package.json"),
1623
+ format!(
1624
+ r#"{{
1625
+ "name":"starter-app",
1626
+ "version":"0.1.0",
1627
+ "main":"src/main.js",
1628
+ "devDependencies":{{"electron":"30.0.0"}},
1629
+ "config":{{"forge":{{"publishers":{publishers}}}}}
1630
+ }}"#
1631
+ ),
1632
+ )
1633
+ .expect("package.json with publishers should be written");
1634
+ }
1635
+
1636
+ fn write_package_json_with_makers_and_publishers(root: &Path, makers: &str, publishers: &str) {
1637
+ fs::write(
1638
+ root.join("package.json"),
1639
+ format!(
1640
+ r#"{{
1641
+ "name":"starter-app",
1642
+ "version":"0.1.0",
1643
+ "main":"src/main.js",
1644
+ "devDependencies":{{"electron":"30.0.0"}},
1645
+ "config":{{"forge":{{"makers":{makers},"publishers":{publishers}}}}}
1646
+ }}"#
1647
+ ),
1648
+ )
1649
+ .expect("package.json with makers and publishers should be written");
1650
+ }
1651
+
946
1652
  fn write_app_file(root: &Path) {
947
1653
  fs::create_dir_all(root.join("src")).expect("src should be created");
948
1654
  fs::write(root.join("src/main.js"), "console.log('hello');")