Skip to main content

fission_command_package/
lib.rs

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