1use anyhow::{bail, Context, Result};
2use clap::ValueEnum;
3use fission_command_core::{
4 normalize_windows_package_version, resolve_release_version_config,
5 sync_resolved_release_platform_config, DistributionProvider, FissionProject, NativeVariant,
6 Target,
7};
8use serde::{Deserialize, Serialize};
9use serde_json::{json, Value};
10use sha2::{Digest, Sha256};
11use std::collections::BTreeMap;
12use std::env;
13use std::ffi::OsStr;
14use std::fs;
15use std::io::{self, Read, Write};
16use std::path::{Path, PathBuf};
17use std::process::{Command, Stdio};
18
19mod artifact;
20mod distribution;
21mod docker_registry;
22mod files;
23mod github_releases;
24mod macos_notarization;
25mod package;
26mod publish_shell;
27mod readiness;
28mod static_hosts;
29mod stores;
30mod support;
31
32use support::*;
33
34use artifact::*;
35use distribution::*;
36use readiness::*;
37
38const ARTIFACT_MANIFEST: &str = "artifact-manifest.json";
39
40#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
41pub enum PackageFormat {
42 Aab,
43 Apk,
44 App,
45 #[value(name = "docker-image")]
46 DockerImage,
47 Exe,
48 Ipa,
49 Msi,
50 Msix,
51 Pkg,
52 Run,
53 Static,
54}
55
56impl PackageFormat {
57 pub fn as_str(self) -> &'static str {
58 match self {
59 Self::Aab => "aab",
60 Self::Apk => "apk",
61 Self::App => "app",
62 Self::DockerImage => "docker-image",
63 Self::Exe => "exe",
64 Self::Ipa => "ipa",
65 Self::Msi => "msi",
66 Self::Msix => "msix",
67 Self::Pkg => "pkg",
68 Self::Run => "run",
69 Self::Static => "static",
70 }
71 }
72}
73
74#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
75pub enum DistributeAction {
76 Setup,
77 Publish,
78 Status,
79 Promote,
80 Rollback,
81}
82
83impl DistributeAction {
84 fn as_str(self) -> &'static str {
85 match self {
86 Self::Setup => "setup",
87 Self::Publish => "publish",
88 Self::Status => "status",
89 Self::Promote => "promote",
90 Self::Rollback => "rollback",
91 }
92 }
93}
94
95#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
96pub enum ReadinessKind {
97 Package,
98 Distribute,
99 Release,
100}
101
102#[derive(Clone, Debug)]
103pub struct PackageOptions {
104 pub project_dir: PathBuf,
105 pub target: Target,
106 pub format: PackageFormat,
107 pub release: bool,
108 pub variant: Option<NativeVariant>,
109 pub json: bool,
110}
111
112#[derive(Clone, Debug)]
113pub struct DistributeOptions {
114 pub project_dir: PathBuf,
115 pub provider: DistributionProvider,
116 pub action: DistributeAction,
117 pub target: Option<Target>,
118 pub format: Option<PackageFormat>,
119 pub artifact: Option<PathBuf>,
120 pub site: String,
121 pub deploy: Option<String>,
122 pub track: Option<String>,
123 pub locales: Vec<String>,
124 pub dry_run: bool,
125 pub yes: bool,
126 pub json: bool,
127}
128
129#[derive(Clone, Debug, Serialize, Deserialize)]
130pub struct DistributionEvent {
131 pub at_unix_seconds: u64,
132 pub id: String,
133 pub status: String,
134 pub details: Option<String>,
135}
136
137#[derive(Clone, Debug, Serialize, Deserialize)]
138pub struct DistributionPublishOutcome {
139 pub receipt: Value,
140 pub events: Vec<DistributionEvent>,
141}
142
143#[derive(Clone, Debug)]
144pub struct ReadinessOptions {
145 pub project_dir: PathBuf,
146 pub kind: ReadinessKind,
147 pub target: Option<Target>,
148 pub format: Option<PackageFormat>,
149 pub provider: Option<DistributionProvider>,
150 pub artifact: Option<PathBuf>,
151 pub site: String,
152 pub track: Option<String>,
153 pub release: bool,
154 pub json: bool,
155}
156
157pub use publish_shell::{
158 default_format_for_target_provider, default_target_for_provider, default_track_for_provider,
159 ensure_publish_workspace, publish_flow_snapshot, run_publish_shell,
160 run_publish_shell_with_hooks, PublishFlowSnapshot, PublishShellHooks, PublishShellOptions,
161 PublishShellProviderCapability, PublishShellReleasePlan, PublishShellReleaseStep,
162 PublishShellWorkflowRequest,
163};
164
165#[derive(Debug, Serialize, Deserialize)]
166struct ArtifactManifest {
167 schema_version: u32,
168 created_at_unix_seconds: u64,
169 project: ArtifactProject,
170 target: String,
171 format: String,
172 profile: String,
173 #[serde(default, skip_serializing_if = "Option::is_none")]
174 variant: Option<String>,
175 root_dir: String,
176 #[serde(default, skip_serializing_if = "Vec::is_empty")]
177 source_config: Vec<ArtifactSourceConfig>,
178 artifacts: Vec<ArtifactFile>,
179 #[serde(default, skip_serializing_if = "Option::is_none")]
180 icon_manifest: Option<ArtifactIconManifest>,
181 #[serde(default, skip_serializing_if = "Option::is_none")]
182 signing: Option<ArtifactSigning>,
183 #[serde(default, skip_serializing_if = "Option::is_none")]
184 notarization: Option<Value>,
185 validation: ArtifactValidation,
186}
187
188#[derive(Debug, Serialize, Deserialize)]
189struct ArtifactProject {
190 app_id: String,
191 name: String,
192 #[serde(default, skip_serializing_if = "Option::is_none")]
193 build: Option<u64>,
194 version: Option<String>,
195}
196
197#[derive(Debug, Serialize, Deserialize)]
198struct ArtifactFile {
199 kind: String,
200 purpose: Option<String>,
201 platform: Option<String>,
202 upload_provider: Option<String>,
203 path: String,
204 relative_path: String,
205 sha256: String,
206 size_bytes: u64,
207 mime_type: String,
208}
209
210#[derive(Debug, Serialize, Deserialize)]
211struct ArtifactSourceConfig {
212 kind: String,
213 path: String,
214 sha256: String,
215}
216
217#[derive(Debug, Serialize, Deserialize)]
218struct ArtifactIconManifest {
219 path: String,
220 sha256: String,
221 outputs: usize,
222}
223
224#[derive(Debug, Serialize, Deserialize)]
225struct ArtifactSigning {
226 state: String,
227 identity: Option<String>,
228 certificate_sha256: Option<String>,
229}
230
231#[derive(Debug, Serialize, Deserialize)]
232struct ArtifactValidation {
233 state: String,
234 checks: Vec<ReadinessCheck>,
235}
236
237#[derive(Clone, Debug, Serialize, Deserialize)]
238pub struct ReadinessCheck {
239 pub id: String,
240 pub severity: CheckSeverity,
241 pub status: CheckStatus,
242 pub summary: String,
243 pub details: Option<String>,
244 pub remediation: Vec<String>,
245}
246
247#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
248#[serde(rename_all = "kebab-case")]
249pub enum CheckSeverity {
250 Error,
251 Warning,
252 Info,
253}
254
255#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
256#[serde(rename_all = "kebab-case")]
257pub enum CheckStatus {
258 Passed,
259 Missing,
260 Failed,
261 Warning,
262 Skipped,
263}
264
265#[derive(Debug, Serialize)]
266pub(crate) struct ReadinessReport {
267 project_dir: String,
268 target: Option<String>,
269 format: Option<String>,
270 provider: Option<String>,
271 site: Option<String>,
272 status: String,
273 checks: Vec<ReadinessCheck>,
274}
275
276#[derive(Debug, Serialize)]
277struct DistributionReceipt {
278 schema_version: u32,
279 created_at_unix_seconds: u64,
280 provider: String,
281 site: String,
282 action: String,
283 artifact_manifest: Option<String>,
284 deployment_id: Option<String>,
285 canonical_url: Option<String>,
286 preview_url: Option<String>,
287 custom_domain: Option<String>,
288 status: String,
289 stdout: Option<String>,
290 stderr: Option<String>,
291 manual_follow_up: Vec<String>,
292}
293
294#[derive(Debug, Serialize)]
295struct DistributionReceiptView<'a> {
296 #[serde(flatten)]
297 receipt: &'a DistributionReceipt,
298 #[serde(skip_serializing_if = "Option::is_none")]
299 release_id: Option<String>,
300 #[serde(skip_serializing_if = "Option::is_none")]
301 target: Option<String>,
302 #[serde(skip_serializing_if = "Option::is_none")]
303 format: Option<String>,
304 #[serde(rename = "track_channel", skip_serializing_if = "Option::is_none")]
305 track: Option<String>,
306 #[serde(skip_serializing_if = "Vec::is_empty")]
307 locales: Vec<String>,
308 #[serde(skip_serializing_if = "Option::is_none")]
309 version: Option<String>,
310 #[serde(skip_serializing_if = "Option::is_none")]
311 build: Option<u64>,
312 #[serde(skip_serializing_if = "Option::is_none")]
313 artifact_hash: Option<String>,
314 #[serde(skip_serializing_if = "Option::is_none")]
315 artifact_manifest_sha256: Option<String>,
316 #[serde(skip_serializing_if = "Option::is_none")]
317 release_content_manifest: Option<String>,
318 #[serde(skip_serializing_if = "Option::is_none")]
319 release_content_manifest_sha256: Option<String>,
320 #[serde(skip_serializing_if = "Vec::is_empty")]
321 release_content_assets: Vec<Value>,
322 uploaded_bytes: u64,
323 #[serde(skip_serializing_if = "Vec::is_empty")]
324 uploaded_assets: Vec<Value>,
325}
326
327#[derive(Debug, Deserialize, Default)]
328struct PublishManifest {
329 site: Option<SiteManifest>,
330 distribution: Option<DistributionManifest>,
331}
332
333#[derive(Debug, Deserialize, Default)]
334struct SiteManifest {
335 entry: Option<String>,
336 out_dir: Option<String>,
337}
338
339#[derive(Debug, Deserialize, Default)]
340struct DistributionManifest {
341 #[serde(default)]
342 s3: BTreeMap<String, S3Config>,
343 #[serde(default)]
344 google_drive: BTreeMap<String, GoogleDriveConfig>,
345 #[serde(default)]
346 onedrive: BTreeMap<String, OneDriveConfig>,
347 #[serde(default)]
348 dropbox: BTreeMap<String, DropboxConfig>,
349 play_store: Option<PlayStoreConfig>,
350 app_store: Option<AppStoreConfig>,
351 microsoft_store: Option<MicrosoftStoreConfig>,
352 #[serde(default)]
353 github_pages: BTreeMap<String, GithubPagesConfig>,
354 #[serde(default)]
355 github_releases: BTreeMap<String, GithubReleasesConfig>,
356 #[serde(default)]
357 cloudflare_pages: BTreeMap<String, CloudflarePagesConfig>,
358 #[serde(default)]
359 docker_registry: BTreeMap<String, DockerRegistryConfig>,
360 #[serde(default)]
361 netlify: BTreeMap<String, NetlifyConfig>,
362}
363
364#[derive(Clone, Debug, Deserialize, Default)]
365struct S3Config {
366 endpoint: Option<String>,
367 region: Option<String>,
368 bucket: Option<String>,
369 prefix: Option<String>,
370 profile: Option<String>,
371 path_style: Option<bool>,
372 visibility: Option<String>,
373 presign_ttl_seconds: Option<u64>,
374 overwrite: Option<bool>,
375 cache_control: Option<String>,
376}
377
378#[derive(Clone, Debug, Deserialize, Default)]
379struct GoogleDriveConfig {
380 folder_id: Option<String>,
381 name_prefix: Option<String>,
382 share: Option<bool>,
383}
384
385#[derive(Clone, Debug, Deserialize, Default)]
386struct OneDriveConfig {
387 root: Option<String>,
388 path_prefix: Option<String>,
389 conflict_behavior: Option<String>,
390}
391
392#[derive(Clone, Debug, Deserialize, Default)]
393struct DropboxConfig {
394 path_prefix: Option<String>,
395 mode: Option<String>,
396 autorename: Option<bool>,
397}
398
399#[derive(Clone, Debug, Deserialize, Default)]
400struct PlayStoreConfig {
401 package_name: Option<String>,
402 default_track: Option<String>,
403 release_status: Option<String>,
404 access_token_env: Option<String>,
405 service_account_json_env: Option<String>,
406 service_account_json_base64_env: Option<String>,
407 google_application_credentials_env: Option<String>,
408}
409
410#[derive(Clone, Debug, Deserialize, Default)]
411struct AppStoreConfig {
412 app_id: Option<String>,
413 bundle_id: Option<String>,
414 issuer_id: Option<String>,
415 key_id: Option<String>,
416 default_track: Option<String>,
417 access_token_env: Option<String>,
418 issuer_id_env: Option<String>,
419 key_id_env: Option<String>,
420 api_key_env: Option<String>,
421 api_key_base64_env: Option<String>,
422 api_key_path_env: Option<String>,
423}
424
425#[derive(Clone, Debug, Deserialize, Default)]
426struct MicrosoftStoreConfig {
427 product_id: Option<String>,
428 package_identity_name: Option<String>,
429 tenant_id: Option<String>,
430 client_id: Option<String>,
431 seller_id: Option<String>,
432 token_env: Option<String>,
433 tenant_id_env: Option<String>,
434 client_id_env: Option<String>,
435 client_secret_env: Option<String>,
436 seller_id_env: Option<String>,
437 package_url: Option<String>,
438 package_type: Option<String>,
439 flight_id: Option<String>,
440 package_rollout_percentage: Option<u8>,
441 msstore_project: Option<String>,
442 languages: Option<Vec<String>>,
443 architectures: Option<Vec<String>>,
444 is_silent_install: Option<bool>,
445 installer_parameters: Option<String>,
446 generic_doc_url: Option<String>,
447 submit: Option<bool>,
448}
449
450#[derive(Clone, Debug, Deserialize, Default)]
451struct GithubPagesConfig {
452 owner: Option<String>,
453 repo: Option<String>,
454 mode: Option<String>,
455 source: Option<String>,
456 source_branch: Option<String>,
457 source_path: Option<String>,
458 site_kind: Option<String>,
459 base_path: Option<String>,
460 custom_domain: Option<String>,
461 enforce_https: Option<bool>,
462 remote: Option<String>,
463 production_branch: Option<String>,
464 workflow: Option<String>,
465}
466
467#[derive(Clone, Debug, Deserialize, Default)]
468struct GithubReleasesConfig {
469 owner: Option<String>,
470 repo: Option<String>,
471 tag: Option<String>,
472 name: Option<String>,
473 target_commitish: Option<String>,
474 notes: Option<String>,
475 notes_file: Option<String>,
476 draft: Option<bool>,
477 prerelease: Option<bool>,
478 make_latest: Option<String>,
479 replace_assets: Option<bool>,
480 upload_artifact_manifest: Option<bool>,
481}
482
483#[derive(Clone, Debug, Deserialize, Default)]
484struct CloudflarePagesConfig {
485 account_id: Option<String>,
486 project_name: Option<String>,
487 environment: Option<String>,
488 custom_domain: Option<String>,
489 base_path: Option<String>,
490}
491
492#[derive(Clone, Debug, Deserialize, Default)]
493struct NetlifyConfig {
494 site_id: Option<String>,
495 team_slug: Option<String>,
496 production: Option<bool>,
497 custom_domain: Option<String>,
498 base_path: Option<String>,
499}
500
501#[derive(Clone, Debug, Deserialize, Default)]
502struct DockerRegistryConfig {
503 tags: Option<Vec<String>>,
504}
505
506pub fn package(options: PackageOptions) -> Result<()> {
507 if options.release {
508 sync_resolved_release_platform_config(&options.project_dir, options.target)?;
509 }
510 let manifest = package::package_artifact(&options)?;
511 if options.json {
512 println!("{}", serde_json::to_string_pretty(&manifest)?);
513 } else {
514 println!(
515 "Packaged {} {} artifact into {}",
516 manifest.target, manifest.format, manifest.root_dir
517 );
518 println!("{} files", manifest.artifacts.len());
519 println!(
520 "{}",
521 Path::new(&manifest.root_dir)
522 .join(ARTIFACT_MANIFEST)
523 .display()
524 );
525 }
526 Ok(())
527}
528
529pub fn package_silent(options: PackageOptions) -> Result<PathBuf> {
530 if options.release {
531 sync_resolved_release_platform_config(&options.project_dir, options.target)?;
532 }
533 package::package_artifact(&options)?;
534 Ok(package_artifact_manifest_path(
535 &options.project_dir,
536 options.target,
537 options.format,
538 options.release,
539 ))
540}
541
542pub fn package_artifact_manifest_path(
543 project_dir: &Path,
544 target: Target,
545 format: PackageFormat,
546 release: bool,
547) -> PathBuf {
548 default_artifact_manifest_path_for_format(project_dir, target, format, release)
549}
550
551pub fn package_readiness_checks(
552 project_dir: &Path,
553 target: Option<Target>,
554 format: Option<PackageFormat>,
555) -> Result<Vec<ReadinessCheck>> {
556 readiness_package(project_dir, target, format, false)
557}
558
559pub fn package_readiness_checks_for_profile(
560 project_dir: &Path,
561 target: Option<Target>,
562 format: Option<PackageFormat>,
563 release: bool,
564) -> Result<Vec<ReadinessCheck>> {
565 readiness_package(project_dir, target, format, release)
566}
567
568pub fn distribution_readiness_checks(
569 project_dir: &Path,
570 provider: DistributionProvider,
571 site: &str,
572 track: Option<&str>,
573 format: Option<PackageFormat>,
574 artifact: Option<&Path>,
575) -> Result<Vec<ReadinessCheck>> {
576 let config = load_publish_manifest(project_dir)?;
577 readiness_distribute(
578 project_dir,
579 provider,
580 site,
581 track,
582 format,
583 artifact,
584 &config,
585 )
586}
587
588pub fn distribute(options: DistributeOptions) -> Result<()> {
589 let mut events = Vec::new();
590 push_distribution_event(
591 &mut events,
592 "distribution.config",
593 "started",
594 Some(options.provider.as_str().to_string()),
595 );
596 let config = match load_publish_manifest(&options.project_dir) {
597 Ok(config) => config,
598 Err(error) => {
599 let message = error.to_string();
600 let receipt_path = write_failed_distribution_receipt(
601 &options,
602 &mut events,
603 "distribution.config",
604 &message,
605 )?;
606 bail!(
607 "distribution config failed: {}; distribution receipt: {}",
608 redact_sensitive_text(&message),
609 receipt_path.display()
610 );
611 }
612 };
613 push_distribution_event(&mut events, "distribution.config", "completed", None);
614 let result = match options.action {
615 DistributeAction::Setup => setup_provider(&options, &config),
616 DistributeAction::Status => provider_status(&options, &config),
617 DistributeAction::Promote | DistributeAction::Rollback => {
618 provider_lifecycle(&options, &config)
619 }
620 DistributeAction::Publish => publish_artifact(&options, &config),
621 };
622 if let Err(error) = result {
623 let message = error.to_string();
624 if message.contains("distribution receipt:") {
625 bail!("{message}");
626 }
627 let receipt_path = write_failed_distribution_receipt(
628 &options,
629 &mut events,
630 distribute_action_stage(options.action),
631 &message,
632 )?;
633 bail!(
634 "{} failed: {}; distribution receipt: {}",
635 options.action.as_str(),
636 redact_sensitive_text(&message),
637 receipt_path.display()
638 );
639 }
640 if options.action == DistributeAction::Setup {
641 let receipt_path = write_setup_distribution_receipt(&options, &mut events)?;
642 if !options.json {
643 println!("Distribution receipt: {}", receipt_path.display());
644 }
645 }
646 Ok(())
647}
648
649pub fn distribute_publish_value(options: DistributeOptions) -> Result<Value> {
650 distribute_publish_outcome(options).map(|outcome| outcome.receipt)
651}
652
653pub fn distribute_status_value(options: DistributeOptions) -> Result<Value> {
654 distribute_status_outcome(options).map(|outcome| outcome.receipt)
655}
656
657pub fn distribute_publish_outcome(
658 options: DistributeOptions,
659) -> Result<DistributionPublishOutcome> {
660 if options.action != DistributeAction::Publish {
661 bail!("distribute_publish_value only supports publish actions");
662 }
663 let mut events = Vec::new();
664 push_distribution_event(
665 &mut events,
666 "distribution.config",
667 "started",
668 Some(options.provider.as_str().to_string()),
669 );
670 let config = match load_publish_manifest(&options.project_dir) {
671 Ok(config) => config,
672 Err(error) => {
673 let message = error.to_string();
674 let receipt_path = write_failed_distribution_receipt(
675 &options,
676 &mut events,
677 "distribution.config",
678 &message,
679 )?;
680 bail!(
681 "distribution config failed: {}; distribution receipt: {}",
682 redact_sensitive_text(&message),
683 receipt_path.display()
684 );
685 }
686 };
687 push_distribution_event(&mut events, "distribution.config", "completed", None);
688 let (_, value) = match publish_artifact_receipt_with_events(&options, &config, &mut events) {
689 Ok(value) => value,
690 Err(error) => {
691 let message = error.to_string();
692 let receipt_path = write_failed_distribution_receipt(
693 &options,
694 &mut events,
695 "distribution.publish",
696 &message,
697 )?;
698 bail!(
699 "distribution publish failed: {}; distribution receipt: {}",
700 redact_sensitive_text(&message),
701 receipt_path.display()
702 );
703 }
704 };
705 Ok(DistributionPublishOutcome {
706 receipt: value,
707 events,
708 })
709}
710
711pub fn distribute_status_outcome(options: DistributeOptions) -> Result<DistributionPublishOutcome> {
712 if options.action != DistributeAction::Status {
713 bail!("distribute_status_value only supports status actions");
714 }
715 let mut events = Vec::new();
716 push_distribution_event(
717 &mut events,
718 "distribution.config",
719 "started",
720 Some(options.provider.as_str().to_string()),
721 );
722 let config = match load_publish_manifest(&options.project_dir) {
723 Ok(config) => config,
724 Err(error) => {
725 let message = error.to_string();
726 let receipt_path = write_failed_distribution_receipt(
727 &options,
728 &mut events,
729 "distribution.config",
730 &message,
731 )?;
732 bail!(
733 "distribution config failed: {}; distribution receipt: {}",
734 redact_sensitive_text(&message),
735 receipt_path.display()
736 );
737 }
738 };
739 push_distribution_event(&mut events, "distribution.config", "completed", None);
740 push_distribution_event(
741 &mut events,
742 "provider.status",
743 "started",
744 Some(options.provider.as_str().to_string()),
745 );
746 push_provider_request_event(&mut events, &options, "status");
747 let receipt = match provider_status_receipt(&options, &config) {
748 Ok(receipt) => receipt,
749 Err(error) => {
750 let message = error.to_string();
751 let receipt_path = write_failed_distribution_receipt(
752 &options,
753 &mut events,
754 "provider.status",
755 &message,
756 )?;
757 bail!(
758 "distribution status failed: {}; distribution receipt: {}",
759 redact_sensitive_text(&message),
760 receipt_path.display()
761 );
762 }
763 };
764 push_provider_response_event(&mut events, &receipt);
765 push_distribution_event(
766 &mut events,
767 "provider.status",
768 receipt.status.as_str(),
769 Some(provider_event_detail(&receipt)),
770 );
771 let receipt_path = receipt_output_path(&options.project_dir, &receipt);
772 push_distribution_event(
773 &mut events,
774 "distribution.receipt",
775 "written",
776 Some(receipt_path.display().to_string()),
777 );
778 let value = distribution_receipt_value_with_events(&options, &receipt, None, None, &events)?;
779 write_receipt(&options.project_dir, &receipt, &value)?;
780 Ok(DistributionPublishOutcome {
781 receipt: value,
782 events,
783 })
784}
785
786pub fn readiness(options: ReadinessOptions) -> Result<()> {
787 let checks = match options.kind {
788 ReadinessKind::Package => readiness_package(
789 &options.project_dir,
790 options.target,
791 options.format,
792 options.release,
793 ),
794 ReadinessKind::Release => {
795 let config = load_publish_manifest(&options.project_dir)?;
796 let mut checks = readiness_package(
797 &options.project_dir,
798 options.target,
799 options.format,
800 options.release,
801 )?;
802 let provider = options
803 .provider
804 .context("readiness release requires --provider")?;
805 checks.extend(readiness_distribute(
806 &options.project_dir,
807 provider,
808 &options.site,
809 options.track.as_deref(),
810 options.format,
811 options.artifact.as_deref(),
812 &config,
813 )?);
814 Ok(checks)
815 }
816 ReadinessKind::Distribute => {
817 let config = load_publish_manifest(&options.project_dir)?;
818 let provider = options
819 .provider
820 .context("readiness distribute requires --provider")?;
821 let artifact = options.artifact.as_deref();
822 readiness_distribute(
823 &options.project_dir,
824 provider,
825 &options.site,
826 options.track.as_deref(),
827 options.format,
828 artifact,
829 &config,
830 )
831 }
832 }?;
833 let report = ReadinessReport {
834 project_dir: options.project_dir.display().to_string(),
835 target: options.target.map(|target| target.as_str().to_string()),
836 format: options.format.map(|format| format.as_str().to_string()),
837 provider: options
838 .provider
839 .map(|provider| provider.as_str().to_string()),
840 site: matches!(
841 options.kind,
842 ReadinessKind::Distribute | ReadinessKind::Release
843 )
844 .then(|| options.site.clone()),
845 status: report_status(&checks).to_string(),
846 checks,
847 };
848 if options.json {
849 println!("{}", serde_json::to_string_pretty(&report)?);
850 } else {
851 print_readiness_report(&report);
852 }
853 if report.status == "blocked" {
854 bail!("readiness checks failed");
855 }
856 Ok(())
857}
858
859fn load_publish_manifest(project_dir: &Path) -> Result<PublishManifest> {
860 let path = project_dir.join("fission.toml");
861 let data =
862 fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
863 toml::from_str(&data).with_context(|| format!("failed to parse {}", path.display()))
864}
865
866fn site_output_dir(project_dir: &Path) -> Result<PathBuf> {
867 let manifest = load_publish_manifest(project_dir)?;
868 Ok(manifest
869 .site
870 .and_then(|site| site.out_dir)
871 .map(|path| resolve_project_path(project_dir, path))
872 .unwrap_or_else(|| project_dir.join("target/fission/site")))
873}
874
875fn read_artifact_manifest(path: &Path) -> Result<ArtifactManifest> {
876 let data = fs::read_to_string(path)
877 .with_context(|| format!("failed to read artifact manifest {}", path.display()))?;
878 serde_json::from_str(&data)
879 .with_context(|| format!("failed to parse artifact manifest {}", path.display()))
880}
881
882fn default_artifact_manifest_path(project_dir: &Path, target: Target, release: bool) -> PathBuf {
883 default_artifact_manifest_path_for_format(project_dir, target, PackageFormat::Static, release)
884}
885
886fn default_artifact_manifest_path_for_format(
887 project_dir: &Path,
888 target: Target,
889 format: PackageFormat,
890 release: bool,
891) -> PathBuf {
892 project_dir
893 .join("target/fission")
894 .join(if release { "release" } else { "debug" })
895 .join(target.as_str())
896 .join(format.as_str())
897 .join(ARTIFACT_MANIFEST)
898}
899
900fn github_config(config: &PublishManifest, site: &str) -> Result<GithubPagesConfig> {
901 Ok(config
902 .distribution
903 .as_ref()
904 .and_then(|distribution| distribution.github_pages.get(site))
905 .cloned()
906 .unwrap_or_default())
907}
908
909fn github_releases_config(config: &PublishManifest, site: &str) -> Result<GithubReleasesConfig> {
910 config
911 .distribution
912 .as_ref()
913 .and_then(|distribution| distribution.github_releases.get(site))
914 .cloned()
915 .with_context(|| format!("missing [distribution.github_releases.{site}] in fission.toml"))
916}
917
918fn docker_registry_config(config: &PublishManifest, site: &str) -> Result<DockerRegistryConfig> {
919 Ok(config
920 .distribution
921 .as_ref()
922 .and_then(|distribution| distribution.docker_registry.get(site))
923 .cloned()
924 .unwrap_or_default())
925}
926
927fn s3_config(config: &PublishManifest, site: &str) -> Result<S3Config> {
928 config
929 .distribution
930 .as_ref()
931 .and_then(|distribution| distribution.s3.get(site))
932 .cloned()
933 .with_context(|| format!("missing [distribution.s3.{site}] in fission.toml"))
934}
935
936fn google_drive_config(config: &PublishManifest, site: &str) -> Result<GoogleDriveConfig> {
937 Ok(config
938 .distribution
939 .as_ref()
940 .and_then(|distribution| distribution.google_drive.get(site))
941 .cloned()
942 .unwrap_or_default())
943}
944
945fn onedrive_config(config: &PublishManifest, site: &str) -> Result<OneDriveConfig> {
946 Ok(config
947 .distribution
948 .as_ref()
949 .and_then(|distribution| distribution.onedrive.get(site))
950 .cloned()
951 .unwrap_or_default())
952}
953
954fn dropbox_config(config: &PublishManifest, site: &str) -> Result<DropboxConfig> {
955 Ok(config
956 .distribution
957 .as_ref()
958 .and_then(|distribution| distribution.dropbox.get(site))
959 .cloned()
960 .unwrap_or_default())
961}
962
963fn cloudflare_config(config: &PublishManifest, site: &str) -> Result<CloudflarePagesConfig> {
964 config
965 .distribution
966 .as_ref()
967 .and_then(|distribution| distribution.cloudflare_pages.get(site))
968 .cloned()
969 .with_context(|| format!("missing [distribution.cloudflare_pages.{site}] in fission.toml"))
970}
971
972fn netlify_config(config: &PublishManifest, site: &str) -> Result<NetlifyConfig> {
973 config
974 .distribution
975 .as_ref()
976 .and_then(|distribution| distribution.netlify.get(site))
977 .cloned()
978 .with_context(|| format!("missing [distribution.netlify.{site}] in fission.toml"))
979}
980
981fn github_workflow_path(project_dir: &Path, _cfg: &GithubPagesConfig, workflow: &str) -> PathBuf {
982 git_repo_root(project_dir)
983 .unwrap_or_else(|| project_dir.to_path_buf())
984 .join(".github/workflows")
985 .join(workflow)
986}
987
988fn project_dir_argument_for_workflow(project_dir: &Path) -> String {
989 let Some(repo_root) = git_repo_root(project_dir) else {
990 return ".".to_string();
991 };
992 let Ok(project_dir) = fs::canonicalize(project_dir) else {
993 return ".".to_string();
994 };
995 let Ok(repo_root) = fs::canonicalize(repo_root) else {
996 return ".".to_string();
997 };
998 if project_dir == repo_root {
999 ".".to_string()
1000 } else {
1001 project_dir
1002 .strip_prefix(&repo_root)
1003 .map(|path| path.to_string_lossy().replace('\\', "/"))
1004 .unwrap_or_else(|_| ".".to_string())
1005 }
1006}
1007
1008fn render_github_pages_workflow(project_dir: &Path, cfg: &GithubPagesConfig) -> String {
1009 let branch = cfg.production_branch.as_deref().unwrap_or("main");
1010 let package_project_dir = project_dir_argument_for_workflow(project_dir);
1011 let artifact_path = if package_project_dir == "." {
1012 "target/fission/release/static-site/static".to_string()
1013 } else {
1014 format!("{package_project_dir}/target/fission/release/static-site/static")
1015 };
1016 format!(
1017 r#"name: Publish Fission site
1018
1019on:
1020 push:
1021 branches:
1022 - {branch}
1023 workflow_dispatch:
1024
1025permissions:
1026 contents: read
1027 pages: write
1028 id-token: write
1029
1030concurrency:
1031 group: github-pages
1032 cancel-in-progress: true
1033
1034jobs:
1035 build:
1036 runs-on: ubuntu-latest
1037 environment:
1038 name: github-pages
1039 steps:
1040 - name: Check out repository
1041 uses: actions/checkout@v4
1042 with:
1043 submodules: recursive
1044
1045 - name: Set up Rust
1046 uses: dtolnay/rust-toolchain@stable
1047
1048 - name: Build Fission static package
1049 run: fission package --project-dir {package_project_dir} --target static-site --format static --release
1050
1051 - name: Upload GitHub Pages artifact
1052 uses: actions/upload-pages-artifact@v3
1053 with:
1054 path: {artifact_path}
1055
1056 deploy:
1057 needs: build
1058 runs-on: ubuntu-latest
1059 environment:
1060 name: github-pages
1061 url: ${{{{ steps.deployment.outputs.page_url }}}}
1062 steps:
1063 - name: Deploy to GitHub Pages
1064 id: deployment
1065 uses: actions/deploy-pages@v4
1066"#,
1067 branch = branch,
1068 package_project_dir = package_project_dir,
1069 artifact_path = artifact_path,
1070 )
1071}
1072
1073fn distribution_receipt_value(
1074 options: &DistributeOptions,
1075 receipt: &DistributionReceipt,
1076 artifact_path: Option<&Path>,
1077 manifest: Option<&ArtifactManifest>,
1078) -> Result<Value> {
1079 let artifact_manifest_sha256 = artifact_path
1080 .filter(|path| path.exists())
1081 .map(|path| hash_file(path).map(|(sha256, _)| sha256))
1082 .transpose()?;
1083 let release_content_manifest =
1084 release_content_manifest_path(&options.project_dir, options.provider);
1085 let release_content_manifest_sha256 = release_content_manifest
1086 .as_deref()
1087 .map(|path| hash_file(path).map(|(sha256, _)| sha256))
1088 .transpose()?;
1089 let provider_uploaded = provider_uploaded_assets_by_relative(receipt);
1090 let uploaded_assets = manifest
1091 .map(|manifest| {
1092 manifest
1093 .artifacts
1094 .iter()
1095 .map(|artifact| {
1096 let mut value = json!({
1097 "kind": artifact.kind,
1098 "purpose": artifact.purpose,
1099 "platform": artifact.platform,
1100 "upload_provider": artifact.upload_provider,
1101 "path": artifact.path,
1102 "relative_path": artifact.relative_path,
1103 "sha256": artifact.sha256,
1104 "size_bytes": artifact.size_bytes,
1105 "mime_type": artifact.mime_type,
1106 });
1107 if let Some(provider) = provider_uploaded.get(&artifact.relative_path) {
1108 if let Some(provider_id) = provider.get("provider_id").cloned() {
1109 value["provider_id"] = provider_id;
1110 }
1111 if let Some(url) = provider.get("url").cloned() {
1112 value["url"] = url;
1113 }
1114 }
1115 value
1116 })
1117 .collect::<Vec<_>>()
1118 })
1119 .unwrap_or_default();
1120 let uploaded_bytes = uploaded_assets
1121 .iter()
1122 .filter_map(|asset| asset.get("size_bytes").and_then(Value::as_u64))
1123 .sum();
1124 let view = DistributionReceiptView {
1125 receipt,
1126 release_id: active_release_id(&options.project_dir)
1127 .or_else(|| release_id_from_manifest(manifest))
1128 .or_else(|| release_id_from_version_build(manifest, &options.project_dir)),
1129 target: options
1130 .target
1131 .map(|target| target.as_str().to_string())
1132 .or_else(|| manifest.map(|manifest| manifest.target.clone())),
1133 format: options
1134 .format
1135 .map(|format| format.as_str().to_string())
1136 .or_else(|| manifest.map(|manifest| manifest.format.clone())),
1137 track: options.track.clone(),
1138 locales: release_locales(options),
1139 version: manifest
1140 .and_then(|manifest| manifest.project.version.clone())
1141 .or_else(|| release_version(&options.project_dir)),
1142 build: manifest
1143 .and_then(|manifest| manifest.project.build)
1144 .or_else(|| release_build_number(&options.project_dir)),
1145 artifact_hash: manifest
1146 .and_then(|manifest| manifest.artifacts.first())
1147 .map(|artifact| artifact.sha256.clone()),
1148 artifact_manifest_sha256,
1149 release_content_manifest: release_content_manifest
1150 .as_ref()
1151 .map(|path| path.display().to_string()),
1152 release_content_manifest_sha256,
1153 release_content_assets: release_content_manifest
1154 .as_deref()
1155 .map(release_content_assets_from_manifest)
1156 .transpose()?
1157 .unwrap_or_default(),
1158 uploaded_bytes,
1159 uploaded_assets,
1160 };
1161 let mut value =
1162 serde_json::to_value(view).context("failed to serialize distribution receipt")?;
1163 redact_json_value(&mut value);
1164 Ok(value)
1165}
1166
1167fn distribution_receipt_value_with_events(
1168 options: &DistributeOptions,
1169 receipt: &DistributionReceipt,
1170 artifact_path: Option<&Path>,
1171 manifest: Option<&ArtifactManifest>,
1172 events: &[DistributionEvent],
1173) -> Result<Value> {
1174 let mut value = distribution_receipt_value(options, receipt, artifact_path, manifest)?;
1175 if !events.is_empty() {
1176 let events_value =
1177 serde_json::to_value(events).context("failed to serialize distribution events")?;
1178 if let Value::Object(object) = &mut value {
1179 object.insert("events".to_string(), events_value);
1180 }
1181 }
1182 Ok(value)
1183}
1184
1185fn receipt_artifact_context(
1186 receipt: &DistributionReceipt,
1187) -> Result<(Option<PathBuf>, Option<ArtifactManifest>)> {
1188 let Some(path) = receipt
1189 .artifact_manifest
1190 .as_deref()
1191 .filter(|value| !value.trim().is_empty())
1192 .map(PathBuf::from)
1193 else {
1194 return Ok((None, None));
1195 };
1196 let manifest = path
1197 .exists()
1198 .then(|| read_artifact_manifest(&path))
1199 .transpose()?;
1200 Ok((Some(path), manifest))
1201}
1202
1203fn provider_uploaded_assets_by_relative(receipt: &DistributionReceipt) -> BTreeMap<String, Value> {
1204 receipt
1205 .stdout
1206 .as_deref()
1207 .and_then(|stdout| serde_json::from_str::<Value>(stdout).ok())
1208 .and_then(|value| value.get("uploaded").and_then(Value::as_array).cloned())
1209 .unwrap_or_default()
1210 .into_iter()
1211 .filter_map(|asset| {
1212 let relative = asset
1213 .get("relative_path")
1214 .and_then(Value::as_str)
1215 .map(str::to_string)?;
1216 Some((relative, asset))
1217 })
1218 .collect()
1219}
1220
1221fn release_content_assets_from_manifest(path: &Path) -> Result<Vec<Value>> {
1222 let value: Value = serde_json::from_slice(
1223 &fs::read(path).with_context(|| format!("failed to read {}", path.display()))?,
1224 )
1225 .with_context(|| format!("failed to parse {}", path.display()))?;
1226 if let Some(assets) = value
1227 .pointer("/rendered_screenshots/manifest/assets")
1228 .and_then(Value::as_array)
1229 {
1230 return Ok(assets.clone());
1231 }
1232 if let Some(assets) = value.get("assets").and_then(Value::as_array) {
1233 return Ok(assets.clone());
1234 }
1235 Ok(Vec::new())
1236}
1237
1238fn release_content_manifest_path(
1239 project_dir: &Path,
1240 provider: DistributionProvider,
1241) -> Option<PathBuf> {
1242 let data = fs::read_to_string(project_dir.join("fission.toml")).ok();
1243 let rendered_dir = data
1244 .as_deref()
1245 .and_then(|data| toml::from_str::<toml::Value>(data).ok())
1246 .and_then(|value| {
1247 value
1248 .get("release")
1249 .and_then(|release| release.get("screenshots"))
1250 .and_then(|screenshots| screenshots.get("rendered_dir"))
1251 .and_then(toml::Value::as_str)
1252 .map(str::to_string)
1253 })
1254 .unwrap_or_else(|| "release-content/screenshots/rendered".to_string());
1255 [
1256 project_dir.join("release-content/content-manifest.json"),
1257 project_dir
1258 .join(&rendered_dir)
1259 .join(provider.as_str())
1260 .join("release-content-manifest.json"),
1261 ]
1262 .into_iter()
1263 .find(|path| path.exists())
1264}
1265
1266fn active_release_id(project_dir: &Path) -> Option<String> {
1267 let data = fs::read_to_string(project_dir.join("fission.toml")).ok()?;
1268 let value: toml::Value = toml::from_str(&data).ok()?;
1269 value
1270 .get("release")
1271 .and_then(|release| release.get("active_release"))
1272 .and_then(toml::Value::as_str)
1273 .filter(|id| !id.trim().is_empty())
1274 .map(str::to_string)
1275}
1276
1277fn release_id_from_manifest(manifest: Option<&ArtifactManifest>) -> Option<String> {
1278 let manifest = manifest?;
1279 let version = manifest.project.version.as_deref()?;
1280 let build = manifest.project.build?;
1281 Some(format!("{version}+{build}"))
1282}
1283
1284fn release_id_from_version_build(
1285 manifest: Option<&ArtifactManifest>,
1286 project_dir: &Path,
1287) -> Option<String> {
1288 if manifest.is_some() {
1289 return None;
1290 }
1291 Some(format!(
1292 "{}+{}",
1293 release_version(project_dir)?,
1294 release_build_number(project_dir)?
1295 ))
1296}
1297
1298fn release_locales(options: &DistributeOptions) -> Vec<String> {
1299 if !options.locales.is_empty() {
1300 return options.locales.clone();
1301 }
1302 let data = match fs::read_to_string(options.project_dir.join("fission.toml")) {
1303 Ok(data) => data,
1304 Err(_) => return Vec::new(),
1305 };
1306 let Ok(value) = toml::from_str::<toml::Value>(&data) else {
1307 return Vec::new();
1308 };
1309 value
1310 .get("release")
1311 .and_then(|release| release.get("default_locales"))
1312 .and_then(toml::Value::as_array)
1313 .map(|locales| {
1314 locales
1315 .iter()
1316 .filter_map(toml::Value::as_str)
1317 .filter(|locale| !locale.trim().is_empty())
1318 .map(str::to_string)
1319 .collect()
1320 })
1321 .unwrap_or_default()
1322}
1323
1324fn write_receipt(
1325 project_dir: &Path,
1326 receipt: &DistributionReceipt,
1327 value: &Value,
1328) -> Result<PathBuf> {
1329 let path = receipt_output_path(project_dir, receipt);
1330 let dir = path
1331 .parent()
1332 .context("distribution receipt path has no parent directory")?;
1333 fs::create_dir_all(&dir)?;
1334 fs::write(&path, serde_json::to_vec_pretty(value)?)
1335 .with_context(|| format!("failed to write {}", path.display()))?;
1336 Ok(path)
1337}
1338
1339fn receipt_output_path(project_dir: &Path, receipt: &DistributionReceipt) -> PathBuf {
1340 let base = project_dir
1341 .join("target/fission/distribution")
1342 .join(&receipt.provider)
1343 .join(&receipt.site)
1344 .join(format!(
1345 "{}-{}.json",
1346 receipt.action, receipt.created_at_unix_seconds
1347 ));
1348 unique_receipt_path(base)
1349}
1350
1351fn unique_receipt_path(path: PathBuf) -> PathBuf {
1352 if !path.exists() {
1353 return path;
1354 }
1355 let parent = path.parent().map(Path::to_path_buf).unwrap_or_default();
1356 let stem = path
1357 .file_stem()
1358 .and_then(|value| value.to_str())
1359 .unwrap_or("receipt");
1360 let extension = path.extension().and_then(|value| value.to_str());
1361 for index in 2.. {
1362 let file_name = match extension {
1363 Some(extension) => format!("{stem}-{index}.{extension}"),
1364 None => format!("{stem}-{index}"),
1365 };
1366 let candidate = parent.join(file_name);
1367 if !candidate.exists() {
1368 return candidate;
1369 }
1370 }
1371 unreachable!("unbounded receipt path search should always return")
1372}
1373
1374fn write_failed_distribution_receipt(
1375 options: &DistributeOptions,
1376 events: &mut Vec<DistributionEvent>,
1377 stage_id: &str,
1378 message: &str,
1379) -> Result<PathBuf> {
1380 push_distribution_event(events, stage_id, "failed", Some(message.to_string()));
1381 push_distribution_event(
1382 events,
1383 "distribution.failed",
1384 "failed",
1385 Some(message.to_string()),
1386 );
1387 let artifact_path = options.artifact.as_deref().filter(|path| path.exists());
1388 let manifest = artifact_path
1389 .map(read_artifact_manifest)
1390 .transpose()
1391 .unwrap_or(None);
1392 let receipt = DistributionReceipt {
1393 schema_version: 1,
1394 created_at_unix_seconds: now_unix_seconds(),
1395 provider: options.provider.as_str().to_string(),
1396 site: options.site.clone(),
1397 action: options.action.as_str().to_string(),
1398 artifact_manifest: options
1399 .artifact
1400 .as_ref()
1401 .map(|path| path.display().to_string()),
1402 deployment_id: None,
1403 canonical_url: None,
1404 preview_url: None,
1405 custom_domain: None,
1406 status: "failed".to_string(),
1407 stdout: None,
1408 stderr: Some(redact_sensitive_text(message)),
1409 manual_follow_up: vec![
1410 "Fix the failed distribution stage, then rerun readiness or publish.".to_string(),
1411 ],
1412 };
1413 let receipt_path = receipt_output_path(&options.project_dir, &receipt);
1414 push_distribution_event(
1415 events,
1416 "distribution.receipt",
1417 "written",
1418 Some(receipt_path.display().to_string()),
1419 );
1420 let value = distribution_receipt_value_with_events(
1421 options,
1422 &receipt,
1423 artifact_path,
1424 manifest.as_ref(),
1425 events,
1426 )?;
1427 write_receipt(&options.project_dir, &receipt, &value)
1428}
1429
1430fn write_setup_distribution_receipt(
1431 options: &DistributeOptions,
1432 events: &mut Vec<DistributionEvent>,
1433) -> Result<PathBuf> {
1434 push_distribution_event(
1435 events,
1436 "distribution.setup",
1437 if options.dry_run {
1438 "dry-run"
1439 } else {
1440 "completed"
1441 },
1442 Some(options.provider.as_str().to_string()),
1443 );
1444 let receipt = DistributionReceipt {
1445 schema_version: 1,
1446 created_at_unix_seconds: now_unix_seconds(),
1447 provider: options.provider.as_str().to_string(),
1448 site: options.site.clone(),
1449 action: options.action.as_str().to_string(),
1450 artifact_manifest: options
1451 .artifact
1452 .as_ref()
1453 .map(|path| path.display().to_string()),
1454 deployment_id: options.deploy.clone(),
1455 canonical_url: None,
1456 preview_url: None,
1457 custom_domain: None,
1458 status: if options.dry_run {
1459 "dry-run".to_string()
1460 } else {
1461 "completed".to_string()
1462 },
1463 stdout: None,
1464 stderr: None,
1465 manual_follow_up: vec![
1466 "Run readiness before publishing, then use fission publish or fission distribute publish.".to_string(),
1467 ],
1468 };
1469 let receipt_path = receipt_output_path(&options.project_dir, &receipt);
1470 push_distribution_event(
1471 events,
1472 "distribution.receipt",
1473 "written",
1474 Some(receipt_path.display().to_string()),
1475 );
1476 let value = distribution_receipt_value_with_events(options, &receipt, None, None, events)?;
1477 write_receipt(&options.project_dir, &receipt, &value)
1478}
1479
1480fn distribute_action_stage(action: DistributeAction) -> &'static str {
1481 match action {
1482 DistributeAction::Setup => "distribution.setup",
1483 DistributeAction::Publish => "distribution.publish",
1484 DistributeAction::Status => "distribution.status",
1485 DistributeAction::Promote | DistributeAction::Rollback => "provider.lifecycle",
1486 }
1487}
1488
1489fn push_distribution_event(
1490 events: &mut Vec<DistributionEvent>,
1491 id: &str,
1492 status: &str,
1493 details: Option<String>,
1494) {
1495 events.push(DistributionEvent {
1496 at_unix_seconds: now_unix_seconds(),
1497 id: id.to_string(),
1498 status: status.to_string(),
1499 details: details.map(|details| redact_sensitive_text(&details)),
1500 });
1501}
1502
1503fn push_provider_stdio_line_events(events: &mut Vec<DistributionEvent>, id: &str, text: &str) {
1504 const MAX_STDIO_LINE_EVENTS: usize = 200;
1505 let mut count = 0usize;
1506 for line in text.lines().map(str::trim).filter(|line| !line.is_empty()) {
1507 if count == MAX_STDIO_LINE_EVENTS {
1508 push_distribution_event(
1509 events,
1510 id,
1511 "truncated",
1512 Some(
1513 "additional provider output is retained in the distribution receipt"
1514 .to_string(),
1515 ),
1516 );
1517 break;
1518 }
1519 push_distribution_event(events, id, "captured", Some(truncate_event_detail(line)));
1520 count += 1;
1521 }
1522}
1523
1524fn push_provider_request_event(
1525 events: &mut Vec<DistributionEvent>,
1526 options: &DistributeOptions,
1527 operation: &str,
1528) {
1529 let details = json!({
1530 "provider": options.provider.as_str(),
1531 "operation": operation,
1532 "site": &options.site,
1533 "track": &options.track,
1534 "artifact": options.artifact.as_ref().map(|path| path.display().to_string()),
1535 "dry_run": options.dry_run,
1536 });
1537 push_distribution_event(
1538 events,
1539 "provider.request",
1540 "started",
1541 Some(details.to_string()),
1542 );
1543}
1544
1545fn push_provider_response_event(
1546 events: &mut Vec<DistributionEvent>,
1547 receipt: &DistributionReceipt,
1548) {
1549 let details = json!({
1550 "provider": &receipt.provider,
1551 "action": &receipt.action,
1552 "status": &receipt.status,
1553 "deployment_id": &receipt.deployment_id,
1554 "canonical_url": &receipt.canonical_url,
1555 "preview_url": &receipt.preview_url,
1556 });
1557 push_distribution_event(
1558 events,
1559 "provider.response",
1560 receipt.status.as_str(),
1561 Some(details.to_string()),
1562 );
1563}
1564
1565fn push_provider_uploaded_asset_events(
1566 events: &mut Vec<DistributionEvent>,
1567 options: &DistributeOptions,
1568 manifest: &ArtifactManifest,
1569 provider_uploaded: &BTreeMap<String, Value>,
1570) {
1571 const MAX_UPLOAD_ASSET_EVENTS: usize = 200;
1572 for (index, artifact) in manifest.artifacts.iter().enumerate() {
1573 if index == MAX_UPLOAD_ASSET_EVENTS {
1574 push_distribution_event(
1575 events,
1576 "provider.uploaded_asset",
1577 "truncated",
1578 Some(
1579 "additional uploaded/planned assets are retained in the distribution receipt"
1580 .to_string(),
1581 ),
1582 );
1583 break;
1584 }
1585 let uploaded = provider_uploaded.get(&artifact.relative_path);
1586 let detail = json!({
1587 "relative_path": artifact.relative_path,
1588 "path": artifact.path,
1589 "kind": artifact.kind,
1590 "purpose": artifact.purpose,
1591 "size_bytes": artifact.size_bytes,
1592 "sha256": artifact.sha256,
1593 "mime_type": artifact.mime_type,
1594 "provider_id": uploaded.and_then(|value| value.get("provider_id")).cloned(),
1595 "url": uploaded.and_then(|value| value.get("url")).cloned(),
1596 });
1597 push_distribution_event(
1598 events,
1599 "provider.uploaded_asset",
1600 if options.dry_run {
1601 "planned"
1602 } else {
1603 "uploaded"
1604 },
1605 Some(detail.to_string()),
1606 );
1607 }
1608}
1609
1610fn provider_event_detail(receipt: &DistributionReceipt) -> String {
1611 [
1612 receipt.deployment_id.as_deref(),
1613 receipt.canonical_url.as_deref(),
1614 receipt.preview_url.as_deref(),
1615 ]
1616 .into_iter()
1617 .flatten()
1618 .next()
1619 .unwrap_or(&receipt.provider)
1620 .to_string()
1621}
1622
1623pub(crate) fn redact_sensitive_text(text: &str) -> String {
1624 let mut redacted = text.to_string();
1625 for (key, value) in secret_env_values() {
1626 redacted = redacted.replace(&value, &format!("<redacted:{key}>"));
1627 }
1628 redacted
1629}
1630
1631fn redact_json_value(value: &mut Value) {
1632 match value {
1633 Value::String(text) => {
1634 *text = redact_sensitive_text(text);
1635 }
1636 Value::Array(items) => {
1637 for item in items {
1638 redact_json_value(item);
1639 }
1640 }
1641 Value::Object(object) => {
1642 for item in object.values_mut() {
1643 redact_json_value(item);
1644 }
1645 }
1646 _ => {}
1647 }
1648}
1649
1650fn secret_env_values() -> Vec<(String, String)> {
1651 let mut values = env::vars()
1652 .filter(|(key, value)| secretish_env_key(key) && value.len() >= 8)
1653 .map(|(key, value)| (key.to_ascii_uppercase(), value))
1654 .collect::<Vec<_>>();
1655 values.sort_by(|(_, left), (_, right)| right.len().cmp(&left.len()));
1656 values.dedup_by(|(_, left), (_, right)| left == right);
1657 values
1658}
1659
1660fn secretish_env_key(key: &str) -> bool {
1661 let key = key.to_ascii_uppercase();
1662 [
1663 "PASSWORD",
1664 "TOKEN",
1665 "SECRET",
1666 "PRIVATE",
1667 "CREDENTIAL",
1668 "KEYSTORE",
1669 "SERVICE_ACCOUNT",
1670 "API_KEY",
1671 "ACCESS_KEY",
1672 "CLIENT_SECRET",
1673 "CERTIFICATE",
1674 "P8",
1675 "P12",
1676 "PFX",
1677 "JKS",
1678 ]
1679 .iter()
1680 .any(|needle| key.contains(needle))
1681}
1682
1683fn truncate_event_detail(value: &str) -> String {
1684 const MAX_EVENT_DETAIL_CHARS: usize = 2_000;
1685 let mut detail = value
1686 .trim()
1687 .chars()
1688 .take(MAX_EVENT_DETAIL_CHARS)
1689 .collect::<String>();
1690 if value.trim().chars().count() > MAX_EVENT_DETAIL_CHARS {
1691 detail.push_str("...");
1692 }
1693 detail
1694}
1695
1696#[cfg(test)]
1697#[path = "lib_tests.rs"]
1698mod tests;