electron-cli 0.3.0-alpha.2 → 0.3.0-alpha.20
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 +5380 -101
- package/Cargo.toml +17 -1
- package/README.md +103 -12
- package/package.json +2 -1
- package/src/cli.rs +226 -4
- package/src/commands/init.rs +443 -27
- package/src/commands/make.rs +3076 -0
- package/src/commands/mod.rs +4 -0
- package/src/commands/package.rs +3238 -0
- package/src/commands/plan.rs +65 -5
- package/src/commands/publish.rs +1832 -0
- package/src/commands/start.rs +287 -0
- package/src/forge_config.rs +547 -0
- package/src/main.rs +5 -0
- package/src/project.rs +52 -1
- package/templates/minimal/gitignore +5 -0
- package/templates/minimal/src/index.html +82 -0
- package/templates/minimal/src/main.js +33 -0
- package/templates/minimal/src/preload.js +6 -0
- package/templates/minimal/src/renderer.js +5 -0
|
@@ -0,0 +1,1832 @@
|
|
|
1
|
+
use std::{
|
|
2
|
+
fs,
|
|
3
|
+
fs::File,
|
|
4
|
+
path::{Path, PathBuf},
|
|
5
|
+
time::{Duration, SystemTime, UNIX_EPOCH},
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
use anyhow::{bail, Context, Result};
|
|
9
|
+
use camino::Utf8PathBuf;
|
|
10
|
+
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
|
11
|
+
use serde_json::{Map as JsonMap, Value as JsonValue};
|
|
12
|
+
|
|
13
|
+
use crate::{
|
|
14
|
+
cli::{MakeArgs, PublishArgs, PublishTarget},
|
|
15
|
+
commands::make::{self, MakeReport},
|
|
16
|
+
output,
|
|
17
|
+
project::ProjectSnapshot,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
#[derive(Debug, Serialize)]
|
|
21
|
+
struct PublishReport {
|
|
22
|
+
make: MakeReport,
|
|
23
|
+
publisher: String,
|
|
24
|
+
#[serde(skip)]
|
|
25
|
+
publisher_kind: PublishTarget,
|
|
26
|
+
channel: String,
|
|
27
|
+
local: Option<LocalPublishPlan>,
|
|
28
|
+
github: Option<GithubPublishPlan>,
|
|
29
|
+
#[serde(skip)]
|
|
30
|
+
force_publish: bool,
|
|
31
|
+
#[serde(skip)]
|
|
32
|
+
github_auth_token: Option<String>,
|
|
33
|
+
skip_make: bool,
|
|
34
|
+
dry_run: bool,
|
|
35
|
+
status: PublishStatus,
|
|
36
|
+
published_at_unix_seconds: Option<u64>,
|
|
37
|
+
warnings: Vec<String>,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
#[derive(Debug, Serialize)]
|
|
41
|
+
struct LocalPublishPlan {
|
|
42
|
+
destination_dir: Utf8PathBuf,
|
|
43
|
+
destination_artifact: Utf8PathBuf,
|
|
44
|
+
manifest: Utf8PathBuf,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
#[derive(Debug, Serialize)]
|
|
48
|
+
struct GithubPublishPlan {
|
|
49
|
+
repo: String,
|
|
50
|
+
tag: String,
|
|
51
|
+
release_name: String,
|
|
52
|
+
draft: bool,
|
|
53
|
+
prerelease: bool,
|
|
54
|
+
api_url: String,
|
|
55
|
+
artifact_name: String,
|
|
56
|
+
release_url: Option<String>,
|
|
57
|
+
asset_url: Option<String>,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
#[derive(Clone, Copy, Debug, Serialize)]
|
|
61
|
+
#[serde(rename_all = "kebab-case")]
|
|
62
|
+
enum PublishStatus {
|
|
63
|
+
Planned,
|
|
64
|
+
Published,
|
|
65
|
+
}
|
|
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
|
+
|
|
75
|
+
#[derive(Debug, Serialize)]
|
|
76
|
+
struct PublishManifest {
|
|
77
|
+
schema_version: u8,
|
|
78
|
+
publisher: String,
|
|
79
|
+
channel: String,
|
|
80
|
+
app_name: String,
|
|
81
|
+
package_name: Option<String>,
|
|
82
|
+
package_version: Option<String>,
|
|
83
|
+
platform: String,
|
|
84
|
+
arch: String,
|
|
85
|
+
target: String,
|
|
86
|
+
published_at_unix_seconds: u64,
|
|
87
|
+
artifacts: Vec<PublishedArtifact>,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
#[derive(Debug, Serialize)]
|
|
91
|
+
struct PublishedArtifact {
|
|
92
|
+
file: String,
|
|
93
|
+
path: Utf8PathBuf,
|
|
94
|
+
size: u64,
|
|
95
|
+
}
|
|
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
|
+
|
|
137
|
+
pub fn run(args: PublishArgs) -> Result<()> {
|
|
138
|
+
let mut make_reports = make::build_reports(&make_args(&args))?;
|
|
139
|
+
let mut reports = build_reports_from_make_reports(&args, &make_reports)?;
|
|
140
|
+
|
|
141
|
+
if args.dry_run {
|
|
142
|
+
return print_reports(&reports, args.json, PublishStatus::Planned);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
execute_publish_reports(&mut reports, &mut make_reports, &args)?;
|
|
146
|
+
|
|
147
|
+
print_reports(&reports, args.json, PublishStatus::Published)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
#[cfg(test)]
|
|
151
|
+
fn build_report(args: &PublishArgs) -> Result<PublishReport> {
|
|
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> {
|
|
203
|
+
let root = Path::new(make.package().project().root.as_str());
|
|
204
|
+
let artifact_name = make
|
|
205
|
+
.artifact()
|
|
206
|
+
.file_name()
|
|
207
|
+
.context("Make artifact path has no UTF-8 file name")?
|
|
208
|
+
.to_string();
|
|
209
|
+
|
|
210
|
+
let mut warnings = make.warnings().to_vec();
|
|
211
|
+
warnings.extend(config_warnings.iter().cloned());
|
|
212
|
+
if args.skip_make && !Path::new(make.artifact().as_str()).exists() {
|
|
213
|
+
warnings.push(format!(
|
|
214
|
+
"Make artifact does not exist: {}.",
|
|
215
|
+
make.artifact()
|
|
216
|
+
));
|
|
217
|
+
}
|
|
218
|
+
let (local, github) = match publisher.target {
|
|
219
|
+
PublishTarget::Local => {
|
|
220
|
+
let local = build_local_plan(root, publisher, &make, &artifact_name)?;
|
|
221
|
+
if Path::new(local.destination_artifact.as_str()).exists() && !publisher.force_publish {
|
|
222
|
+
warnings.push(format!(
|
|
223
|
+
"Publish artifact already exists: {}. Use --force to overwrite it.",
|
|
224
|
+
local.destination_artifact
|
|
225
|
+
));
|
|
226
|
+
}
|
|
227
|
+
if Path::new(local.manifest.as_str()).exists() && !publisher.force_publish {
|
|
228
|
+
warnings.push(format!(
|
|
229
|
+
"Publish manifest already exists: {}. Use --force to overwrite it.",
|
|
230
|
+
local.manifest
|
|
231
|
+
));
|
|
232
|
+
}
|
|
233
|
+
(Some(local), None)
|
|
234
|
+
}
|
|
235
|
+
PublishTarget::Github => {
|
|
236
|
+
let github = build_github_plan(publisher, &make, &artifact_name, &mut warnings)?;
|
|
237
|
+
(None, Some(github))
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
Ok(PublishReport {
|
|
242
|
+
make,
|
|
243
|
+
publisher: publisher.target.as_str().to_string(),
|
|
244
|
+
publisher_kind: publisher.target,
|
|
245
|
+
channel: publisher.channel.clone(),
|
|
246
|
+
local,
|
|
247
|
+
github,
|
|
248
|
+
force_publish: publisher.force_publish,
|
|
249
|
+
github_auth_token: publisher.github_auth_token.clone(),
|
|
250
|
+
skip_make: args.skip_make,
|
|
251
|
+
dry_run: args.dry_run,
|
|
252
|
+
status: PublishStatus::Planned,
|
|
253
|
+
published_at_unix_seconds: None,
|
|
254
|
+
warnings,
|
|
255
|
+
})
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
fn build_local_plan(
|
|
259
|
+
root: &Path,
|
|
260
|
+
publisher: &ResolvedPublisher,
|
|
261
|
+
make: &MakeReport,
|
|
262
|
+
artifact_name: &str,
|
|
263
|
+
) -> Result<LocalPublishPlan> {
|
|
264
|
+
let publish_root = resolve_destination(root, &publisher.to);
|
|
265
|
+
let destination_dir = publish_root
|
|
266
|
+
.join(&publisher.channel)
|
|
267
|
+
.join(make.package().platform())
|
|
268
|
+
.join(make.package().arch());
|
|
269
|
+
let destination_artifact = destination_dir.join(artifact_name);
|
|
270
|
+
let manifest = destination_dir.join("manifest.json");
|
|
271
|
+
|
|
272
|
+
Ok(LocalPublishPlan {
|
|
273
|
+
destination_dir: utf8_path(destination_dir)?,
|
|
274
|
+
destination_artifact: utf8_path(destination_artifact)?,
|
|
275
|
+
manifest: utf8_path(manifest)?,
|
|
276
|
+
})
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
fn build_github_plan(
|
|
280
|
+
publisher: &ResolvedPublisher,
|
|
281
|
+
make: &MakeReport,
|
|
282
|
+
artifact_name: &str,
|
|
283
|
+
warnings: &mut Vec<String>,
|
|
284
|
+
) -> Result<GithubPublishPlan> {
|
|
285
|
+
let repo = publisher
|
|
286
|
+
.github_repo
|
|
287
|
+
.clone()
|
|
288
|
+
.or_else(|| {
|
|
289
|
+
make.package()
|
|
290
|
+
.project()
|
|
291
|
+
.repository
|
|
292
|
+
.as_deref()
|
|
293
|
+
.and_then(github_repo_from_repository)
|
|
294
|
+
})
|
|
295
|
+
.unwrap_or_default();
|
|
296
|
+
if repo.is_empty() {
|
|
297
|
+
warnings.push(
|
|
298
|
+
"GitHub repository is not configured. Pass --github-repo OWNER/REPO or set package.json repository.".to_string(),
|
|
299
|
+
);
|
|
300
|
+
} else if !valid_github_repo(&repo) {
|
|
301
|
+
warnings.push(format!(
|
|
302
|
+
"GitHub repository should use OWNER/REPO form: {repo}."
|
|
303
|
+
));
|
|
304
|
+
}
|
|
305
|
+
|
|
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
|
|
314
|
+
.github_release_name
|
|
315
|
+
.clone()
|
|
316
|
+
.unwrap_or_else(|| tag.clone());
|
|
317
|
+
let prerelease = publisher.github_prerelease || tag.contains('-');
|
|
318
|
+
|
|
319
|
+
Ok(GithubPublishPlan {
|
|
320
|
+
repo,
|
|
321
|
+
tag,
|
|
322
|
+
release_name,
|
|
323
|
+
draft: publisher.github_draft,
|
|
324
|
+
prerelease,
|
|
325
|
+
api_url: publisher.github_api_url.trim_end_matches('/').to_string(),
|
|
326
|
+
artifact_name: artifact_name.to_string(),
|
|
327
|
+
release_url: None,
|
|
328
|
+
asset_url: None,
|
|
329
|
+
})
|
|
330
|
+
}
|
|
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)]
|
|
647
|
+
fn execute_publish(report: &mut PublishReport, args: &PublishArgs) -> Result<()> {
|
|
648
|
+
if !args.skip_make {
|
|
649
|
+
make::execute_make(&mut report.make, &make_args(args))?;
|
|
650
|
+
report.make.mark_made()?;
|
|
651
|
+
} else if !Path::new(report.make.artifact().as_str()).exists() {
|
|
652
|
+
bail!(
|
|
653
|
+
"Make artifact does not exist: {}. Run without --skip-make or run electron-cli make first.",
|
|
654
|
+
report.make.artifact()
|
|
655
|
+
);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
let published_at_unix_seconds = now_unix_seconds()?;
|
|
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;
|
|
662
|
+
|
|
663
|
+
Ok(())
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
fn execute_publish_reports(
|
|
667
|
+
reports: &mut [PublishReport],
|
|
668
|
+
make_reports: &mut [MakeReport],
|
|
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,
|
|
713
|
+
published_at_unix_seconds: u64,
|
|
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<()> {
|
|
722
|
+
let local = report
|
|
723
|
+
.local
|
|
724
|
+
.as_ref()
|
|
725
|
+
.context("Local publish plan was not built")?;
|
|
726
|
+
let destination_artifact = Path::new(local.destination_artifact.as_str());
|
|
727
|
+
let manifest = Path::new(local.manifest.as_str());
|
|
728
|
+
|
|
729
|
+
for path in [destination_artifact, manifest] {
|
|
730
|
+
if path.exists() {
|
|
731
|
+
if report.force_publish {
|
|
732
|
+
fs::remove_file(path)
|
|
733
|
+
.with_context(|| format!("Could not remove {}", path.display()))?;
|
|
734
|
+
} else {
|
|
735
|
+
bail!(
|
|
736
|
+
"Publish output already exists: {}. Use --force to overwrite it.",
|
|
737
|
+
path.display()
|
|
738
|
+
);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
fs::create_dir_all(local.destination_dir.as_str())
|
|
744
|
+
.with_context(|| format!("Could not create {}", local.destination_dir))?;
|
|
745
|
+
fs::copy(report.make.artifact().as_str(), destination_artifact).with_context(|| {
|
|
746
|
+
format!(
|
|
747
|
+
"Could not publish {} to {}",
|
|
748
|
+
report.make.artifact(),
|
|
749
|
+
destination_artifact.display()
|
|
750
|
+
)
|
|
751
|
+
})?;
|
|
752
|
+
|
|
753
|
+
let manifest_json =
|
|
754
|
+
serde_json::to_string_pretty(&build_manifest(report, published_at_unix_seconds)?)?;
|
|
755
|
+
fs::write(manifest, format!("{manifest_json}\n"))
|
|
756
|
+
.with_context(|| format!("Could not write {}", manifest.display()))?;
|
|
757
|
+
|
|
758
|
+
Ok(())
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
fn execute_github_publish(report: &mut PublishReport) -> Result<()> {
|
|
762
|
+
let token = github_token(report.github_auth_token.as_deref())?;
|
|
763
|
+
let agent = github_agent();
|
|
764
|
+
publish_to_github(report, &token, &agent)
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
#[derive(Debug, Serialize)]
|
|
768
|
+
struct CreateGithubRelease<'a> {
|
|
769
|
+
tag_name: &'a str,
|
|
770
|
+
name: &'a str,
|
|
771
|
+
body: &'a str,
|
|
772
|
+
draft: bool,
|
|
773
|
+
prerelease: bool,
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
#[derive(Debug, Deserialize)]
|
|
777
|
+
struct GithubRelease {
|
|
778
|
+
html_url: String,
|
|
779
|
+
upload_url: String,
|
|
780
|
+
#[serde(default)]
|
|
781
|
+
assets: Vec<GithubAsset>,
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
#[derive(Debug, Deserialize)]
|
|
785
|
+
struct GithubAsset {
|
|
786
|
+
id: u64,
|
|
787
|
+
name: String,
|
|
788
|
+
browser_download_url: String,
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
#[derive(Debug, Deserialize)]
|
|
792
|
+
struct GithubErrorBody {
|
|
793
|
+
message: Option<String>,
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
fn publish_to_github(report: &mut PublishReport, token: &str, agent: &ureq::Agent) -> Result<()> {
|
|
797
|
+
let artifact_path = Path::new(report.make.artifact().as_str());
|
|
798
|
+
let github = report
|
|
799
|
+
.github
|
|
800
|
+
.as_mut()
|
|
801
|
+
.context("GitHub publish plan was not built")?;
|
|
802
|
+
if github.repo.is_empty() || !valid_github_repo(&github.repo) {
|
|
803
|
+
bail!("GitHub repository must be configured as OWNER/REPO.");
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
let release = get_or_create_github_release(agent, token, github)?;
|
|
807
|
+
if let Some(asset) = release
|
|
808
|
+
.assets
|
|
809
|
+
.iter()
|
|
810
|
+
.find(|asset| asset.name == github.artifact_name)
|
|
811
|
+
{
|
|
812
|
+
if report.force_publish {
|
|
813
|
+
delete_github_asset(agent, token, github, asset.id)?;
|
|
814
|
+
} else {
|
|
815
|
+
bail!(
|
|
816
|
+
"GitHub release asset already exists: {}. Use --force to replace it.",
|
|
817
|
+
github.artifact_name
|
|
818
|
+
);
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
let asset = upload_github_asset(agent, token, github, &release.upload_url, artifact_path)?;
|
|
823
|
+
github.release_url = Some(release.html_url);
|
|
824
|
+
github.asset_url = Some(asset.browser_download_url);
|
|
825
|
+
|
|
826
|
+
Ok(())
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
fn get_or_create_github_release(
|
|
830
|
+
agent: &ureq::Agent,
|
|
831
|
+
token: &str,
|
|
832
|
+
github: &GithubPublishPlan,
|
|
833
|
+
) -> Result<GithubRelease> {
|
|
834
|
+
let get_url = format!(
|
|
835
|
+
"{}/repos/{}/releases/tags/{}",
|
|
836
|
+
github.api_url,
|
|
837
|
+
github.repo,
|
|
838
|
+
encode_url_component(&github.tag)
|
|
839
|
+
);
|
|
840
|
+
let response = github_request(agent.get(&get_url), token)
|
|
841
|
+
.call()
|
|
842
|
+
.with_context(|| format!("Could not query GitHub release {}", github.tag))?;
|
|
843
|
+
match response.status().as_u16() {
|
|
844
|
+
200 => parse_github_response(response, "Could not parse GitHub release"),
|
|
845
|
+
404 => create_github_release(agent, token, github),
|
|
846
|
+
status => github_status_error(response, status, "Could not query GitHub release"),
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
fn create_github_release(
|
|
851
|
+
agent: &ureq::Agent,
|
|
852
|
+
token: &str,
|
|
853
|
+
github: &GithubPublishPlan,
|
|
854
|
+
) -> Result<GithubRelease> {
|
|
855
|
+
let url = format!("{}/repos/{}/releases", github.api_url, github.repo);
|
|
856
|
+
let body = format!(
|
|
857
|
+
"{} {} {} artifact published by electron-cli.",
|
|
858
|
+
github.artifact_name, github.tag, github.repo
|
|
859
|
+
);
|
|
860
|
+
let request = CreateGithubRelease {
|
|
861
|
+
tag_name: &github.tag,
|
|
862
|
+
name: &github.release_name,
|
|
863
|
+
body: &body,
|
|
864
|
+
draft: github.draft,
|
|
865
|
+
prerelease: github.prerelease,
|
|
866
|
+
};
|
|
867
|
+
let response = github_request(agent.post(&url), token)
|
|
868
|
+
.content_type("application/json")
|
|
869
|
+
.send_json(&request)
|
|
870
|
+
.with_context(|| format!("Could not create GitHub release {}", github.tag))?;
|
|
871
|
+
let status = response.status().as_u16();
|
|
872
|
+
if status == 201 {
|
|
873
|
+
parse_github_response(response, "Could not parse created GitHub release")
|
|
874
|
+
} else {
|
|
875
|
+
github_status_error(response, status, "Could not create GitHub release")
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
fn delete_github_asset(
|
|
880
|
+
agent: &ureq::Agent,
|
|
881
|
+
token: &str,
|
|
882
|
+
github: &GithubPublishPlan,
|
|
883
|
+
asset_id: u64,
|
|
884
|
+
) -> Result<()> {
|
|
885
|
+
let url = format!(
|
|
886
|
+
"{}/repos/{}/releases/assets/{}",
|
|
887
|
+
github.api_url, github.repo, asset_id
|
|
888
|
+
);
|
|
889
|
+
let response = github_request(agent.delete(&url), token)
|
|
890
|
+
.call()
|
|
891
|
+
.with_context(|| format!("Could not delete GitHub release asset {asset_id}"))?;
|
|
892
|
+
let status = response.status().as_u16();
|
|
893
|
+
if status == 204 {
|
|
894
|
+
Ok(())
|
|
895
|
+
} else {
|
|
896
|
+
github_status_error(response, status, "Could not delete GitHub release asset")
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
fn upload_github_asset(
|
|
901
|
+
agent: &ureq::Agent,
|
|
902
|
+
token: &str,
|
|
903
|
+
github: &GithubPublishPlan,
|
|
904
|
+
upload_url: &str,
|
|
905
|
+
artifact_path: &Path,
|
|
906
|
+
) -> Result<GithubAsset> {
|
|
907
|
+
let url = github_asset_upload_url(upload_url, &github.artifact_name);
|
|
908
|
+
let file = File::open(artifact_path)
|
|
909
|
+
.with_context(|| format!("Could not open {}", artifact_path.display()))?;
|
|
910
|
+
let response = github_request(agent.post(&url), token)
|
|
911
|
+
.content_type("application/octet-stream")
|
|
912
|
+
.send(file)
|
|
913
|
+
.with_context(|| format!("Could not upload {}", artifact_path.display()))?;
|
|
914
|
+
let status = response.status().as_u16();
|
|
915
|
+
if status == 201 {
|
|
916
|
+
parse_github_response(response, "Could not parse uploaded GitHub asset")
|
|
917
|
+
} else {
|
|
918
|
+
github_status_error(response, status, "Could not upload GitHub release asset")
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
fn github_request<T>(request: ureq::RequestBuilder<T>, token: &str) -> ureq::RequestBuilder<T> {
|
|
923
|
+
request
|
|
924
|
+
.header("Accept", "application/vnd.github+json")
|
|
925
|
+
.header("Authorization", format!("Bearer {token}"))
|
|
926
|
+
.header(
|
|
927
|
+
"User-Agent",
|
|
928
|
+
format!("electron-cli/{}", env!("CARGO_PKG_VERSION")),
|
|
929
|
+
)
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
fn parse_github_response<T: DeserializeOwned>(
|
|
933
|
+
mut response: ureq::http::Response<ureq::Body>,
|
|
934
|
+
context: &str,
|
|
935
|
+
) -> Result<T> {
|
|
936
|
+
response
|
|
937
|
+
.body_mut()
|
|
938
|
+
.read_json::<T>()
|
|
939
|
+
.with_context(|| context.to_string())
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
fn github_status_error<T>(
|
|
943
|
+
mut response: ureq::http::Response<ureq::Body>,
|
|
944
|
+
status: u16,
|
|
945
|
+
context: &str,
|
|
946
|
+
) -> Result<T> {
|
|
947
|
+
let body = response.body_mut().read_to_string().unwrap_or_default();
|
|
948
|
+
let message = serde_json::from_str::<GithubErrorBody>(&body)
|
|
949
|
+
.ok()
|
|
950
|
+
.and_then(|body| body.message)
|
|
951
|
+
.filter(|message| !message.trim().is_empty())
|
|
952
|
+
.unwrap_or(body);
|
|
953
|
+
bail!("{context}: HTTP {status}: {message}")
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
fn github_agent() -> ureq::Agent {
|
|
957
|
+
ureq::Agent::config_builder()
|
|
958
|
+
.http_status_as_error(false)
|
|
959
|
+
.timeout_global(Some(Duration::from_secs(300)))
|
|
960
|
+
.build()
|
|
961
|
+
.into()
|
|
962
|
+
}
|
|
963
|
+
|
|
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())
|
|
971
|
+
.context("GitHub publisher requires GITHUB_TOKEN or GH_TOKEN")
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
fn build_manifest(
|
|
975
|
+
report: &PublishReport,
|
|
976
|
+
published_at_unix_seconds: u64,
|
|
977
|
+
) -> Result<PublishManifest> {
|
|
978
|
+
let local = report
|
|
979
|
+
.local
|
|
980
|
+
.as_ref()
|
|
981
|
+
.context("Local publish manifest requires a local publish plan")?;
|
|
982
|
+
let destination_artifact = Path::new(local.destination_artifact.as_str());
|
|
983
|
+
let artifact_size = fs::metadata(destination_artifact)
|
|
984
|
+
.with_context(|| format!("Could not stat {}", destination_artifact.display()))?
|
|
985
|
+
.len();
|
|
986
|
+
let artifact_file = destination_artifact
|
|
987
|
+
.file_name()
|
|
988
|
+
.and_then(|name| name.to_str())
|
|
989
|
+
.context("Published artifact path has no UTF-8 file name")?
|
|
990
|
+
.to_string();
|
|
991
|
+
|
|
992
|
+
Ok(PublishManifest {
|
|
993
|
+
schema_version: 1,
|
|
994
|
+
publisher: report.publisher.clone(),
|
|
995
|
+
channel: report.channel.clone(),
|
|
996
|
+
app_name: report.make.package().app_name().to_string(),
|
|
997
|
+
package_name: report.make.package().project().name.clone(),
|
|
998
|
+
package_version: report.make.package().project().version.clone(),
|
|
999
|
+
platform: report.make.package().platform().to_string(),
|
|
1000
|
+
arch: report.make.package().arch().to_string(),
|
|
1001
|
+
target: report.make.target().to_string(),
|
|
1002
|
+
published_at_unix_seconds,
|
|
1003
|
+
artifacts: vec![PublishedArtifact {
|
|
1004
|
+
file: artifact_file,
|
|
1005
|
+
path: local.destination_artifact.clone(),
|
|
1006
|
+
size: artifact_size,
|
|
1007
|
+
}],
|
|
1008
|
+
})
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
fn print_report(report: &PublishReport, json: bool) -> Result<()> {
|
|
1012
|
+
if json {
|
|
1013
|
+
return output::json(report);
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
println!("electron-cli publish");
|
|
1017
|
+
println!();
|
|
1018
|
+
println!("Project");
|
|
1019
|
+
println!(" root: {}", report.make.package().project().root);
|
|
1020
|
+
match report.make.package().project().package_label() {
|
|
1021
|
+
Some(label) => println!(" package: {label}"),
|
|
1022
|
+
None => println!(" package: not found"),
|
|
1023
|
+
}
|
|
1024
|
+
println!(" app name: {}", report.make.package().app_name());
|
|
1025
|
+
println!(
|
|
1026
|
+
" target: {} {} {}",
|
|
1027
|
+
report.make.target(),
|
|
1028
|
+
report.make.package().platform(),
|
|
1029
|
+
report.make.package().arch()
|
|
1030
|
+
);
|
|
1031
|
+
println!(" publisher: {}", report.publisher);
|
|
1032
|
+
println!(" channel: {}", report.channel);
|
|
1033
|
+
println!(" status: {}", report.status.as_str());
|
|
1034
|
+
|
|
1035
|
+
println!();
|
|
1036
|
+
println!("Publish");
|
|
1037
|
+
if let Some(local) = &report.local {
|
|
1038
|
+
println!(" artifact: {}", local.destination_artifact);
|
|
1039
|
+
println!(" manifest: {}", local.manifest);
|
|
1040
|
+
}
|
|
1041
|
+
if let Some(github) = &report.github {
|
|
1042
|
+
println!(" repository: {}", github.repo);
|
|
1043
|
+
println!(" tag: {}", github.tag);
|
|
1044
|
+
println!(" release: {}", github.release_name);
|
|
1045
|
+
println!(" artifact: {}", github.artifact_name);
|
|
1046
|
+
if let Some(url) = &github.release_url {
|
|
1047
|
+
println!(" release url: {url}");
|
|
1048
|
+
}
|
|
1049
|
+
if let Some(url) = &github.asset_url {
|
|
1050
|
+
println!(" asset url: {url}");
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
if !report.warnings.is_empty() {
|
|
1055
|
+
println!();
|
|
1056
|
+
println!("Warnings");
|
|
1057
|
+
for warning in &report.warnings {
|
|
1058
|
+
println!(" {warning}");
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
Ok(())
|
|
1063
|
+
}
|
|
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
|
+
|
|
1144
|
+
fn resolve_destination(root: &Path, destination: &Path) -> PathBuf {
|
|
1145
|
+
if destination.is_absolute() {
|
|
1146
|
+
destination.to_path_buf()
|
|
1147
|
+
} else {
|
|
1148
|
+
root.join(destination)
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
fn default_github_tag(make: &MakeReport, channel: &str, tag_prefix: Option<&str>) -> String {
|
|
1153
|
+
make.package()
|
|
1154
|
+
.project()
|
|
1155
|
+
.version
|
|
1156
|
+
.as_deref()
|
|
1157
|
+
.map(|version| {
|
|
1158
|
+
let prefix = tag_prefix.unwrap_or("v");
|
|
1159
|
+
if prefix.is_empty() || version.starts_with(prefix) {
|
|
1160
|
+
version.to_string()
|
|
1161
|
+
} else {
|
|
1162
|
+
format!("{prefix}{version}")
|
|
1163
|
+
}
|
|
1164
|
+
})
|
|
1165
|
+
.unwrap_or_else(|| channel.to_string())
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
fn valid_github_repo(repo: &str) -> bool {
|
|
1169
|
+
let mut parts = repo.split('/');
|
|
1170
|
+
let Some(owner) = parts.next() else {
|
|
1171
|
+
return false;
|
|
1172
|
+
};
|
|
1173
|
+
let Some(name) = parts.next() else {
|
|
1174
|
+
return false;
|
|
1175
|
+
};
|
|
1176
|
+
parts.next().is_none() && valid_github_path_part(owner) && valid_github_path_part(name)
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
fn valid_github_path_part(part: &str) -> bool {
|
|
1180
|
+
!part.is_empty()
|
|
1181
|
+
&& part
|
|
1182
|
+
.chars()
|
|
1183
|
+
.all(|char| char.is_ascii_alphanumeric() || matches!(char, '-' | '_' | '.'))
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
fn github_repo_from_repository(repository: &str) -> Option<String> {
|
|
1187
|
+
let mut value = repository.trim().trim_start_matches("git+").to_string();
|
|
1188
|
+
if let Some(fragment) = value.find('#') {
|
|
1189
|
+
value.truncate(fragment);
|
|
1190
|
+
}
|
|
1191
|
+
let value = value.trim_end_matches(".git");
|
|
1192
|
+
let path = value
|
|
1193
|
+
.split_once("github.com:")
|
|
1194
|
+
.map(|(_, path)| path)
|
|
1195
|
+
.or_else(|| value.split_once("github.com/").map(|(_, path)| path))?;
|
|
1196
|
+
let mut parts = path.trim_matches('/').split('/');
|
|
1197
|
+
let owner = parts.next()?.trim();
|
|
1198
|
+
let repo = parts.next()?.trim();
|
|
1199
|
+
if owner.is_empty() || repo.is_empty() {
|
|
1200
|
+
None
|
|
1201
|
+
} else {
|
|
1202
|
+
Some(format!("{owner}/{repo}"))
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
fn github_asset_upload_url(upload_url: &str, artifact_name: &str) -> String {
|
|
1207
|
+
let base = upload_url.split('{').next().unwrap_or(upload_url);
|
|
1208
|
+
let separator = if base.contains('?') { '&' } else { '?' };
|
|
1209
|
+
format!(
|
|
1210
|
+
"{base}{separator}name={}",
|
|
1211
|
+
encode_url_component(artifact_name)
|
|
1212
|
+
)
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
fn encode_url_component(value: &str) -> String {
|
|
1216
|
+
let mut encoded = String::new();
|
|
1217
|
+
for byte in value.bytes() {
|
|
1218
|
+
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
|
|
1219
|
+
encoded.push(byte as char);
|
|
1220
|
+
} else {
|
|
1221
|
+
encoded.push_str(&format!("%{byte:02X}"));
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
encoded
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
fn now_unix_seconds() -> Result<u64> {
|
|
1228
|
+
Ok(SystemTime::now()
|
|
1229
|
+
.duration_since(UNIX_EPOCH)
|
|
1230
|
+
.context("System clock is before the Unix epoch")?
|
|
1231
|
+
.as_secs())
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
fn utf8_path(path: PathBuf) -> Result<Utf8PathBuf> {
|
|
1235
|
+
Utf8PathBuf::from_path_buf(path).map_err(|path| {
|
|
1236
|
+
anyhow::anyhow!(
|
|
1237
|
+
"Path contains invalid UTF-8 and cannot be represented in JSON: {}",
|
|
1238
|
+
path.display()
|
|
1239
|
+
)
|
|
1240
|
+
})
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
impl PublishStatus {
|
|
1244
|
+
fn as_str(&self) -> &'static str {
|
|
1245
|
+
match self {
|
|
1246
|
+
PublishStatus::Planned => "planned",
|
|
1247
|
+
PublishStatus::Published => "published",
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
#[cfg(test)]
|
|
1253
|
+
mod tests {
|
|
1254
|
+
use super::*;
|
|
1255
|
+
use std::{
|
|
1256
|
+
io::{Read, Write},
|
|
1257
|
+
net::{TcpListener, TcpStream},
|
|
1258
|
+
sync::{Arc, Mutex},
|
|
1259
|
+
thread,
|
|
1260
|
+
};
|
|
1261
|
+
|
|
1262
|
+
#[test]
|
|
1263
|
+
fn builds_local_publish_report() {
|
|
1264
|
+
let root = unique_temp_dir("plan");
|
|
1265
|
+
write_package_json(&root);
|
|
1266
|
+
write_app_file(&root);
|
|
1267
|
+
write_fake_electron_dist(&root);
|
|
1268
|
+
|
|
1269
|
+
let args = publish_args(root.clone(), true);
|
|
1270
|
+
let report = build_report(&args).expect("report should build");
|
|
1271
|
+
|
|
1272
|
+
assert_eq!(report.publisher, "local");
|
|
1273
|
+
assert_eq!(report.channel, "default");
|
|
1274
|
+
let local = report.local.as_ref().expect("local plan should exist");
|
|
1275
|
+
assert!(Path::new(local.destination_artifact.as_str()).ends_with(
|
|
1276
|
+
PathBuf::from("out")
|
|
1277
|
+
.join("publish")
|
|
1278
|
+
.join("local")
|
|
1279
|
+
.join("default")
|
|
1280
|
+
.join(report.make.package().platform())
|
|
1281
|
+
.join(report.make.package().arch())
|
|
1282
|
+
.join(format!(
|
|
1283
|
+
"starter-app-{}-{}.zip",
|
|
1284
|
+
report.make.package().platform(),
|
|
1285
|
+
report.make.package().arch()
|
|
1286
|
+
))
|
|
1287
|
+
));
|
|
1288
|
+
|
|
1289
|
+
let _ = fs::remove_dir_all(root);
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
#[test]
|
|
1293
|
+
fn publishes_make_artifact_to_local_directory() {
|
|
1294
|
+
let root = unique_temp_dir("execute");
|
|
1295
|
+
write_package_json(&root);
|
|
1296
|
+
write_app_file(&root);
|
|
1297
|
+
write_fake_electron_dist(&root);
|
|
1298
|
+
|
|
1299
|
+
let args = publish_args(root.clone(), false);
|
|
1300
|
+
let mut report = build_report(&args).expect("report should build");
|
|
1301
|
+
|
|
1302
|
+
execute_publish(&mut report, &args).expect("publish should succeed");
|
|
1303
|
+
|
|
1304
|
+
let local = report.local.as_ref().expect("local plan should exist");
|
|
1305
|
+
assert!(Path::new(local.destination_artifact.as_str()).exists());
|
|
1306
|
+
assert!(Path::new(local.manifest.as_str()).exists());
|
|
1307
|
+
let manifest = fs::read_to_string(local.manifest.as_str()).expect("manifest should read");
|
|
1308
|
+
assert!(manifest.contains("\"publisher\": \"local\""));
|
|
1309
|
+
assert!(manifest.contains("\"app_name\": \"starter-app\""));
|
|
1310
|
+
|
|
1311
|
+
let _ = fs::remove_dir_all(root);
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
#[test]
|
|
1315
|
+
fn builds_github_publish_report_from_package_repository() {
|
|
1316
|
+
let root = unique_temp_dir("github-plan");
|
|
1317
|
+
write_github_package_json(&root);
|
|
1318
|
+
write_app_file(&root);
|
|
1319
|
+
write_fake_electron_dist(&root);
|
|
1320
|
+
|
|
1321
|
+
let args = github_publish_args(root.clone(), true, "http://127.0.0.1:9");
|
|
1322
|
+
let report = build_report(&args).expect("report should build");
|
|
1323
|
+
|
|
1324
|
+
assert_eq!(report.publisher, "github");
|
|
1325
|
+
assert!(report.local.is_none());
|
|
1326
|
+
let github = report.github.as_ref().expect("github plan should exist");
|
|
1327
|
+
assert_eq!(github.repo, "Ikana/electron-cli");
|
|
1328
|
+
assert_eq!(github.tag, "v0.1.0");
|
|
1329
|
+
assert_eq!(github.release_name, "v0.1.0");
|
|
1330
|
+
assert!(!github.draft);
|
|
1331
|
+
assert!(!github.prerelease);
|
|
1332
|
+
assert_eq!(
|
|
1333
|
+
github.artifact_name,
|
|
1334
|
+
format!(
|
|
1335
|
+
"starter-app-{}-{}.zip",
|
|
1336
|
+
report.make.package().platform(),
|
|
1337
|
+
report.make.package().arch()
|
|
1338
|
+
)
|
|
1339
|
+
);
|
|
1340
|
+
|
|
1341
|
+
let _ = fs::remove_dir_all(root);
|
|
1342
|
+
}
|
|
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
|
+
|
|
1484
|
+
#[test]
|
|
1485
|
+
fn publishes_make_artifact_to_github_release() {
|
|
1486
|
+
let server = MockGithubServer::new(3);
|
|
1487
|
+
let root = unique_temp_dir("github-execute");
|
|
1488
|
+
write_github_package_json(&root);
|
|
1489
|
+
write_app_file(&root);
|
|
1490
|
+
write_fake_electron_dist(&root);
|
|
1491
|
+
|
|
1492
|
+
let mut args = github_publish_args(root.clone(), false, &server.api_url);
|
|
1493
|
+
args.skip_make = true;
|
|
1494
|
+
let mut report = build_report(&args).expect("report should build");
|
|
1495
|
+
let artifact = Path::new(report.make.artifact().as_str());
|
|
1496
|
+
fs::create_dir_all(artifact.parent().expect("artifact parent should exist"))
|
|
1497
|
+
.expect("artifact parent should be created");
|
|
1498
|
+
fs::write(artifact, b"artifact bytes").expect("artifact should be written");
|
|
1499
|
+
|
|
1500
|
+
let agent = github_agent();
|
|
1501
|
+
publish_to_github(&mut report, "test-token", &agent)
|
|
1502
|
+
.expect("github publish should succeed");
|
|
1503
|
+
|
|
1504
|
+
let github = report.github.as_ref().expect("github plan should exist");
|
|
1505
|
+
let artifact_name = github.artifact_name.clone();
|
|
1506
|
+
assert_eq!(
|
|
1507
|
+
github.release_url.as_deref(),
|
|
1508
|
+
Some("https://github.com/Ikana/electron-cli/releases/tag/v0.1.0")
|
|
1509
|
+
);
|
|
1510
|
+
assert_eq!(
|
|
1511
|
+
github.asset_url.as_deref(),
|
|
1512
|
+
Some("https://github.com/Ikana/electron-cli/releases/download/v0.1.0/starter-app.zip")
|
|
1513
|
+
);
|
|
1514
|
+
|
|
1515
|
+
let requests = server.finish();
|
|
1516
|
+
assert_eq!(requests.len(), 3);
|
|
1517
|
+
assert_eq!(requests[0].method, "GET");
|
|
1518
|
+
assert_eq!(
|
|
1519
|
+
requests[0].path,
|
|
1520
|
+
"/repos/Ikana/electron-cli/releases/tags/v0.1.0"
|
|
1521
|
+
);
|
|
1522
|
+
assert_eq!(
|
|
1523
|
+
requests[0].header("authorization").as_deref(),
|
|
1524
|
+
Some("Bearer test-token")
|
|
1525
|
+
);
|
|
1526
|
+
assert_eq!(requests[1].method, "POST");
|
|
1527
|
+
assert_eq!(requests[1].path, "/repos/Ikana/electron-cli/releases");
|
|
1528
|
+
let release_body =
|
|
1529
|
+
String::from_utf8(requests[1].body.clone()).expect("release body should be utf-8");
|
|
1530
|
+
assert!(release_body.contains("\"tag_name\": \"v0.1.0\""));
|
|
1531
|
+
assert_eq!(requests[2].method, "POST");
|
|
1532
|
+
assert_eq!(
|
|
1533
|
+
requests[2].path,
|
|
1534
|
+
format!("/uploads/1?name={}", encode_url_component(&artifact_name))
|
|
1535
|
+
);
|
|
1536
|
+
assert_eq!(requests[2].body, b"artifact bytes");
|
|
1537
|
+
|
|
1538
|
+
let _ = fs::remove_dir_all(root);
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
#[test]
|
|
1542
|
+
fn skip_make_requires_existing_artifact() {
|
|
1543
|
+
let root = unique_temp_dir("skip-make");
|
|
1544
|
+
write_package_json(&root);
|
|
1545
|
+
write_app_file(&root);
|
|
1546
|
+
write_fake_electron_dist(&root);
|
|
1547
|
+
|
|
1548
|
+
let mut args = publish_args(root.clone(), false);
|
|
1549
|
+
args.skip_make = true;
|
|
1550
|
+
let mut report = build_report(&args).expect("report should build");
|
|
1551
|
+
|
|
1552
|
+
assert!(execute_publish(&mut report, &args).is_err());
|
|
1553
|
+
|
|
1554
|
+
let _ = fs::remove_dir_all(root);
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
#[test]
|
|
1558
|
+
fn parses_github_repository_urls() {
|
|
1559
|
+
assert_eq!(
|
|
1560
|
+
github_repo_from_repository("git+https://github.com/Ikana/electron-cli.git"),
|
|
1561
|
+
Some("Ikana/electron-cli".to_string())
|
|
1562
|
+
);
|
|
1563
|
+
assert_eq!(
|
|
1564
|
+
github_repo_from_repository("git@github.com:Ikana/electron-cli.git"),
|
|
1565
|
+
Some("Ikana/electron-cli".to_string())
|
|
1566
|
+
);
|
|
1567
|
+
assert_eq!(
|
|
1568
|
+
github_repo_from_repository("https://example.com/Ikana/electron-cli.git"),
|
|
1569
|
+
None
|
|
1570
|
+
);
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
fn publish_args(root: PathBuf, dry_run: bool) -> PublishArgs {
|
|
1574
|
+
PublishArgs {
|
|
1575
|
+
cwd: root,
|
|
1576
|
+
out_dir: PathBuf::from("out"),
|
|
1577
|
+
name: None,
|
|
1578
|
+
platform: None,
|
|
1579
|
+
arch: None,
|
|
1580
|
+
target: Some(crate::cli::MakeTarget::Zip),
|
|
1581
|
+
publisher: Some(crate::cli::PublishTarget::Local),
|
|
1582
|
+
to: Some(PathBuf::from("out/publish/local")),
|
|
1583
|
+
github_repo: None,
|
|
1584
|
+
github_tag: None,
|
|
1585
|
+
github_release_name: None,
|
|
1586
|
+
github_draft: false,
|
|
1587
|
+
github_prerelease: false,
|
|
1588
|
+
github_api_url: Some("https://api.github.com".to_string()),
|
|
1589
|
+
channel: Some("default".to_string()),
|
|
1590
|
+
skip_make: false,
|
|
1591
|
+
force: false,
|
|
1592
|
+
dry_run,
|
|
1593
|
+
json: true,
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
fn github_publish_args(root: PathBuf, dry_run: bool, api_url: &str) -> PublishArgs {
|
|
1598
|
+
let mut args = publish_args(root, dry_run);
|
|
1599
|
+
args.publisher = Some(crate::cli::PublishTarget::Github);
|
|
1600
|
+
args.github_api_url = Some(api_url.to_string());
|
|
1601
|
+
args
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
fn write_package_json(root: &Path) {
|
|
1605
|
+
fs::write(
|
|
1606
|
+
root.join("package.json"),
|
|
1607
|
+
r#"{"name":"starter-app","version":"0.1.0","main":"src/main.js","devDependencies":{"electron":"30.0.0"}}"#,
|
|
1608
|
+
)
|
|
1609
|
+
.expect("package.json should be written");
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
fn write_github_package_json(root: &Path) {
|
|
1613
|
+
fs::write(
|
|
1614
|
+
root.join("package.json"),
|
|
1615
|
+
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"}}"#,
|
|
1616
|
+
)
|
|
1617
|
+
.expect("package.json should be written");
|
|
1618
|
+
}
|
|
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
|
+
|
|
1652
|
+
fn write_app_file(root: &Path) {
|
|
1653
|
+
fs::create_dir_all(root.join("src")).expect("src should be created");
|
|
1654
|
+
fs::write(root.join("src/main.js"), "console.log('hello');")
|
|
1655
|
+
.expect("main file should be written");
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
fn write_fake_electron_dist(root: &Path) {
|
|
1659
|
+
let dist = root.join("node_modules/electron/dist");
|
|
1660
|
+
if cfg!(target_os = "macos") {
|
|
1661
|
+
let app = dist.join("Electron.app/Contents/MacOS");
|
|
1662
|
+
fs::create_dir_all(&app).expect("fake macOS electron app should be created");
|
|
1663
|
+
fs::write(app.join("Electron"), "").expect("fake macOS binary should be written");
|
|
1664
|
+
} else if cfg!(target_os = "windows") {
|
|
1665
|
+
fs::create_dir_all(&dist).expect("fake electron dist should be created");
|
|
1666
|
+
fs::write(dist.join("electron.exe"), "").expect("fake exe should be written");
|
|
1667
|
+
} else {
|
|
1668
|
+
fs::create_dir_all(&dist).expect("fake electron dist should be created");
|
|
1669
|
+
fs::write(dist.join("electron"), "").expect("fake binary should be written");
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
fn unique_temp_dir(label: &str) -> PathBuf {
|
|
1674
|
+
let nanos = std::time::SystemTime::now()
|
|
1675
|
+
.duration_since(std::time::UNIX_EPOCH)
|
|
1676
|
+
.expect("clock should be after epoch")
|
|
1677
|
+
.as_nanos();
|
|
1678
|
+
let path = std::env::temp_dir().join(format!(
|
|
1679
|
+
"electron-cli-publish-{label}-{}-{nanos}",
|
|
1680
|
+
std::process::id()
|
|
1681
|
+
));
|
|
1682
|
+
fs::create_dir_all(&path).expect("temp dir should be created");
|
|
1683
|
+
path
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
#[derive(Debug, Clone)]
|
|
1687
|
+
struct RecordedRequest {
|
|
1688
|
+
method: String,
|
|
1689
|
+
path: String,
|
|
1690
|
+
headers: Vec<(String, String)>,
|
|
1691
|
+
body: Vec<u8>,
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
impl RecordedRequest {
|
|
1695
|
+
fn header(&self, name: &str) -> Option<String> {
|
|
1696
|
+
self.headers
|
|
1697
|
+
.iter()
|
|
1698
|
+
.find(|(key, _)| key.eq_ignore_ascii_case(name))
|
|
1699
|
+
.map(|(_, value)| value.clone())
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
struct MockGithubServer {
|
|
1704
|
+
api_url: String,
|
|
1705
|
+
requests: Arc<Mutex<Vec<RecordedRequest>>>,
|
|
1706
|
+
handle: Option<thread::JoinHandle<()>>,
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
impl MockGithubServer {
|
|
1710
|
+
fn new(request_count: usize) -> Self {
|
|
1711
|
+
let listener = TcpListener::bind("127.0.0.1:0").expect("server should bind");
|
|
1712
|
+
let address = listener.local_addr().expect("server address should read");
|
|
1713
|
+
let api_url = format!("http://{address}");
|
|
1714
|
+
let requests = Arc::new(Mutex::new(Vec::new()));
|
|
1715
|
+
let thread_requests = Arc::clone(&requests);
|
|
1716
|
+
let thread_api_url = api_url.clone();
|
|
1717
|
+
let handle = thread::spawn(move || {
|
|
1718
|
+
for index in 0..request_count {
|
|
1719
|
+
let (mut stream, _) = listener.accept().expect("request should connect");
|
|
1720
|
+
let request = read_http_request(&mut stream);
|
|
1721
|
+
thread_requests
|
|
1722
|
+
.lock()
|
|
1723
|
+
.expect("requests should lock")
|
|
1724
|
+
.push(request);
|
|
1725
|
+
|
|
1726
|
+
match index {
|
|
1727
|
+
0 => write_http_response(&mut stream, 404, r#"{"message":"Not Found"}"#),
|
|
1728
|
+
1 => write_http_response(
|
|
1729
|
+
&mut stream,
|
|
1730
|
+
201,
|
|
1731
|
+
&format!(
|
|
1732
|
+
r#"{{
|
|
1733
|
+
"html_url":"https://github.com/Ikana/electron-cli/releases/tag/v0.1.0",
|
|
1734
|
+
"upload_url":"{}/uploads/1{{?name,label}}",
|
|
1735
|
+
"assets":[]
|
|
1736
|
+
}}"#,
|
|
1737
|
+
thread_api_url
|
|
1738
|
+
),
|
|
1739
|
+
),
|
|
1740
|
+
_ => write_http_response(
|
|
1741
|
+
&mut stream,
|
|
1742
|
+
201,
|
|
1743
|
+
r#"{
|
|
1744
|
+
"id":42,
|
|
1745
|
+
"name":"starter-app",
|
|
1746
|
+
"browser_download_url":"https://github.com/Ikana/electron-cli/releases/download/v0.1.0/starter-app.zip"
|
|
1747
|
+
}"#,
|
|
1748
|
+
),
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
});
|
|
1752
|
+
|
|
1753
|
+
Self {
|
|
1754
|
+
api_url,
|
|
1755
|
+
requests,
|
|
1756
|
+
handle: Some(handle),
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
fn finish(mut self) -> Vec<RecordedRequest> {
|
|
1761
|
+
if let Some(handle) = self.handle.take() {
|
|
1762
|
+
handle.join().expect("server thread should finish");
|
|
1763
|
+
}
|
|
1764
|
+
self.requests.lock().expect("requests should lock").clone()
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
fn read_http_request(stream: &mut TcpStream) -> RecordedRequest {
|
|
1769
|
+
let mut bytes = Vec::new();
|
|
1770
|
+
let header_end = loop {
|
|
1771
|
+
let mut buffer = [0; 1024];
|
|
1772
|
+
let read = stream.read(&mut buffer).expect("request should read");
|
|
1773
|
+
assert!(read > 0, "connection closed before request headers");
|
|
1774
|
+
bytes.extend_from_slice(&buffer[..read]);
|
|
1775
|
+
if let Some(position) = bytes.windows(4).position(|window| window == b"\r\n\r\n") {
|
|
1776
|
+
break position + 4;
|
|
1777
|
+
}
|
|
1778
|
+
};
|
|
1779
|
+
|
|
1780
|
+
let headers_text =
|
|
1781
|
+
String::from_utf8(bytes[..header_end].to_vec()).expect("headers should be utf-8");
|
|
1782
|
+
let mut lines = headers_text.split("\r\n");
|
|
1783
|
+
let request_line = lines.next().expect("request line should exist");
|
|
1784
|
+
let mut request_parts = request_line.split_whitespace();
|
|
1785
|
+
let method = request_parts
|
|
1786
|
+
.next()
|
|
1787
|
+
.expect("request method should exist")
|
|
1788
|
+
.to_string();
|
|
1789
|
+
let path = request_parts
|
|
1790
|
+
.next()
|
|
1791
|
+
.expect("request path should exist")
|
|
1792
|
+
.to_string();
|
|
1793
|
+
let headers = lines
|
|
1794
|
+
.filter_map(|line| line.split_once(':'))
|
|
1795
|
+
.map(|(key, value)| (key.trim().to_string(), value.trim().to_string()))
|
|
1796
|
+
.collect::<Vec<_>>();
|
|
1797
|
+
let content_length = headers
|
|
1798
|
+
.iter()
|
|
1799
|
+
.find(|(key, _)| key.eq_ignore_ascii_case("content-length"))
|
|
1800
|
+
.and_then(|(_, value)| value.parse::<usize>().ok())
|
|
1801
|
+
.unwrap_or(0);
|
|
1802
|
+
while bytes.len() < header_end + content_length {
|
|
1803
|
+
let mut buffer = [0; 1024];
|
|
1804
|
+
let read = stream.read(&mut buffer).expect("request body should read");
|
|
1805
|
+
assert!(read > 0, "connection closed before request body");
|
|
1806
|
+
bytes.extend_from_slice(&buffer[..read]);
|
|
1807
|
+
}
|
|
1808
|
+
let body = bytes[header_end..header_end + content_length].to_vec();
|
|
1809
|
+
|
|
1810
|
+
RecordedRequest {
|
|
1811
|
+
method,
|
|
1812
|
+
path,
|
|
1813
|
+
headers,
|
|
1814
|
+
body,
|
|
1815
|
+
}
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
fn write_http_response(stream: &mut TcpStream, status: u16, body: &str) {
|
|
1819
|
+
let reason = match status {
|
|
1820
|
+
201 => "Created",
|
|
1821
|
+
404 => "Not Found",
|
|
1822
|
+
_ => "OK",
|
|
1823
|
+
};
|
|
1824
|
+
write!(
|
|
1825
|
+
stream,
|
|
1826
|
+
"HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
|
1827
|
+
body.len(),
|
|
1828
|
+
body
|
|
1829
|
+
)
|
|
1830
|
+
.expect("response should write");
|
|
1831
|
+
}
|
|
1832
|
+
}
|