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