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