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 platform: Option<String>,
415 issuer_id: Option<String>,
416 key_id: Option<String>,
417 default_track: Option<String>,
418 access_token_env: Option<String>,
419 issuer_id_env: Option<String>,
420 key_id_env: Option<String>,
421 api_key_env: Option<String>,
422 api_key_base64_env: Option<String>,
423 api_key_path_env: Option<String>,
424}
425
426#[derive(Clone, Debug, Deserialize, Default)]
427struct MicrosoftStoreConfig {
428 product_id: Option<String>,
429 package_identity_name: Option<String>,
430 tenant_id: Option<String>,
431 client_id: Option<String>,
432 seller_id: Option<String>,
433 token_env: Option<String>,
434 tenant_id_env: Option<String>,
435 client_id_env: Option<String>,
436 client_secret_env: Option<String>,
437 seller_id_env: Option<String>,
438 package_url: Option<String>,
439 package_type: Option<String>,
440 flight_id: Option<String>,
441 package_rollout_percentage: Option<u8>,
442 msstore_project: Option<String>,
443 languages: Option<Vec<String>>,
444 architectures: Option<Vec<String>>,
445 is_silent_install: Option<bool>,
446 installer_parameters: Option<String>,
447 generic_doc_url: Option<String>,
448 submit: Option<bool>,
449}
450
451#[derive(Clone, Debug, Deserialize, Default)]
452struct GithubPagesConfig {
453 owner: Option<String>,
454 repo: Option<String>,
455 mode: Option<String>,
456 source: Option<String>,
457 source_branch: Option<String>,
458 source_path: Option<String>,
459 site_kind: Option<String>,
460 base_path: Option<String>,
461 custom_domain: Option<String>,
462 enforce_https: Option<bool>,
463 remote: Option<String>,
464 production_branch: Option<String>,
465 workflow: Option<String>,
466}
467
468#[derive(Clone, Debug, Deserialize, Default)]
469struct GithubReleasesConfig {
470 owner: Option<String>,
471 repo: Option<String>,
472 tag: Option<String>,
473 name: Option<String>,
474 target_commitish: Option<String>,
475 notes: Option<String>,
476 notes_file: Option<String>,
477 draft: Option<bool>,
478 prerelease: Option<bool>,
479 make_latest: Option<String>,
480 replace_assets: Option<bool>,
481 upload_artifact_manifest: Option<bool>,
482}
483
484#[derive(Clone, Debug, Deserialize, Default)]
485struct CloudflarePagesConfig {
486 account_id: Option<String>,
487 project_name: Option<String>,
488 environment: Option<String>,
489 custom_domain: Option<String>,
490 base_path: Option<String>,
491}
492
493#[derive(Clone, Debug, Deserialize, Default)]
494struct NetlifyConfig {
495 site_id: Option<String>,
496 team_slug: Option<String>,
497 production: Option<bool>,
498 custom_domain: Option<String>,
499 base_path: Option<String>,
500}
501
502#[derive(Clone, Debug, Deserialize, Default)]
503struct DockerRegistryConfig {
504 tags: Option<Vec<String>>,
505}
506
507pub fn package(options: PackageOptions) -> Result<()> {
508 if options.release {
509 sync_resolved_release_platform_config(&options.project_dir, options.target)?;
510 }
511 let manifest = package::package_artifact(&options)?;
512 if options.json {
513 println!("{}", serde_json::to_string_pretty(&manifest)?);
514 } else {
515 println!(
516 "Packaged {} {} artifact into {}",
517 manifest.target, manifest.format, manifest.root_dir
518 );
519 println!("{} files", manifest.artifacts.len());
520 println!(
521 "{}",
522 Path::new(&manifest.root_dir)
523 .join(ARTIFACT_MANIFEST)
524 .display()
525 );
526 }
527 Ok(())
528}
529
530pub fn package_silent(options: PackageOptions) -> Result<PathBuf> {
531 if options.release {
532 sync_resolved_release_platform_config(&options.project_dir, options.target)?;
533 }
534 package::package_artifact(&options)?;
535 Ok(package_artifact_manifest_path(
536 &options.project_dir,
537 options.target,
538 options.format,
539 options.release,
540 ))
541}
542
543pub fn package_artifact_manifest_path(
544 project_dir: &Path,
545 target: Target,
546 format: PackageFormat,
547 release: bool,
548) -> PathBuf {
549 default_artifact_manifest_path_for_format(project_dir, target, format, release)
550}
551
552pub fn package_readiness_checks(
553 project_dir: &Path,
554 target: Option<Target>,
555 format: Option<PackageFormat>,
556) -> Result<Vec<ReadinessCheck>> {
557 readiness_package(project_dir, target, format, false)
558}
559
560pub fn package_readiness_checks_for_profile(
561 project_dir: &Path,
562 target: Option<Target>,
563 format: Option<PackageFormat>,
564 release: bool,
565) -> Result<Vec<ReadinessCheck>> {
566 readiness_package(project_dir, target, format, release)
567}
568
569pub fn distribution_readiness_checks(
570 project_dir: &Path,
571 provider: DistributionProvider,
572 site: &str,
573 track: Option<&str>,
574 format: Option<PackageFormat>,
575 artifact: Option<&Path>,
576) -> Result<Vec<ReadinessCheck>> {
577 let config = load_publish_manifest(project_dir)?;
578 readiness_distribute(
579 project_dir,
580 provider,
581 site,
582 track,
583 format,
584 artifact,
585 &config,
586 )
587}
588
589pub fn distribute(options: DistributeOptions) -> Result<()> {
590 let mut events = Vec::new();
591 push_distribution_event(
592 &mut events,
593 "distribution.config",
594 "started",
595 Some(options.provider.as_str().to_string()),
596 );
597 let config = match load_publish_manifest(&options.project_dir) {
598 Ok(config) => config,
599 Err(error) => {
600 let message = error.to_string();
601 let receipt_path = write_failed_distribution_receipt(
602 &options,
603 &mut events,
604 "distribution.config",
605 &message,
606 )?;
607 bail!(
608 "distribution config failed: {}; distribution receipt: {}",
609 redact_sensitive_text(&message),
610 receipt_path.display()
611 );
612 }
613 };
614 push_distribution_event(&mut events, "distribution.config", "completed", None);
615 let result = match options.action {
616 DistributeAction::Setup => setup_provider(&options, &config),
617 DistributeAction::Status => provider_status(&options, &config),
618 DistributeAction::Promote | DistributeAction::Rollback => {
619 provider_lifecycle(&options, &config)
620 }
621 DistributeAction::Publish => publish_artifact(&options, &config),
622 };
623 if let Err(error) = result {
624 let message = error.to_string();
625 if message.contains("distribution receipt:") {
626 bail!("{message}");
627 }
628 let receipt_path = write_failed_distribution_receipt(
629 &options,
630 &mut events,
631 distribute_action_stage(options.action),
632 &message,
633 )?;
634 bail!(
635 "{} failed: {}; distribution receipt: {}",
636 options.action.as_str(),
637 redact_sensitive_text(&message),
638 receipt_path.display()
639 );
640 }
641 if options.action == DistributeAction::Setup {
642 let receipt_path = write_setup_distribution_receipt(&options, &mut events)?;
643 if !options.json {
644 println!("Distribution receipt: {}", receipt_path.display());
645 }
646 }
647 Ok(())
648}
649
650pub fn distribute_publish_value(options: DistributeOptions) -> Result<Value> {
651 distribute_publish_outcome(options).map(|outcome| outcome.receipt)
652}
653
654pub fn distribute_status_value(options: DistributeOptions) -> Result<Value> {
655 distribute_status_outcome(options).map(|outcome| outcome.receipt)
656}
657
658pub fn distribute_publish_outcome(
659 options: DistributeOptions,
660) -> Result<DistributionPublishOutcome> {
661 if options.action != DistributeAction::Publish {
662 bail!("distribute_publish_value only supports publish actions");
663 }
664 let mut events = Vec::new();
665 push_distribution_event(
666 &mut events,
667 "distribution.config",
668 "started",
669 Some(options.provider.as_str().to_string()),
670 );
671 let config = match load_publish_manifest(&options.project_dir) {
672 Ok(config) => config,
673 Err(error) => {
674 let message = error.to_string();
675 let receipt_path = write_failed_distribution_receipt(
676 &options,
677 &mut events,
678 "distribution.config",
679 &message,
680 )?;
681 bail!(
682 "distribution config failed: {}; distribution receipt: {}",
683 redact_sensitive_text(&message),
684 receipt_path.display()
685 );
686 }
687 };
688 push_distribution_event(&mut events, "distribution.config", "completed", None);
689 let (_, value) = match publish_artifact_receipt_with_events(&options, &config, &mut events) {
690 Ok(value) => value,
691 Err(error) => {
692 let message = error.to_string();
693 let receipt_path = write_failed_distribution_receipt(
694 &options,
695 &mut events,
696 "distribution.publish",
697 &message,
698 )?;
699 bail!(
700 "distribution publish failed: {}; distribution receipt: {}",
701 redact_sensitive_text(&message),
702 receipt_path.display()
703 );
704 }
705 };
706 Ok(DistributionPublishOutcome {
707 receipt: value,
708 events,
709 })
710}
711
712pub fn distribute_status_outcome(options: DistributeOptions) -> Result<DistributionPublishOutcome> {
713 if options.action != DistributeAction::Status {
714 bail!("distribute_status_value only supports status actions");
715 }
716 let mut events = Vec::new();
717 push_distribution_event(
718 &mut events,
719 "distribution.config",
720 "started",
721 Some(options.provider.as_str().to_string()),
722 );
723 let config = match load_publish_manifest(&options.project_dir) {
724 Ok(config) => config,
725 Err(error) => {
726 let message = error.to_string();
727 let receipt_path = write_failed_distribution_receipt(
728 &options,
729 &mut events,
730 "distribution.config",
731 &message,
732 )?;
733 bail!(
734 "distribution config failed: {}; distribution receipt: {}",
735 redact_sensitive_text(&message),
736 receipt_path.display()
737 );
738 }
739 };
740 push_distribution_event(&mut events, "distribution.config", "completed", None);
741 push_distribution_event(
742 &mut events,
743 "provider.status",
744 "started",
745 Some(options.provider.as_str().to_string()),
746 );
747 push_provider_request_event(&mut events, &options, "status");
748 let receipt = match provider_status_receipt(&options, &config) {
749 Ok(receipt) => receipt,
750 Err(error) => {
751 let message = error.to_string();
752 let receipt_path = write_failed_distribution_receipt(
753 &options,
754 &mut events,
755 "provider.status",
756 &message,
757 )?;
758 bail!(
759 "distribution status failed: {}; distribution receipt: {}",
760 redact_sensitive_text(&message),
761 receipt_path.display()
762 );
763 }
764 };
765 push_provider_response_event(&mut events, &receipt);
766 push_distribution_event(
767 &mut events,
768 "provider.status",
769 receipt.status.as_str(),
770 Some(provider_event_detail(&receipt)),
771 );
772 let receipt_path = receipt_output_path(&options.project_dir, &receipt);
773 push_distribution_event(
774 &mut events,
775 "distribution.receipt",
776 "written",
777 Some(receipt_path.display().to_string()),
778 );
779 let value = distribution_receipt_value_with_events(&options, &receipt, None, None, &events)?;
780 write_receipt(&options.project_dir, &receipt, &value)?;
781 Ok(DistributionPublishOutcome {
782 receipt: value,
783 events,
784 })
785}
786
787pub fn readiness(options: ReadinessOptions) -> Result<()> {
788 let checks = match options.kind {
789 ReadinessKind::Package => readiness_package(
790 &options.project_dir,
791 options.target,
792 options.format,
793 options.release,
794 ),
795 ReadinessKind::Release => {
796 let config = load_publish_manifest(&options.project_dir)?;
797 let mut checks = readiness_package(
798 &options.project_dir,
799 options.target,
800 options.format,
801 options.release,
802 )?;
803 let provider = options
804 .provider
805 .context("readiness release requires --provider")?;
806 checks.extend(readiness_distribute(
807 &options.project_dir,
808 provider,
809 &options.site,
810 options.track.as_deref(),
811 options.format,
812 options.artifact.as_deref(),
813 &config,
814 )?);
815 Ok(checks)
816 }
817 ReadinessKind::Distribute => {
818 let config = load_publish_manifest(&options.project_dir)?;
819 let provider = options
820 .provider
821 .context("readiness distribute requires --provider")?;
822 let artifact = options.artifact.as_deref();
823 readiness_distribute(
824 &options.project_dir,
825 provider,
826 &options.site,
827 options.track.as_deref(),
828 options.format,
829 artifact,
830 &config,
831 )
832 }
833 }?;
834 let report = ReadinessReport {
835 project_dir: options.project_dir.display().to_string(),
836 target: options.target.map(|target| target.as_str().to_string()),
837 format: options.format.map(|format| format.as_str().to_string()),
838 provider: options
839 .provider
840 .map(|provider| provider.as_str().to_string()),
841 site: matches!(
842 options.kind,
843 ReadinessKind::Distribute | ReadinessKind::Release
844 )
845 .then(|| options.site.clone()),
846 status: report_status(&checks).to_string(),
847 checks,
848 };
849 if options.json {
850 println!("{}", serde_json::to_string_pretty(&report)?);
851 } else {
852 print_readiness_report(&report);
853 }
854 if report.status == "blocked" {
855 bail!("readiness checks failed");
856 }
857 Ok(())
858}
859
860fn load_publish_manifest(project_dir: &Path) -> Result<PublishManifest> {
861 let path = project_dir.join("fission.toml");
862 let data =
863 fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
864 toml::from_str(&data).with_context(|| format!("failed to parse {}", path.display()))
865}
866
867fn site_output_dir(project_dir: &Path) -> Result<PathBuf> {
868 let manifest = load_publish_manifest(project_dir)?;
869 Ok(manifest
870 .site
871 .and_then(|site| site.out_dir)
872 .map(|path| resolve_project_path(project_dir, path))
873 .unwrap_or_else(|| project_dir.join("target/fission/site")))
874}
875
876fn read_artifact_manifest(path: &Path) -> Result<ArtifactManifest> {
877 let data = fs::read_to_string(path)
878 .with_context(|| format!("failed to read artifact manifest {}", path.display()))?;
879 serde_json::from_str(&data)
880 .with_context(|| format!("failed to parse artifact manifest {}", path.display()))
881}
882
883fn default_artifact_manifest_path(project_dir: &Path, target: Target, release: bool) -> PathBuf {
884 default_artifact_manifest_path_for_format(project_dir, target, PackageFormat::Static, release)
885}
886
887fn default_artifact_manifest_path_for_format(
888 project_dir: &Path,
889 target: Target,
890 format: PackageFormat,
891 release: bool,
892) -> PathBuf {
893 project_dir
894 .join("target/fission")
895 .join(if release { "release" } else { "debug" })
896 .join(target.as_str())
897 .join(format.as_str())
898 .join(ARTIFACT_MANIFEST)
899}
900
901fn github_config(config: &PublishManifest, site: &str) -> Result<GithubPagesConfig> {
902 Ok(config
903 .distribution
904 .as_ref()
905 .and_then(|distribution| distribution.github_pages.get(site))
906 .cloned()
907 .unwrap_or_default())
908}
909
910fn github_releases_config(config: &PublishManifest, site: &str) -> Result<GithubReleasesConfig> {
911 config
912 .distribution
913 .as_ref()
914 .and_then(|distribution| distribution.github_releases.get(site))
915 .cloned()
916 .with_context(|| format!("missing [distribution.github_releases.{site}] in fission.toml"))
917}
918
919fn docker_registry_config(config: &PublishManifest, site: &str) -> Result<DockerRegistryConfig> {
920 Ok(config
921 .distribution
922 .as_ref()
923 .and_then(|distribution| distribution.docker_registry.get(site))
924 .cloned()
925 .unwrap_or_default())
926}
927
928fn s3_config(config: &PublishManifest, site: &str) -> Result<S3Config> {
929 config
930 .distribution
931 .as_ref()
932 .and_then(|distribution| distribution.s3.get(site))
933 .cloned()
934 .with_context(|| format!("missing [distribution.s3.{site}] in fission.toml"))
935}
936
937fn google_drive_config(config: &PublishManifest, site: &str) -> Result<GoogleDriveConfig> {
938 Ok(config
939 .distribution
940 .as_ref()
941 .and_then(|distribution| distribution.google_drive.get(site))
942 .cloned()
943 .unwrap_or_default())
944}
945
946fn onedrive_config(config: &PublishManifest, site: &str) -> Result<OneDriveConfig> {
947 Ok(config
948 .distribution
949 .as_ref()
950 .and_then(|distribution| distribution.onedrive.get(site))
951 .cloned()
952 .unwrap_or_default())
953}
954
955fn dropbox_config(config: &PublishManifest, site: &str) -> Result<DropboxConfig> {
956 Ok(config
957 .distribution
958 .as_ref()
959 .and_then(|distribution| distribution.dropbox.get(site))
960 .cloned()
961 .unwrap_or_default())
962}
963
964fn cloudflare_config(config: &PublishManifest, site: &str) -> Result<CloudflarePagesConfig> {
965 config
966 .distribution
967 .as_ref()
968 .and_then(|distribution| distribution.cloudflare_pages.get(site))
969 .cloned()
970 .with_context(|| format!("missing [distribution.cloudflare_pages.{site}] in fission.toml"))
971}
972
973fn netlify_config(config: &PublishManifest, site: &str) -> Result<NetlifyConfig> {
974 config
975 .distribution
976 .as_ref()
977 .and_then(|distribution| distribution.netlify.get(site))
978 .cloned()
979 .with_context(|| format!("missing [distribution.netlify.{site}] in fission.toml"))
980}
981
982fn github_workflow_path(project_dir: &Path, _cfg: &GithubPagesConfig, workflow: &str) -> PathBuf {
983 git_repo_root(project_dir)
984 .unwrap_or_else(|| project_dir.to_path_buf())
985 .join(".github/workflows")
986 .join(workflow)
987}
988
989fn project_dir_argument_for_workflow(project_dir: &Path) -> String {
990 let Some(repo_root) = git_repo_root(project_dir) else {
991 return ".".to_string();
992 };
993 let Ok(project_dir) = fs::canonicalize(project_dir) else {
994 return ".".to_string();
995 };
996 let Ok(repo_root) = fs::canonicalize(repo_root) else {
997 return ".".to_string();
998 };
999 if project_dir == repo_root {
1000 ".".to_string()
1001 } else {
1002 project_dir
1003 .strip_prefix(&repo_root)
1004 .map(|path| path.to_string_lossy().replace('\\', "/"))
1005 .unwrap_or_else(|_| ".".to_string())
1006 }
1007}
1008
1009fn render_github_pages_workflow(project_dir: &Path, cfg: &GithubPagesConfig) -> String {
1010 let branch = cfg.production_branch.as_deref().unwrap_or("main");
1011 let package_project_dir = project_dir_argument_for_workflow(project_dir);
1012 let artifact_path = if package_project_dir == "." {
1013 "target/fission/release/static-site/static".to_string()
1014 } else {
1015 format!("{package_project_dir}/target/fission/release/static-site/static")
1016 };
1017 format!(
1018 r#"name: Publish Fission site
1019
1020on:
1021 push:
1022 branches:
1023 - {branch}
1024 workflow_dispatch:
1025
1026permissions:
1027 contents: read
1028 pages: write
1029 id-token: write
1030
1031concurrency:
1032 group: github-pages
1033 cancel-in-progress: true
1034
1035jobs:
1036 build:
1037 runs-on: ubuntu-latest
1038 environment:
1039 name: github-pages
1040 steps:
1041 - name: Check out repository
1042 uses: actions/checkout@v4
1043 with:
1044 submodules: recursive
1045
1046 - name: Set up Rust
1047 uses: dtolnay/rust-toolchain@stable
1048
1049 - name: Build Fission static package
1050 run: fission package --project-dir {package_project_dir} --target static-site --format static --release
1051
1052 - name: Upload GitHub Pages artifact
1053 uses: actions/upload-pages-artifact@v3
1054 with:
1055 path: {artifact_path}
1056
1057 deploy:
1058 needs: build
1059 runs-on: ubuntu-latest
1060 environment:
1061 name: github-pages
1062 url: ${{{{ steps.deployment.outputs.page_url }}}}
1063 steps:
1064 - name: Deploy to GitHub Pages
1065 id: deployment
1066 uses: actions/deploy-pages@v4
1067"#,
1068 branch = branch,
1069 package_project_dir = package_project_dir,
1070 artifact_path = artifact_path,
1071 )
1072}
1073
1074fn distribution_receipt_value(
1075 options: &DistributeOptions,
1076 receipt: &DistributionReceipt,
1077 artifact_path: Option<&Path>,
1078 manifest: Option<&ArtifactManifest>,
1079) -> Result<Value> {
1080 let artifact_manifest_sha256 = artifact_path
1081 .filter(|path| path.exists())
1082 .map(|path| hash_file(path).map(|(sha256, _)| sha256))
1083 .transpose()?;
1084 let release_content_manifest =
1085 release_content_manifest_path(&options.project_dir, options.provider);
1086 let release_content_manifest_sha256 = release_content_manifest
1087 .as_deref()
1088 .map(|path| hash_file(path).map(|(sha256, _)| sha256))
1089 .transpose()?;
1090 let provider_uploaded = provider_uploaded_assets_by_relative(receipt);
1091 let uploaded_assets = manifest
1092 .map(|manifest| {
1093 manifest
1094 .artifacts
1095 .iter()
1096 .map(|artifact| {
1097 let mut value = json!({
1098 "kind": artifact.kind,
1099 "purpose": artifact.purpose,
1100 "platform": artifact.platform,
1101 "upload_provider": artifact.upload_provider,
1102 "path": artifact.path,
1103 "relative_path": artifact.relative_path,
1104 "sha256": artifact.sha256,
1105 "size_bytes": artifact.size_bytes,
1106 "mime_type": artifact.mime_type,
1107 });
1108 if let Some(provider) = provider_uploaded.get(&artifact.relative_path) {
1109 if let Some(provider_id) = provider.get("provider_id").cloned() {
1110 value["provider_id"] = provider_id;
1111 }
1112 if let Some(url) = provider.get("url").cloned() {
1113 value["url"] = url;
1114 }
1115 }
1116 value
1117 })
1118 .collect::<Vec<_>>()
1119 })
1120 .unwrap_or_default();
1121 let uploaded_bytes = uploaded_assets
1122 .iter()
1123 .filter_map(|asset| asset.get("size_bytes").and_then(Value::as_u64))
1124 .sum();
1125 let view = DistributionReceiptView {
1126 receipt,
1127 release_id: active_release_id(&options.project_dir)
1128 .or_else(|| release_id_from_manifest(manifest))
1129 .or_else(|| release_id_from_version_build(manifest, &options.project_dir)),
1130 target: options
1131 .target
1132 .map(|target| target.as_str().to_string())
1133 .or_else(|| manifest.map(|manifest| manifest.target.clone())),
1134 format: options
1135 .format
1136 .map(|format| format.as_str().to_string())
1137 .or_else(|| manifest.map(|manifest| manifest.format.clone())),
1138 track: options.track.clone(),
1139 locales: release_locales(options),
1140 version: manifest
1141 .and_then(|manifest| manifest.project.version.clone())
1142 .or_else(|| release_version(&options.project_dir)),
1143 build: manifest
1144 .and_then(|manifest| manifest.project.build)
1145 .or_else(|| release_build_number(&options.project_dir)),
1146 artifact_hash: manifest
1147 .and_then(|manifest| manifest.artifacts.first())
1148 .map(|artifact| artifact.sha256.clone()),
1149 artifact_manifest_sha256,
1150 release_content_manifest: release_content_manifest
1151 .as_ref()
1152 .map(|path| path.display().to_string()),
1153 release_content_manifest_sha256,
1154 release_content_assets: release_content_manifest
1155 .as_deref()
1156 .map(release_content_assets_from_manifest)
1157 .transpose()?
1158 .unwrap_or_default(),
1159 uploaded_bytes,
1160 uploaded_assets,
1161 };
1162 let mut value =
1163 serde_json::to_value(view).context("failed to serialize distribution receipt")?;
1164 redact_json_value(&mut value);
1165 Ok(value)
1166}
1167
1168fn distribution_receipt_value_with_events(
1169 options: &DistributeOptions,
1170 receipt: &DistributionReceipt,
1171 artifact_path: Option<&Path>,
1172 manifest: Option<&ArtifactManifest>,
1173 events: &[DistributionEvent],
1174) -> Result<Value> {
1175 let mut value = distribution_receipt_value(options, receipt, artifact_path, manifest)?;
1176 if !events.is_empty() {
1177 let events_value =
1178 serde_json::to_value(events).context("failed to serialize distribution events")?;
1179 if let Value::Object(object) = &mut value {
1180 object.insert("events".to_string(), events_value);
1181 }
1182 }
1183 Ok(value)
1184}
1185
1186fn receipt_artifact_context(
1187 receipt: &DistributionReceipt,
1188) -> Result<(Option<PathBuf>, Option<ArtifactManifest>)> {
1189 let Some(path) = receipt
1190 .artifact_manifest
1191 .as_deref()
1192 .filter(|value| !value.trim().is_empty())
1193 .map(PathBuf::from)
1194 else {
1195 return Ok((None, None));
1196 };
1197 let manifest = path
1198 .exists()
1199 .then(|| read_artifact_manifest(&path))
1200 .transpose()?;
1201 Ok((Some(path), manifest))
1202}
1203
1204fn provider_uploaded_assets_by_relative(receipt: &DistributionReceipt) -> BTreeMap<String, Value> {
1205 receipt
1206 .stdout
1207 .as_deref()
1208 .and_then(|stdout| serde_json::from_str::<Value>(stdout).ok())
1209 .and_then(|value| value.get("uploaded").and_then(Value::as_array).cloned())
1210 .unwrap_or_default()
1211 .into_iter()
1212 .filter_map(|asset| {
1213 let relative = asset
1214 .get("relative_path")
1215 .and_then(Value::as_str)
1216 .map(str::to_string)?;
1217 Some((relative, asset))
1218 })
1219 .collect()
1220}
1221
1222fn release_content_assets_from_manifest(path: &Path) -> Result<Vec<Value>> {
1223 let value: Value = serde_json::from_slice(
1224 &fs::read(path).with_context(|| format!("failed to read {}", path.display()))?,
1225 )
1226 .with_context(|| format!("failed to parse {}", path.display()))?;
1227 if let Some(assets) = value
1228 .pointer("/rendered_screenshots/manifest/assets")
1229 .and_then(Value::as_array)
1230 {
1231 return Ok(assets.clone());
1232 }
1233 if let Some(assets) = value.get("assets").and_then(Value::as_array) {
1234 return Ok(assets.clone());
1235 }
1236 Ok(Vec::new())
1237}
1238
1239fn release_content_manifest_path(
1240 project_dir: &Path,
1241 provider: DistributionProvider,
1242) -> Option<PathBuf> {
1243 let data = fs::read_to_string(project_dir.join("fission.toml")).ok();
1244 let rendered_dir = data
1245 .as_deref()
1246 .and_then(|data| toml::from_str::<toml::Value>(data).ok())
1247 .and_then(|value| {
1248 value
1249 .get("release")
1250 .and_then(|release| release.get("screenshots"))
1251 .and_then(|screenshots| screenshots.get("rendered_dir"))
1252 .and_then(toml::Value::as_str)
1253 .map(str::to_string)
1254 })
1255 .unwrap_or_else(|| "release-content/screenshots/rendered".to_string());
1256 [
1257 project_dir.join("release-content/content-manifest.json"),
1258 project_dir
1259 .join(&rendered_dir)
1260 .join(provider.as_str())
1261 .join("release-content-manifest.json"),
1262 ]
1263 .into_iter()
1264 .find(|path| path.exists())
1265}
1266
1267fn active_release_id(project_dir: &Path) -> Option<String> {
1268 let data = fs::read_to_string(project_dir.join("fission.toml")).ok()?;
1269 let value: toml::Value = toml::from_str(&data).ok()?;
1270 value
1271 .get("release")
1272 .and_then(|release| release.get("active_release"))
1273 .and_then(toml::Value::as_str)
1274 .filter(|id| !id.trim().is_empty())
1275 .map(str::to_string)
1276}
1277
1278fn release_id_from_manifest(manifest: Option<&ArtifactManifest>) -> Option<String> {
1279 let manifest = manifest?;
1280 let version = manifest.project.version.as_deref()?;
1281 let build = manifest.project.build?;
1282 Some(format!("{version}+{build}"))
1283}
1284
1285fn release_id_from_version_build(
1286 manifest: Option<&ArtifactManifest>,
1287 project_dir: &Path,
1288) -> Option<String> {
1289 if manifest.is_some() {
1290 return None;
1291 }
1292 Some(format!(
1293 "{}+{}",
1294 release_version(project_dir)?,
1295 release_build_number(project_dir)?
1296 ))
1297}
1298
1299fn release_locales(options: &DistributeOptions) -> Vec<String> {
1300 if !options.locales.is_empty() {
1301 return options.locales.clone();
1302 }
1303 let data = match fs::read_to_string(options.project_dir.join("fission.toml")) {
1304 Ok(data) => data,
1305 Err(_) => return Vec::new(),
1306 };
1307 let Ok(value) = toml::from_str::<toml::Value>(&data) else {
1308 return Vec::new();
1309 };
1310 value
1311 .get("release")
1312 .and_then(|release| release.get("default_locales"))
1313 .and_then(toml::Value::as_array)
1314 .map(|locales| {
1315 locales
1316 .iter()
1317 .filter_map(toml::Value::as_str)
1318 .filter(|locale| !locale.trim().is_empty())
1319 .map(str::to_string)
1320 .collect()
1321 })
1322 .unwrap_or_default()
1323}
1324
1325fn write_receipt(
1326 project_dir: &Path,
1327 receipt: &DistributionReceipt,
1328 value: &Value,
1329) -> Result<PathBuf> {
1330 let path = receipt_output_path(project_dir, receipt);
1331 let dir = path
1332 .parent()
1333 .context("distribution receipt path has no parent directory")?;
1334 fs::create_dir_all(&dir)?;
1335 fs::write(&path, serde_json::to_vec_pretty(value)?)
1336 .with_context(|| format!("failed to write {}", path.display()))?;
1337 Ok(path)
1338}
1339
1340fn receipt_output_path(project_dir: &Path, receipt: &DistributionReceipt) -> PathBuf {
1341 let base = project_dir
1342 .join("target/fission/distribution")
1343 .join(&receipt.provider)
1344 .join(&receipt.site)
1345 .join(format!(
1346 "{}-{}.json",
1347 receipt.action, receipt.created_at_unix_seconds
1348 ));
1349 unique_receipt_path(base)
1350}
1351
1352fn unique_receipt_path(path: PathBuf) -> PathBuf {
1353 if !path.exists() {
1354 return path;
1355 }
1356 let parent = path.parent().map(Path::to_path_buf).unwrap_or_default();
1357 let stem = path
1358 .file_stem()
1359 .and_then(|value| value.to_str())
1360 .unwrap_or("receipt");
1361 let extension = path.extension().and_then(|value| value.to_str());
1362 for index in 2.. {
1363 let file_name = match extension {
1364 Some(extension) => format!("{stem}-{index}.{extension}"),
1365 None => format!("{stem}-{index}"),
1366 };
1367 let candidate = parent.join(file_name);
1368 if !candidate.exists() {
1369 return candidate;
1370 }
1371 }
1372 unreachable!("unbounded receipt path search should always return")
1373}
1374
1375fn write_failed_distribution_receipt(
1376 options: &DistributeOptions,
1377 events: &mut Vec<DistributionEvent>,
1378 stage_id: &str,
1379 message: &str,
1380) -> Result<PathBuf> {
1381 push_distribution_event(events, stage_id, "failed", Some(message.to_string()));
1382 push_distribution_event(
1383 events,
1384 "distribution.failed",
1385 "failed",
1386 Some(message.to_string()),
1387 );
1388 let artifact_path = options.artifact.as_deref().filter(|path| path.exists());
1389 let manifest = artifact_path
1390 .map(read_artifact_manifest)
1391 .transpose()
1392 .unwrap_or(None);
1393 let receipt = DistributionReceipt {
1394 schema_version: 1,
1395 created_at_unix_seconds: now_unix_seconds(),
1396 provider: options.provider.as_str().to_string(),
1397 site: options.site.clone(),
1398 action: options.action.as_str().to_string(),
1399 artifact_manifest: options
1400 .artifact
1401 .as_ref()
1402 .map(|path| path.display().to_string()),
1403 deployment_id: None,
1404 canonical_url: None,
1405 preview_url: None,
1406 custom_domain: None,
1407 status: "failed".to_string(),
1408 stdout: None,
1409 stderr: Some(redact_sensitive_text(message)),
1410 manual_follow_up: vec![
1411 "Fix the failed distribution stage, then rerun readiness or publish.".to_string(),
1412 ],
1413 };
1414 let receipt_path = receipt_output_path(&options.project_dir, &receipt);
1415 push_distribution_event(
1416 events,
1417 "distribution.receipt",
1418 "written",
1419 Some(receipt_path.display().to_string()),
1420 );
1421 let value = distribution_receipt_value_with_events(
1422 options,
1423 &receipt,
1424 artifact_path,
1425 manifest.as_ref(),
1426 events,
1427 )?;
1428 write_receipt(&options.project_dir, &receipt, &value)
1429}
1430
1431fn write_setup_distribution_receipt(
1432 options: &DistributeOptions,
1433 events: &mut Vec<DistributionEvent>,
1434) -> Result<PathBuf> {
1435 push_distribution_event(
1436 events,
1437 "distribution.setup",
1438 if options.dry_run {
1439 "dry-run"
1440 } else {
1441 "completed"
1442 },
1443 Some(options.provider.as_str().to_string()),
1444 );
1445 let receipt = DistributionReceipt {
1446 schema_version: 1,
1447 created_at_unix_seconds: now_unix_seconds(),
1448 provider: options.provider.as_str().to_string(),
1449 site: options.site.clone(),
1450 action: options.action.as_str().to_string(),
1451 artifact_manifest: options
1452 .artifact
1453 .as_ref()
1454 .map(|path| path.display().to_string()),
1455 deployment_id: options.deploy.clone(),
1456 canonical_url: None,
1457 preview_url: None,
1458 custom_domain: None,
1459 status: if options.dry_run {
1460 "dry-run".to_string()
1461 } else {
1462 "completed".to_string()
1463 },
1464 stdout: None,
1465 stderr: None,
1466 manual_follow_up: vec![
1467 "Run readiness before publishing, then use fission publish or fission distribute publish.".to_string(),
1468 ],
1469 };
1470 let receipt_path = receipt_output_path(&options.project_dir, &receipt);
1471 push_distribution_event(
1472 events,
1473 "distribution.receipt",
1474 "written",
1475 Some(receipt_path.display().to_string()),
1476 );
1477 let value = distribution_receipt_value_with_events(options, &receipt, None, None, events)?;
1478 write_receipt(&options.project_dir, &receipt, &value)
1479}
1480
1481fn distribute_action_stage(action: DistributeAction) -> &'static str {
1482 match action {
1483 DistributeAction::Setup => "distribution.setup",
1484 DistributeAction::Publish => "distribution.publish",
1485 DistributeAction::Status => "distribution.status",
1486 DistributeAction::Promote | DistributeAction::Rollback => "provider.lifecycle",
1487 }
1488}
1489
1490fn push_distribution_event(
1491 events: &mut Vec<DistributionEvent>,
1492 id: &str,
1493 status: &str,
1494 details: Option<String>,
1495) {
1496 events.push(DistributionEvent {
1497 at_unix_seconds: now_unix_seconds(),
1498 id: id.to_string(),
1499 status: status.to_string(),
1500 details: details.map(|details| redact_sensitive_text(&details)),
1501 });
1502}
1503
1504fn push_provider_stdio_line_events(events: &mut Vec<DistributionEvent>, id: &str, text: &str) {
1505 const MAX_STDIO_LINE_EVENTS: usize = 200;
1506 let mut count = 0usize;
1507 for line in text.lines().map(str::trim).filter(|line| !line.is_empty()) {
1508 if count == MAX_STDIO_LINE_EVENTS {
1509 push_distribution_event(
1510 events,
1511 id,
1512 "truncated",
1513 Some(
1514 "additional provider output is retained in the distribution receipt"
1515 .to_string(),
1516 ),
1517 );
1518 break;
1519 }
1520 push_distribution_event(events, id, "captured", Some(truncate_event_detail(line)));
1521 count += 1;
1522 }
1523}
1524
1525fn push_provider_request_event(
1526 events: &mut Vec<DistributionEvent>,
1527 options: &DistributeOptions,
1528 operation: &str,
1529) {
1530 let details = json!({
1531 "provider": options.provider.as_str(),
1532 "operation": operation,
1533 "site": &options.site,
1534 "track": &options.track,
1535 "artifact": options.artifact.as_ref().map(|path| path.display().to_string()),
1536 "dry_run": options.dry_run,
1537 });
1538 push_distribution_event(
1539 events,
1540 "provider.request",
1541 "started",
1542 Some(details.to_string()),
1543 );
1544}
1545
1546fn push_provider_response_event(
1547 events: &mut Vec<DistributionEvent>,
1548 receipt: &DistributionReceipt,
1549) {
1550 let details = json!({
1551 "provider": &receipt.provider,
1552 "action": &receipt.action,
1553 "status": &receipt.status,
1554 "deployment_id": &receipt.deployment_id,
1555 "canonical_url": &receipt.canonical_url,
1556 "preview_url": &receipt.preview_url,
1557 });
1558 push_distribution_event(
1559 events,
1560 "provider.response",
1561 receipt.status.as_str(),
1562 Some(details.to_string()),
1563 );
1564}
1565
1566fn push_provider_uploaded_asset_events(
1567 events: &mut Vec<DistributionEvent>,
1568 options: &DistributeOptions,
1569 manifest: &ArtifactManifest,
1570 provider_uploaded: &BTreeMap<String, Value>,
1571) {
1572 const MAX_UPLOAD_ASSET_EVENTS: usize = 200;
1573 for (index, artifact) in manifest.artifacts.iter().enumerate() {
1574 if index == MAX_UPLOAD_ASSET_EVENTS {
1575 push_distribution_event(
1576 events,
1577 "provider.uploaded_asset",
1578 "truncated",
1579 Some(
1580 "additional uploaded/planned assets are retained in the distribution receipt"
1581 .to_string(),
1582 ),
1583 );
1584 break;
1585 }
1586 let uploaded = provider_uploaded.get(&artifact.relative_path);
1587 let detail = json!({
1588 "relative_path": artifact.relative_path,
1589 "path": artifact.path,
1590 "kind": artifact.kind,
1591 "purpose": artifact.purpose,
1592 "size_bytes": artifact.size_bytes,
1593 "sha256": artifact.sha256,
1594 "mime_type": artifact.mime_type,
1595 "provider_id": uploaded.and_then(|value| value.get("provider_id")).cloned(),
1596 "url": uploaded.and_then(|value| value.get("url")).cloned(),
1597 });
1598 push_distribution_event(
1599 events,
1600 "provider.uploaded_asset",
1601 if options.dry_run {
1602 "planned"
1603 } else {
1604 "uploaded"
1605 },
1606 Some(detail.to_string()),
1607 );
1608 }
1609}
1610
1611fn provider_event_detail(receipt: &DistributionReceipt) -> String {
1612 [
1613 receipt.deployment_id.as_deref(),
1614 receipt.canonical_url.as_deref(),
1615 receipt.preview_url.as_deref(),
1616 ]
1617 .into_iter()
1618 .flatten()
1619 .next()
1620 .unwrap_or(&receipt.provider)
1621 .to_string()
1622}
1623
1624pub(crate) fn redact_sensitive_text(text: &str) -> String {
1625 let mut redacted = text.to_string();
1626 for (key, value) in secret_env_values() {
1627 redacted = redacted.replace(&value, &format!("<redacted:{key}>"));
1628 }
1629 redacted
1630}
1631
1632fn redact_json_value(value: &mut Value) {
1633 match value {
1634 Value::String(text) => {
1635 *text = redact_sensitive_text(text);
1636 }
1637 Value::Array(items) => {
1638 for item in items {
1639 redact_json_value(item);
1640 }
1641 }
1642 Value::Object(object) => {
1643 for item in object.values_mut() {
1644 redact_json_value(item);
1645 }
1646 }
1647 _ => {}
1648 }
1649}
1650
1651fn secret_env_values() -> Vec<(String, String)> {
1652 let mut values = env::vars()
1653 .filter(|(key, value)| secretish_env_key(key) && value.len() >= 8)
1654 .map(|(key, value)| (key.to_ascii_uppercase(), value))
1655 .collect::<Vec<_>>();
1656 values.sort_by(|(_, left), (_, right)| right.len().cmp(&left.len()));
1657 values.dedup_by(|(_, left), (_, right)| left == right);
1658 values
1659}
1660
1661fn secretish_env_key(key: &str) -> bool {
1662 let key = key.to_ascii_uppercase();
1663 [
1664 "PASSWORD",
1665 "TOKEN",
1666 "SECRET",
1667 "PRIVATE",
1668 "CREDENTIAL",
1669 "KEYSTORE",
1670 "SERVICE_ACCOUNT",
1671 "API_KEY",
1672 "ACCESS_KEY",
1673 "CLIENT_SECRET",
1674 "CERTIFICATE",
1675 "P8",
1676 "P12",
1677 "PFX",
1678 "JKS",
1679 ]
1680 .iter()
1681 .any(|needle| key.contains(needle))
1682}
1683
1684fn truncate_event_detail(value: &str) -> String {
1685 const MAX_EVENT_DETAIL_CHARS: usize = 2_000;
1686 let mut detail = value
1687 .trim()
1688 .chars()
1689 .take(MAX_EVENT_DETAIL_CHARS)
1690 .collect::<String>();
1691 if value.trim().chars().count() > MAX_EVENT_DETAIL_CHARS {
1692 detail.push_str("...");
1693 }
1694 detail
1695}
1696
1697#[cfg(test)]
1698#[path = "lib_tests.rs"]
1699mod tests;