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

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.
package/Cargo.lock CHANGED
@@ -376,7 +376,7 @@ checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
376
376
 
377
377
  [[package]]
378
378
  name = "electron-cli"
379
- version = "0.3.0-alpha.13"
379
+ version = "0.3.0-alpha.14"
380
380
  dependencies = [
381
381
  "anyhow",
382
382
  "apple-dmg",
package/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "electron-cli"
3
- version = "0.3.0-alpha.13"
3
+ version = "0.3.0-alpha.14"
4
4
  edition = "2021"
5
5
  description = "Experimental Rust CLI for Electron project diagnostics and workflow automation"
6
6
  license = "MIT"
package/README.md CHANGED
@@ -37,9 +37,9 @@ The Rust-native flow currently owns:
37
37
  - `start`: launches the installed Electron runtime directly.
38
38
  - `package`: copies the installed Electron runtime, app files, installed production dependency closure, app metadata, macOS icon, and extra resources into a local app bundle for the current platform and architecture.
39
39
  - `make`: runs `package` and writes distributables under `out/make/<target>/<platform>/<arch>/`; it reads JSON-shaped `config.forge.makers` / `electronCli.makers` arrays when `--target` is omitted, and `--target` still forces one maker. ZIP works on all platforms, `--target dmg` writes a basic macOS disk image, `--target deb` / `--target rpm` write Linux packages, and `--target msi` writes a basic Windows Installer package.
40
- - `publish`: runs `make` and publishes the distributable to a local directory with a manifest or to GitHub Releases.
40
+ - `publish`: runs `make` and publishes distributables to a local directory with a manifest or to GitHub Releases; it reads JSON-shaped `config.forge.publishers` / `electronCli.publishers` arrays when `--publisher` is omitted, and `--publisher` still forces one publisher.
41
41
 
42
- The GitHub publisher creates or reuses a release, uploads the selected make artifact, and can replace an existing asset with `--force`. It reads `GITHUB_TOKEN` or `GH_TOKEN` and can infer `OWNER/REPO` from `package.json` `repository`, or you can pass `--github-repo`.
42
+ The GitHub publisher creates or reuses a release, uploads selected make artifacts, and can replace an existing asset with `--force`. It reads `GITHUB_TOKEN` or `GH_TOKEN` and can infer `OWNER/REPO` from package metadata, Forge GitHub publisher config, or `package.json` `repository`. You can also pass `--github-repo`.
43
43
 
44
44
  The DMG maker is currently a pure-Rust FAT32 image with the app bundle and an Applications entry. The MSI maker writes a compressed embedded CAB, Windows Installer database tables, and a Start Menu shortcut when the packaged executable is present. HFS+/APFS DMG layout customization, installer UI customization, Windows/Linux icon embedding, signing, and notarization are still TODO.
45
45
 
@@ -61,19 +61,33 @@ Package metadata can be configured in `package.json`:
61
61
  { "name": "@electron-forge/maker-deb", "platforms": ["linux"] },
62
62
  { "name": "@electron-forge/maker-rpm", "platforms": ["linux"] },
63
63
  { "name": "@electron-forge/maker-wix", "platforms": ["win32"] }
64
+ ],
65
+ "publishers": [
66
+ { "name": "local", "config": { "to": "out/publish/local", "channel": "alpha" } },
67
+ {
68
+ "name": "@electron-forge/publisher-github",
69
+ "config": {
70
+ "repository": { "owner": "example", "name": "my-app" },
71
+ "draft": true,
72
+ "prerelease": true
73
+ }
74
+ }
64
75
  ]
65
76
  },
66
77
  "config": {
67
78
  "forge": {
68
79
  "makers": [
69
80
  { "name": "@electron-forge/maker-zip" }
81
+ ],
82
+ "publishers": [
83
+ { "name": "@electron-forge/publisher-github" }
70
84
  ]
71
85
  }
72
86
  }
73
87
  }
74
88
  ```
75
89
 
76
- The package command also reads JSON-shaped `config.forge.packagerConfig` and `electronPackagerConfig` entries for the same fields. The make command maps JSON-shaped Forge maker names to the Rust-native targets it supports: zip, dmg, deb, rpm, and wix/msi. JavaScript Forge config files are not evaluated.
90
+ The package command also reads JSON-shaped `config.forge.packagerConfig` and `electronPackagerConfig` entries for the same fields. The make command maps JSON-shaped Forge maker names to the Rust-native targets it supports: zip, dmg, deb, rpm, and wix/msi. The publish command maps JSON-shaped publisher names to local and GitHub. JavaScript Forge config files are not evaluated.
77
91
 
78
92
  ## Install
79
93
 
@@ -139,7 +153,7 @@ The inspection and planning commands support `--json` so agents and scripts can
139
153
  `start --dry-run --json` shows the Electron executable that will be launched.
140
154
  `package --dry-run --json` shows the runtime, app file, metadata, icon, and extra-resource copy plan.
141
155
  `make --dry-run --json` shows the package prerequisite and selected maker artifact path.
142
- `publish --dry-run --json` shows the make prerequisite plus either the local destination/manifest path or the GitHub release/upload plan.
156
+ `publish --dry-run --json` shows the make prerequisite plus either the local destination/manifest path or the GitHub release/upload plan. When multiple configured makers or publishers apply, the JSON output contains a `publishes` array.
143
157
 
144
158
  ```sh
145
159
  electron-cli plan --json
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "electron-cli",
3
- "version": "0.3.0-alpha.13",
3
+ "version": "0.3.0-alpha.14",
4
4
  "description": "Experimental Rust CLI for Electron project diagnostics and workflow automation",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/cli.rs CHANGED
@@ -207,17 +207,17 @@ pub struct PublishArgs {
207
207
  #[arg(long)]
208
208
  pub arch: Option<String>,
209
209
 
210
- /// Maker target whose artifact should be published.
211
- #[arg(long, value_enum, default_value_t = MakeTarget::Zip)]
212
- pub target: MakeTarget,
210
+ /// Maker target whose artifact should be published. Overrides configured makers when provided.
211
+ #[arg(long, value_enum)]
212
+ pub target: Option<MakeTarget>,
213
213
 
214
- /// Publisher target to use.
215
- #[arg(long, value_enum, default_value_t = PublishTarget::Local)]
216
- pub publisher: PublishTarget,
214
+ /// Publisher target to use. Overrides configured publishers when provided.
215
+ #[arg(long, value_enum)]
216
+ pub publisher: Option<PublishTarget>,
217
217
 
218
218
  /// Destination for local published artifacts.
219
- #[arg(long, default_value = "out/publish/local", value_name = "PATH")]
220
- pub to: PathBuf,
219
+ #[arg(long, value_name = "PATH")]
220
+ pub to: Option<PathBuf>,
221
221
 
222
222
  /// GitHub repository to publish to, in OWNER/REPO form.
223
223
  #[arg(long, value_name = "OWNER/REPO")]
@@ -240,12 +240,12 @@ pub struct PublishArgs {
240
240
  pub github_prerelease: bool,
241
241
 
242
242
  /// GitHub API base URL, useful for GitHub Enterprise.
243
- #[arg(long, default_value = "https://api.github.com", value_name = "URL")]
244
- pub github_api_url: String,
243
+ #[arg(long, value_name = "URL")]
244
+ pub github_api_url: Option<String>,
245
245
 
246
246
  /// Release channel label written into the publish manifest.
247
- #[arg(long, default_value = "default")]
248
- pub channel: String,
247
+ #[arg(long)]
248
+ pub channel: Option<String>,
249
249
 
250
250
  /// Reuse an existing make artifact instead of running package and make first.
251
251
  #[arg(long)]
@@ -306,7 +306,7 @@ impl MakeTarget {
306
306
  }
307
307
  }
308
308
 
309
- #[derive(Debug, Clone, Copy, ValueEnum)]
309
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
310
310
  #[value(rename_all = "lower")]
311
311
  pub enum PublishTarget {
312
312
  Github,
@@ -27,7 +27,7 @@ use crate::{
27
27
  project::ProjectSnapshot,
28
28
  };
29
29
 
30
- #[derive(Debug, Serialize)]
30
+ #[derive(Clone, Debug, Serialize)]
31
31
  pub(crate) struct MakeReport {
32
32
  package: PackageReport,
33
33
  target: String,
@@ -74,6 +74,7 @@ pub fn run(args: MakeArgs) -> Result<()> {
74
74
  print_reports(&reports, args.json, MakeStatus::Made)
75
75
  }
76
76
 
77
+ #[cfg(test)]
77
78
  pub(crate) fn build_report(args: &MakeArgs) -> Result<MakeReport> {
78
79
  let reports = build_reports(args)?;
79
80
  if reports.len() != 1 {
@@ -345,6 +346,7 @@ fn current_platform_label() -> String {
345
346
  }
346
347
  }
347
348
 
349
+ #[cfg(test)]
348
350
  pub(crate) fn execute_make(report: &mut MakeReport, args: &MakeArgs) -> Result<()> {
349
351
  ensure_package_ready(std::slice::from_mut(report), args)?;
350
352
  execute_make_artifact(report, args)?;
@@ -12,7 +12,7 @@ use serde_json::Value as JsonValue;
12
12
 
13
13
  use crate::{cli::PackageArgs, output, project::ProjectSnapshot};
14
14
 
15
- #[derive(Debug, Serialize)]
15
+ #[derive(Clone, Debug, Serialize)]
16
16
  pub(crate) struct PackageReport {
17
17
  project: ProjectSnapshot,
18
18
  app_name: String,
@@ -31,13 +31,13 @@ pub(crate) struct PackageReport {
31
31
  warnings: Vec<String>,
32
32
  }
33
33
 
34
- #[derive(Debug, Serialize)]
34
+ #[derive(Clone, Debug, Serialize)]
35
35
  struct CopyStep {
36
36
  from: Utf8PathBuf,
37
37
  to: Utf8PathBuf,
38
38
  }
39
39
 
40
- #[derive(Debug, Serialize)]
40
+ #[derive(Clone, Debug, Serialize)]
41
41
  struct PackageMetadata {
42
42
  bundle_identifier: String,
43
43
  app_version: Option<String>,
@@ -49,13 +49,13 @@ struct PackageMetadata {
49
49
  darwin_dark_mode_support: bool,
50
50
  }
51
51
 
52
- #[derive(Debug, Serialize)]
52
+ #[derive(Clone, Debug, Serialize)]
53
53
  struct IconResource {
54
54
  from: Utf8PathBuf,
55
55
  to: Utf8PathBuf,
56
56
  }
57
57
 
58
- #[derive(Debug, Serialize)]
58
+ #[derive(Clone, Copy, Debug, Serialize)]
59
59
  #[serde(rename_all = "kebab-case")]
60
60
  enum PackageStatus {
61
61
  Planned,
@@ -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,359 @@ 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 Some(package_json_path) = &project.package_json else {
453
+ return Ok(Vec::new());
454
+ };
455
+ let package_json_path = Path::new(package_json_path.as_str());
456
+ let raw = fs::read_to_string(package_json_path)
457
+ .with_context(|| format!("Could not read {}", package_json_path.display()))?;
458
+ let package = serde_json::from_str::<JsonValue>(&raw)
459
+ .with_context(|| format!("Could not parse {}", package_json_path.display()))?;
460
+
461
+ let mut publishers = Vec::new();
462
+ for value in [
463
+ package
464
+ .get("config")
465
+ .and_then(|config| config.get("forge"))
466
+ .and_then(|forge| forge.get("publishers")),
467
+ package
468
+ .get("electronCli")
469
+ .or_else(|| package.get("electron-cli"))
470
+ .and_then(|config| config.get("publishers")),
471
+ ]
472
+ .into_iter()
473
+ .flatten()
474
+ {
475
+ publishers.extend(parse_publisher_list(value));
476
+ }
477
+
478
+ Ok(publishers)
479
+ }
480
+
481
+ fn parse_publisher_list(value: &JsonValue) -> Vec<ConfiguredPublisher> {
482
+ match value {
483
+ JsonValue::Array(values) => values.iter().filter_map(parse_publisher).collect(),
484
+ _ => Vec::new(),
485
+ }
486
+ }
487
+
488
+ fn parse_publisher(value: &JsonValue) -> Option<ConfiguredPublisher> {
489
+ match value {
490
+ JsonValue::String(label) => Some(ConfiguredPublisher {
491
+ label: label.clone(),
492
+ target: publisher_target(label),
493
+ platforms: Vec::new(),
494
+ to: None,
495
+ channel: None,
496
+ github_repo: None,
497
+ github_tag: None,
498
+ github_tag_prefix: None,
499
+ github_release_name: None,
500
+ github_draft: None,
501
+ github_prerelease: None,
502
+ github_api_url: None,
503
+ github_auth_token: None,
504
+ force_publish: None,
505
+ }),
506
+ JsonValue::Object(object) => {
507
+ let label = object
508
+ .get("name")
509
+ .or_else(|| object.get("publisher"))
510
+ .or_else(|| object.get("target"))
511
+ .and_then(JsonValue::as_str)?
512
+ .to_string();
513
+ Some(ConfiguredPublisher {
514
+ target: publisher_target(&label),
515
+ platforms: string_values(object.get("platforms")),
516
+ to: publisher_config_string(object, &["to", "path", "dir", "directory"])
517
+ .map(PathBuf::from),
518
+ channel: publisher_config_string(object, &["channel"]),
519
+ github_repo: publisher_config_github_repo(object),
520
+ github_tag: publisher_config_string(object, &["tag", "tagName", "tag_name"]),
521
+ github_tag_prefix: publisher_config_string(object, &["tagPrefix", "tag_prefix"]),
522
+ github_release_name: publisher_config_string(
523
+ object,
524
+ &["releaseName", "release_name"],
525
+ ),
526
+ github_draft: publisher_config_bool(object, &["draft"]),
527
+ github_prerelease: publisher_config_bool(object, &["prerelease", "preRelease"]),
528
+ github_api_url: publisher_config_api_url(object),
529
+ github_auth_token: publisher_config_string(object, &["authToken", "auth_token"]),
530
+ force_publish: publisher_config_bool(object, &["force"]),
531
+ label,
532
+ })
533
+ }
534
+ _ => None,
535
+ }
536
+ }
537
+
538
+ fn publisher_target(label: &str) -> Option<PublishTarget> {
539
+ let label = label.trim().to_ascii_lowercase();
540
+ let compact = label
541
+ .trim_start_matches("@electron-forge/")
542
+ .trim_start_matches("electron-forge-")
543
+ .trim_start_matches("publisher-");
544
+
545
+ if compact == "github"
546
+ || label.ends_with("/publisher-github")
547
+ || label.ends_with("publisher-github")
548
+ {
549
+ Some(PublishTarget::Github)
550
+ } else if compact == "local"
551
+ || label.ends_with("/publisher-local")
552
+ || label.ends_with("publisher-local")
553
+ {
554
+ Some(PublishTarget::Local)
555
+ } else {
556
+ None
557
+ }
558
+ }
559
+
560
+ fn publisher_applies_to_platform(publisher: &ConfiguredPublisher, platform: &str) -> bool {
561
+ publisher.platforms.is_empty()
562
+ || publisher
563
+ .platforms
564
+ .iter()
565
+ .any(|configured| configured == platform || configured == "*")
566
+ }
567
+
568
+ fn string_values(value: Option<&JsonValue>) -> Vec<String> {
569
+ match value {
570
+ Some(JsonValue::String(value)) => vec![value.clone()],
571
+ Some(JsonValue::Array(values)) => values
572
+ .iter()
573
+ .filter_map(JsonValue::as_str)
574
+ .map(ToOwned::to_owned)
575
+ .collect(),
576
+ _ => Vec::new(),
577
+ }
578
+ }
579
+
580
+ fn publisher_config_string(object: &JsonMap<String, JsonValue>, keys: &[&str]) -> Option<String> {
581
+ keys.iter().find_map(|key| {
582
+ publisher_config_value(object, key)
583
+ .and_then(JsonValue::as_str)
584
+ .map(str::trim)
585
+ .filter(|value| !value.is_empty())
586
+ .map(ToOwned::to_owned)
587
+ })
588
+ }
589
+
590
+ fn publisher_config_bool(object: &JsonMap<String, JsonValue>, keys: &[&str]) -> Option<bool> {
591
+ keys.iter()
592
+ .find_map(|key| publisher_config_value(object, key).and_then(JsonValue::as_bool))
593
+ }
594
+
595
+ fn publisher_config_value<'a>(
596
+ object: &'a JsonMap<String, JsonValue>,
597
+ key: &str,
598
+ ) -> Option<&'a JsonValue> {
599
+ object
600
+ .get("config")
601
+ .and_then(JsonValue::as_object)
602
+ .and_then(|config| config.get(key))
603
+ .or_else(|| object.get(key))
604
+ }
605
+
606
+ fn publisher_config_github_repo(object: &JsonMap<String, JsonValue>) -> Option<String> {
607
+ ["repository", "repo", "githubRepo", "github_repo"]
608
+ .iter()
609
+ .find_map(|key| publisher_config_value(object, key).and_then(github_repo_from_config_value))
610
+ }
611
+
612
+ fn github_repo_from_config_value(value: &JsonValue) -> Option<String> {
613
+ match value {
614
+ JsonValue::String(value) => {
615
+ let value = value.trim();
616
+ (!value.is_empty()).then(|| value.to_string())
617
+ }
618
+ JsonValue::Object(object) => {
619
+ let owner = object
620
+ .get("owner")
621
+ .or_else(|| object.get("user"))
622
+ .and_then(JsonValue::as_str)?
623
+ .trim();
624
+ let name = object
625
+ .get("name")
626
+ .or_else(|| object.get("repo"))
627
+ .and_then(JsonValue::as_str)?
628
+ .trim();
629
+ if owner.is_empty() || name.is_empty() {
630
+ None
631
+ } else {
632
+ Some(format!("{owner}/{name}"))
633
+ }
634
+ }
635
+ _ => None,
636
+ }
637
+ }
638
+
639
+ fn publisher_config_api_url(object: &JsonMap<String, JsonValue>) -> Option<String> {
640
+ publisher_config_string(object, &["apiUrl", "api_url", "baseUrl", "base_url"]).or_else(|| {
641
+ publisher_config_value(object, "octokitOptions")
642
+ .and_then(JsonValue::as_object)
643
+ .and_then(|octokit| {
644
+ octokit
645
+ .get("baseUrl")
646
+ .or_else(|| octokit.get("base_url"))
647
+ .and_then(JsonValue::as_str)
648
+ })
649
+ .map(str::trim)
650
+ .filter(|value| !value.is_empty())
651
+ .map(ToOwned::to_owned)
652
+ })
653
+ }
654
+
655
+ #[cfg(test)]
230
656
  fn execute_publish(report: &mut PublishReport, args: &PublishArgs) -> Result<()> {
231
657
  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)?;
658
+ make::execute_make(&mut report.make, &make_args(args))?;
245
659
  report.make.mark_made()?;
246
660
  } else if !Path::new(report.make.artifact().as_str()).exists() {
247
661
  bail!(
@@ -252,18 +666,68 @@ fn execute_publish(report: &mut PublishReport, args: &PublishArgs) -> Result<()>
252
666
 
253
667
  let published_at_unix_seconds = now_unix_seconds()?;
254
668
  report.published_at_unix_seconds = Some(published_at_unix_seconds);
669
+ execute_publish_destination(report, published_at_unix_seconds)?;
670
+ report.status = PublishStatus::Published;
255
671
 
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
- }
672
+ Ok(())
260
673
  }
261
674
 
262
- fn execute_local_publish(
263
- report: &PublishReport,
675
+ fn execute_publish_reports(
676
+ reports: &mut [PublishReport],
677
+ make_reports: &mut [MakeReport],
264
678
  args: &PublishArgs,
679
+ ) -> Result<()> {
680
+ if !args.skip_make {
681
+ make::execute_make_reports(make_reports, &make_args(args))?;
682
+ sync_make_reports(reports, make_reports);
683
+ } else {
684
+ ensure_make_artifacts_exist(make_reports)?;
685
+ }
686
+
687
+ let published_at_unix_seconds = now_unix_seconds()?;
688
+ for report in reports {
689
+ report.published_at_unix_seconds = Some(published_at_unix_seconds);
690
+ execute_publish_destination(report, published_at_unix_seconds)?;
691
+ report.status = PublishStatus::Published;
692
+ }
693
+
694
+ Ok(())
695
+ }
696
+
697
+ fn sync_make_reports(reports: &mut [PublishReport], make_reports: &[MakeReport]) {
698
+ for report in reports {
699
+ if let Some(make) = make_reports
700
+ .iter()
701
+ .find(|make| make.artifact().as_str() == report.make.artifact().as_str())
702
+ {
703
+ report.make = make.clone();
704
+ }
705
+ }
706
+ }
707
+
708
+ fn ensure_make_artifacts_exist(make_reports: &[MakeReport]) -> Result<()> {
709
+ for make in make_reports {
710
+ if !Path::new(make.artifact().as_str()).exists() {
711
+ bail!(
712
+ "Make artifact does not exist: {}. Run without --skip-make or run electron-cli make first.",
713
+ make.artifact()
714
+ );
715
+ }
716
+ }
717
+ Ok(())
718
+ }
719
+
720
+ fn execute_publish_destination(
721
+ report: &mut PublishReport,
265
722
  published_at_unix_seconds: u64,
266
723
  ) -> Result<()> {
724
+ match report.publisher_kind {
725
+ PublishTarget::Local => execute_local_publish(report, published_at_unix_seconds),
726
+ PublishTarget::Github => execute_github_publish(report),
727
+ }
728
+ }
729
+
730
+ fn execute_local_publish(report: &PublishReport, published_at_unix_seconds: u64) -> Result<()> {
267
731
  let local = report
268
732
  .local
269
733
  .as_ref()
@@ -273,7 +737,7 @@ fn execute_local_publish(
273
737
 
274
738
  for path in [destination_artifact, manifest] {
275
739
  if path.exists() {
276
- if args.force {
740
+ if report.force_publish {
277
741
  fs::remove_file(path)
278
742
  .with_context(|| format!("Could not remove {}", path.display()))?;
279
743
  } else {
@@ -303,10 +767,10 @@ fn execute_local_publish(
303
767
  Ok(())
304
768
  }
305
769
 
306
- fn execute_github_publish(report: &mut PublishReport, args: &PublishArgs) -> Result<()> {
307
- let token = github_token()?;
770
+ fn execute_github_publish(report: &mut PublishReport) -> Result<()> {
771
+ let token = github_token(report.github_auth_token.as_deref())?;
308
772
  let agent = github_agent();
309
- publish_to_github(report, args, &token, &agent)
773
+ publish_to_github(report, &token, &agent)
310
774
  }
311
775
 
312
776
  #[derive(Debug, Serialize)]
@@ -338,12 +802,7 @@ struct GithubErrorBody {
338
802
  message: Option<String>,
339
803
  }
340
804
 
341
- fn publish_to_github(
342
- report: &mut PublishReport,
343
- args: &PublishArgs,
344
- token: &str,
345
- agent: &ureq::Agent,
346
- ) -> Result<()> {
805
+ fn publish_to_github(report: &mut PublishReport, token: &str, agent: &ureq::Agent) -> Result<()> {
347
806
  let artifact_path = Path::new(report.make.artifact().as_str());
348
807
  let github = report
349
808
  .github
@@ -359,7 +818,7 @@ fn publish_to_github(
359
818
  .iter()
360
819
  .find(|asset| asset.name == github.artifact_name)
361
820
  {
362
- if args.force {
821
+ if report.force_publish {
363
822
  delete_github_asset(agent, token, github, asset.id)?;
364
823
  } else {
365
824
  bail!(
@@ -511,9 +970,13 @@ fn github_agent() -> ureq::Agent {
511
970
  .into()
512
971
  }
513
972
 
514
- fn github_token() -> Result<String> {
515
- std::env::var("GITHUB_TOKEN")
516
- .or_else(|_| std::env::var("GH_TOKEN"))
973
+ fn github_token(configured_token: Option<&str>) -> Result<String> {
974
+ configured_token
975
+ .map(str::trim)
976
+ .filter(|token| !token.is_empty())
977
+ .map(ToOwned::to_owned)
978
+ .or_else(|| std::env::var("GITHUB_TOKEN").ok())
979
+ .or_else(|| std::env::var("GH_TOKEN").ok())
517
980
  .context("GitHub publisher requires GITHUB_TOKEN or GH_TOKEN")
518
981
  }
519
982
 
@@ -608,6 +1071,85 @@ fn print_report(report: &PublishReport, json: bool) -> Result<()> {
608
1071
  Ok(())
609
1072
  }
610
1073
 
1074
+ fn print_reports(reports: &[PublishReport], json: bool, status: PublishStatus) -> Result<()> {
1075
+ if reports.len() == 1 {
1076
+ return print_report(&reports[0], json);
1077
+ }
1078
+
1079
+ let warnings = combined_warnings(reports);
1080
+ if json {
1081
+ return output::json(&PublishRunReport {
1082
+ publishes: reports,
1083
+ dry_run: reports.iter().any(|report| report.dry_run),
1084
+ status,
1085
+ warnings,
1086
+ });
1087
+ }
1088
+
1089
+ println!("electron-cli publish");
1090
+ println!();
1091
+ if let Some(first) = reports.first() {
1092
+ println!("Project");
1093
+ println!(" root: {}", first.make.package().project().root);
1094
+ match first.make.package().project().package_label() {
1095
+ Some(label) => println!(" package: {label}"),
1096
+ None => println!(" package: not found"),
1097
+ }
1098
+ println!(" app name: {}", first.make.package().app_name());
1099
+ println!(
1100
+ " target platform: {} {}",
1101
+ first.make.package().platform(),
1102
+ first.make.package().arch()
1103
+ );
1104
+ println!(" status: {}", status.as_str());
1105
+ }
1106
+
1107
+ println!();
1108
+ println!("Publishes");
1109
+ for report in reports {
1110
+ println!(
1111
+ " {} {}: {}",
1112
+ report.publisher,
1113
+ report.make.target(),
1114
+ report.make.artifact()
1115
+ );
1116
+ if let Some(local) = &report.local {
1117
+ println!(" artifact: {}", local.destination_artifact);
1118
+ println!(" manifest: {}", local.manifest);
1119
+ }
1120
+ if let Some(github) = &report.github {
1121
+ println!(" repository: {}", github.repo);
1122
+ println!(" tag: {}", github.tag);
1123
+ if let Some(url) = &github.release_url {
1124
+ println!(" release url: {url}");
1125
+ }
1126
+ if let Some(url) = &github.asset_url {
1127
+ println!(" asset url: {url}");
1128
+ }
1129
+ }
1130
+ }
1131
+
1132
+ if !warnings.is_empty() {
1133
+ println!();
1134
+ println!("Warnings");
1135
+ for warning in warnings {
1136
+ println!(" {warning}");
1137
+ }
1138
+ }
1139
+
1140
+ Ok(())
1141
+ }
1142
+
1143
+ fn combined_warnings(reports: &[PublishReport]) -> Vec<String> {
1144
+ let mut warnings = Vec::new();
1145
+ for warning in reports.iter().flat_map(|report| report.warnings.iter()) {
1146
+ if !warnings.contains(warning) {
1147
+ warnings.push(warning.clone());
1148
+ }
1149
+ }
1150
+ warnings
1151
+ }
1152
+
611
1153
  fn resolve_destination(root: &Path, destination: &Path) -> PathBuf {
612
1154
  if destination.is_absolute() {
613
1155
  destination.to_path_buf()
@@ -616,16 +1158,17 @@ fn resolve_destination(root: &Path, destination: &Path) -> PathBuf {
616
1158
  }
617
1159
  }
618
1160
 
619
- fn default_github_tag(make: &MakeReport, channel: &str) -> String {
1161
+ fn default_github_tag(make: &MakeReport, channel: &str, tag_prefix: Option<&str>) -> String {
620
1162
  make.package()
621
1163
  .project()
622
1164
  .version
623
1165
  .as_deref()
624
1166
  .map(|version| {
625
- if version.starts_with('v') {
1167
+ let prefix = tag_prefix.unwrap_or("v");
1168
+ if prefix.is_empty() || version.starts_with(prefix) {
626
1169
  version.to_string()
627
1170
  } else {
628
- format!("v{version}")
1171
+ format!("{prefix}{version}")
629
1172
  }
630
1173
  })
631
1174
  .unwrap_or_else(|| channel.to_string())
@@ -807,6 +1350,106 @@ mod tests {
807
1350
  let _ = fs::remove_dir_all(root);
808
1351
  }
809
1352
 
1353
+ #[test]
1354
+ fn builds_github_publish_report_from_configured_forge_publisher() {
1355
+ let root = unique_temp_dir("configured-github-plan");
1356
+ write_package_json_with_publishers(
1357
+ &root,
1358
+ r#"[
1359
+ {
1360
+ "name":"@electron-forge/publisher-github",
1361
+ "platforms":["*"],
1362
+ "config":{
1363
+ "repository":{"owner":"Ikana","name":"electron-cli"},
1364
+ "draft":true,
1365
+ "prerelease":true,
1366
+ "tagPrefix":"release-",
1367
+ "releaseName":"Configured Release",
1368
+ "baseUrl":"http://127.0.0.1:9"
1369
+ }
1370
+ }
1371
+ ]"#,
1372
+ );
1373
+ write_app_file(&root);
1374
+ write_fake_electron_dist(&root);
1375
+
1376
+ let mut args = publish_args(root.clone(), true);
1377
+ args.target = None;
1378
+ args.publisher = None;
1379
+ args.github_api_url = None;
1380
+ args.channel = None;
1381
+ let report = build_report(&args).expect("report should build");
1382
+
1383
+ assert_eq!(report.publisher, "github");
1384
+ assert!(report.local.is_none());
1385
+ assert_eq!(report.channel, "default");
1386
+ let github = report.github.as_ref().expect("github plan should exist");
1387
+ assert_eq!(github.repo, "Ikana/electron-cli");
1388
+ assert_eq!(github.tag, "release-0.1.0");
1389
+ assert_eq!(github.release_name, "Configured Release");
1390
+ assert!(github.draft);
1391
+ assert!(github.prerelease);
1392
+ assert_eq!(github.api_url, "http://127.0.0.1:9");
1393
+
1394
+ let _ = fs::remove_dir_all(root);
1395
+ }
1396
+
1397
+ #[test]
1398
+ fn builds_publish_reports_from_configured_makers_and_publishers() {
1399
+ let root = unique_temp_dir("configured-publishers");
1400
+ write_package_json_with_makers_and_publishers(
1401
+ &root,
1402
+ r#"[
1403
+ {"name":"@electron-forge/maker-zip"},
1404
+ {"name":"@electron-forge/maker-deb","platforms":["linux"]}
1405
+ ]"#,
1406
+ r#"[
1407
+ {"name":"@electron-forge/publisher-github","config":{"repository":"Ikana/electron-cli"}},
1408
+ {"name":"local","config":{"to":"dist/publish","channel":"beta"}},
1409
+ {"name":"@electron-forge/publisher-s3"}
1410
+ ]"#,
1411
+ );
1412
+ write_app_file(&root);
1413
+ write_fake_electron_dist(&root);
1414
+
1415
+ let mut args = publish_args(root.clone(), true);
1416
+ args.platform = Some("linux".to_string());
1417
+ args.arch = Some("x64".to_string());
1418
+ args.target = None;
1419
+ args.publisher = None;
1420
+ args.to = None;
1421
+ args.channel = None;
1422
+ args.github_api_url = None;
1423
+ let reports = build_reports(&args).expect("reports should build");
1424
+
1425
+ assert_eq!(reports.len(), 4);
1426
+ assert_eq!(reports[0].publisher, "github");
1427
+ assert_eq!(reports[0].make.target(), "zip");
1428
+ assert_eq!(reports[1].publisher, "github");
1429
+ assert_eq!(reports[1].make.target(), "deb");
1430
+ assert_eq!(reports[2].publisher, "local");
1431
+ assert_eq!(reports[2].make.target(), "zip");
1432
+ assert_eq!(reports[3].publisher, "local");
1433
+ assert_eq!(reports[3].make.target(), "deb");
1434
+ let local = reports[2].local.as_ref().expect("local plan should exist");
1435
+ let local_parent = Path::new(local.destination_artifact.as_str())
1436
+ .parent()
1437
+ .expect("local artifact should have parent");
1438
+ assert!(local_parent.ends_with(
1439
+ PathBuf::from("dist")
1440
+ .join("publish")
1441
+ .join("beta")
1442
+ .join("linux")
1443
+ .join("x64")
1444
+ ));
1445
+ assert!(reports[0]
1446
+ .warnings
1447
+ .iter()
1448
+ .any(|warning| warning.contains("@electron-forge/publisher-s3")));
1449
+
1450
+ let _ = fs::remove_dir_all(root);
1451
+ }
1452
+
810
1453
  #[test]
811
1454
  fn publishes_make_artifact_to_github_release() {
812
1455
  let server = MockGithubServer::new(3);
@@ -824,7 +1467,7 @@ mod tests {
824
1467
  fs::write(artifact, b"artifact bytes").expect("artifact should be written");
825
1468
 
826
1469
  let agent = github_agent();
827
- publish_to_github(&mut report, &args, "test-token", &agent)
1470
+ publish_to_github(&mut report, "test-token", &agent)
828
1471
  .expect("github publish should succeed");
829
1472
 
830
1473
  let github = report.github.as_ref().expect("github plan should exist");
@@ -903,16 +1546,16 @@ mod tests {
903
1546
  name: None,
904
1547
  platform: None,
905
1548
  arch: None,
906
- target: crate::cli::MakeTarget::Zip,
907
- publisher: crate::cli::PublishTarget::Local,
908
- to: PathBuf::from("out/publish/local"),
1549
+ target: Some(crate::cli::MakeTarget::Zip),
1550
+ publisher: Some(crate::cli::PublishTarget::Local),
1551
+ to: Some(PathBuf::from("out/publish/local")),
909
1552
  github_repo: None,
910
1553
  github_tag: None,
911
1554
  github_release_name: None,
912
1555
  github_draft: false,
913
1556
  github_prerelease: false,
914
- github_api_url: "https://api.github.com".to_string(),
915
- channel: "default".to_string(),
1557
+ github_api_url: Some("https://api.github.com".to_string()),
1558
+ channel: Some("default".to_string()),
916
1559
  skip_make: false,
917
1560
  force: false,
918
1561
  dry_run,
@@ -922,8 +1565,8 @@ mod tests {
922
1565
 
923
1566
  fn github_publish_args(root: PathBuf, dry_run: bool, api_url: &str) -> PublishArgs {
924
1567
  let mut args = publish_args(root, dry_run);
925
- args.publisher = crate::cli::PublishTarget::Github;
926
- args.github_api_url = api_url.to_string();
1568
+ args.publisher = Some(crate::cli::PublishTarget::Github);
1569
+ args.github_api_url = Some(api_url.to_string());
927
1570
  args
928
1571
  }
929
1572
 
@@ -943,6 +1586,38 @@ mod tests {
943
1586
  .expect("package.json should be written");
944
1587
  }
945
1588
 
1589
+ fn write_package_json_with_publishers(root: &Path, publishers: &str) {
1590
+ fs::write(
1591
+ root.join("package.json"),
1592
+ format!(
1593
+ r#"{{
1594
+ "name":"starter-app",
1595
+ "version":"0.1.0",
1596
+ "main":"src/main.js",
1597
+ "devDependencies":{{"electron":"30.0.0"}},
1598
+ "config":{{"forge":{{"publishers":{publishers}}}}}
1599
+ }}"#
1600
+ ),
1601
+ )
1602
+ .expect("package.json with publishers should be written");
1603
+ }
1604
+
1605
+ fn write_package_json_with_makers_and_publishers(root: &Path, makers: &str, publishers: &str) {
1606
+ fs::write(
1607
+ root.join("package.json"),
1608
+ format!(
1609
+ r#"{{
1610
+ "name":"starter-app",
1611
+ "version":"0.1.0",
1612
+ "main":"src/main.js",
1613
+ "devDependencies":{{"electron":"30.0.0"}},
1614
+ "config":{{"forge":{{"makers":{makers},"publishers":{publishers}}}}}
1615
+ }}"#
1616
+ ),
1617
+ )
1618
+ .expect("package.json with makers and publishers should be written");
1619
+ }
1620
+
946
1621
  fn write_app_file(root: &Path) {
947
1622
  fs::create_dir_all(root.join("src")).expect("src should be created");
948
1623
  fs::write(root.join("src/main.js"), "console.log('hello');")