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