Skip to main content

fission_command_release/
publish_workflow.rs

1use super::*;
2use fission_command_core::{resolve_release_version_config, sync_release_platform_config};
3use fission_command_package::{
4    self as package_cmd, CheckSeverity, CheckStatus, DistributeAction, DistributeOptions,
5    PackageFormat, PackageOptions, ReadinessCheck,
6};
7use serde::{Deserialize, Serialize};
8use serde_json::{json, Value};
9use sha2::{Digest, Sha256};
10use std::collections::BTreeSet;
11use std::env;
12use std::ffi::OsStr;
13
14#[path = "publish_workflow_capabilities.rs"]
15mod publish_workflow_capabilities;
16use publish_workflow_capabilities::*;
17#[path = "publish_workflow_steps.rs"]
18mod publish_workflow_steps;
19use publish_workflow_steps::*;
20
21#[derive(Clone, Debug)]
22pub struct PublishWorkflowOptions {
23    pub project_dir: PathBuf,
24    pub provider: DistributionProvider,
25    pub target: Option<Target>,
26    pub format: Option<PackageFormat>,
27    pub artifact: Option<PathBuf>,
28    pub site: String,
29    pub deploy: Option<String>,
30    pub track: Option<String>,
31    pub locales: Vec<String>,
32    pub overwrite_remote: bool,
33    pub dry_run: bool,
34    pub yes: bool,
35    pub json: bool,
36}
37
38#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
39pub struct ReleasePlanSnapshot {
40    pub context: ReleaseContextSnapshot,
41    pub project_dir: String,
42    pub provider: String,
43    pub target: Option<String>,
44    pub format: Option<String>,
45    pub artifact: Option<String>,
46    pub track: Option<String>,
47    pub locales: Vec<String>,
48    pub status: String,
49    pub steps: Vec<ReleaseStepSnapshot>,
50    pub capabilities: Vec<ProviderCapabilitySnapshot>,
51    pub requirements: Vec<ReleaseRequirementSnapshot>,
52}
53
54#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
55pub struct ReleaseContextSnapshot {
56    pub project_dir: String,
57    pub app_name: Option<String>,
58    pub target: Option<String>,
59    pub format: Option<String>,
60    pub provider: String,
61    pub release_id: Option<String>,
62    pub track: Option<String>,
63    pub locales: Vec<String>,
64    pub interactive: bool,
65    pub ci: bool,
66}
67
68#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
69pub struct ProviderCapabilitySnapshot {
70    pub id: String,
71    pub status: String,
72    pub summary: String,
73    pub details: Option<String>,
74}
75
76#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
77pub struct ReleaseRequirementSnapshot {
78    pub id: String,
79    pub level: String,
80    pub status: String,
81    pub summary: String,
82    pub details: Option<String>,
83    pub remediation: Vec<String>,
84    pub can_fix_interactively: bool,
85}
86
87#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
88pub struct ReleaseStepSnapshot {
89    pub id: String,
90    pub title: String,
91    pub status: String,
92    pub jobs: Vec<ReleaseJobSnapshot>,
93}
94
95#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
96pub struct ReleaseJobSnapshot {
97    pub id: String,
98    pub title: String,
99    pub status: String,
100}
101
102#[derive(Debug, Serialize)]
103struct ReleasePlanReport {
104    context: ReleaseContextReport,
105    project_dir: String,
106    provider: String,
107    target: Option<String>,
108    format: Option<String>,
109    artifact: Option<String>,
110    track: Option<String>,
111    locales: Vec<String>,
112    status: String,
113    steps: Vec<ReleaseStep>,
114    capabilities: Vec<ProviderCapability>,
115    requirements: Vec<ReleaseRequirement>,
116}
117
118#[derive(Debug, Serialize)]
119struct ReleaseContextReport {
120    project_dir: String,
121    app_name: Option<String>,
122    target: Option<String>,
123    format: Option<String>,
124    provider: String,
125    release_id: Option<String>,
126    track: Option<String>,
127    locales: Vec<String>,
128    interactive: bool,
129    ci: bool,
130}
131
132#[derive(Debug, Serialize)]
133struct ReleaseRequirement {
134    id: String,
135    level: RequirementLevel,
136    status: RequirementStatus,
137    summary: String,
138    details: Option<String>,
139    remediation: Vec<String>,
140    can_fix_interactively: bool,
141}
142
143#[derive(Debug, Deserialize, Default)]
144struct ReleaseSkipToml {
145    release: Option<ReleaseSkipRoot>,
146}
147
148#[derive(Debug, Deserialize, Default)]
149struct ReleaseSkipRoot {
150    #[serde(default)]
151    skip_requirements: BTreeSet<String>,
152}
153
154#[derive(Debug, Serialize)]
155struct ReleaseStep {
156    id: String,
157    title: String,
158    status: StepStatus,
159    jobs: Vec<ReleaseJob>,
160}
161
162#[derive(Debug, Serialize)]
163struct ReleaseJob {
164    id: String,
165    title: String,
166    status: StepStatus,
167}
168
169#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
170#[serde(rename_all = "kebab-case")]
171enum StepStatus {
172    Pending,
173    Blocked,
174    Warning,
175    Ready,
176    Completed,
177}
178
179#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
180#[serde(rename_all = "kebab-case")]
181enum RequirementLevel {
182    ProviderRequired,
183    FissionRecommended,
184    Optional,
185}
186
187#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
188#[serde(rename_all = "kebab-case")]
189enum RequirementStatus {
190    Passed,
191    Missing,
192    Failed,
193    Warning,
194    Skipped,
195}
196
197#[derive(Debug, Serialize)]
198struct PublishWorkflowEvent {
199    at_unix_seconds: u64,
200    id: String,
201    status: String,
202    details: Option<String>,
203}
204
205#[derive(Debug, Serialize)]
206struct PublishWorkflowReceipt<'a> {
207    schema_version: u32,
208    created_at_unix_seconds: u64,
209    status: String,
210    dry_run: bool,
211    provider: String,
212    target: Option<String>,
213    format: Option<String>,
214    artifact: Option<String>,
215    track: Option<String>,
216    locales: Vec<String>,
217    release_id: Option<String>,
218    version: Option<String>,
219    build: Option<u64>,
220    artifact_hash: Option<String>,
221    artifact_manifest_sha256: Option<String>,
222    release_content_assets: Vec<Value>,
223    uploaded_bytes: u64,
224    uploaded_assets: Vec<Value>,
225    provider_deployment_id: Option<String>,
226    canonical_url: Option<String>,
227    preview_url: Option<String>,
228    manual_follow_up: Vec<String>,
229    omitted_requirements: Vec<ReceiptRequirement>,
230    release_plan: &'a ReleasePlanReport,
231    release_metadata: Option<Value>,
232    release_content: Option<Value>,
233    distribution: Option<Value>,
234    provider_status: Option<Value>,
235    events: &'a [PublishWorkflowEvent],
236}
237
238#[derive(Debug, Serialize)]
239struct ReceiptRequirement {
240    id: String,
241    level: RequirementLevel,
242    status: RequirementStatus,
243    summary: String,
244}
245
246#[derive(Debug, Default)]
247struct ReceiptDetails {
248    release_id: Option<String>,
249    version: Option<String>,
250    build: Option<u64>,
251    artifact_hash: Option<String>,
252    artifact_manifest_sha256: Option<String>,
253    release_content_assets: Vec<Value>,
254    uploaded_bytes: u64,
255    uploaded_assets: Vec<Value>,
256    provider_deployment_id: Option<String>,
257    canonical_url: Option<String>,
258    preview_url: Option<String>,
259    manual_follow_up: Vec<String>,
260    omitted_requirements: Vec<ReceiptRequirement>,
261}
262
263pub fn readiness_release(options: PublishWorkflowOptions) -> Result<()> {
264    let options = publish_options_with_defaults(options)?;
265    let report = build_release_plan(&options, options.artifact.as_deref())?;
266    print_release_plan(&report, options.json)?;
267    if report.status == "blocked" {
268        bail!("release readiness failed");
269    }
270    Ok(())
271}
272
273pub fn release_readiness_checks(options: PublishWorkflowOptions) -> Result<Vec<ReadinessCheck>> {
274    let options = publish_options_with_defaults(options)?;
275    let report = build_release_plan(&options, options.artifact.as_deref())?;
276    let mut checks = report
277        .requirements
278        .into_iter()
279        .map(release_requirement_readiness_check)
280        .collect::<Vec<_>>();
281    checks.extend(
282        report
283            .capabilities
284            .into_iter()
285            .map(provider_capability_readiness_check),
286    );
287    Ok(checks)
288}
289
290pub fn release_plan_snapshot(options: PublishWorkflowOptions) -> Result<ReleasePlanSnapshot> {
291    let options = publish_options_with_defaults(options)?;
292    let report = build_release_plan(&options, options.artifact.as_deref())?;
293    Ok(release_plan_snapshot_from_report(report))
294}
295
296pub fn publish_workflow(mut options: PublishWorkflowOptions) -> Result<()> {
297    options = publish_options_with_defaults(options)?;
298    let mut events = Vec::new();
299    record_event(&mut events, "workflow.started", "running", None);
300    if !options.dry_run && !options.yes {
301        record_event(
302            &mut events,
303            "workflow.confirmation",
304            "blocked",
305            Some("--yes is required before mutating provider state".to_string()),
306        );
307        let report = build_release_plan(&options, options.artifact.as_deref())?;
308        let receipt_path = write_publish_workflow_receipt(
309            &options, &report, "blocked", None, None, None, None, &events,
310        )?;
311        if options.json {
312            print_publish_workflow_json(
313                &report,
314                None,
315                None,
316                None,
317                None,
318                &events,
319                Some(&receipt_path),
320            )?;
321        } else {
322            print_release_plan(&report, options.json)?;
323        }
324        bail!("publishing changes provider state; pass --yes after reviewing the release plan or use --dry-run");
325    }
326
327    record_event(&mut events, "release.version.resolve", "started", None);
328    let resolved = resolve_release_version_config(&options.project_dir, options.target)?;
329    if let Some(target) = options.target {
330        if options.artifact.is_none() {
331            sync_release_platform_config(&options.project_dir, target, &resolved)?;
332            record_event(
333                &mut events,
334                "release.platform_config.sync",
335                "completed",
336                Some(target.as_str().to_string()),
337            );
338        } else {
339            record_event(
340                &mut events,
341                "release.platform_config.sync",
342                "skipped",
343                Some("existing artifact supplied; native files are not rewritten".to_string()),
344            );
345        }
346    }
347    record_event(&mut events, "release.version.resolve", "completed", None);
348
349    record_event(&mut events, "release.preflight", "started", None);
350    let preflight = build_release_plan(&options, options.artifact.as_deref())?;
351    record_event(
352        &mut events,
353        "release.preflight",
354        preflight.status.as_str(),
355        Some(format!("{} requirement(s)", preflight.requirements.len())),
356    );
357    if preflight.status == "blocked" {
358        record_event(&mut events, "workflow.blocked", "blocked", None);
359        let receipt_path = write_publish_workflow_receipt(
360            &options, &preflight, "blocked", None, None, None, None, &events,
361        )?;
362        if options.json {
363            print_publish_workflow_json(
364                &preflight,
365                None,
366                None,
367                None,
368                None,
369                &events,
370                Some(&receipt_path),
371            )?;
372        } else {
373            print_release_plan(&preflight, options.json)?;
374        }
375        bail!("release readiness failed before packaging");
376    }
377
378    let artifact_path = if let Some(path) = options.artifact.clone() {
379        record_event(
380            &mut events,
381            "package.artifact",
382            "reused",
383            Some(path.display().to_string()),
384        );
385        path
386    } else {
387        let target = options
388            .target
389            .context("publish without --artifact requires --target")?;
390        let format = options
391            .format
392            .context("publish without --artifact requires --format")?;
393        record_event(
394            &mut events,
395            "package.artifact",
396            "started",
397            Some(format!("{} {}", target.as_str(), format.as_str())),
398        );
399        let path = match package_cmd::package_silent(PackageOptions {
400            project_dir: options.project_dir.clone(),
401            target,
402            format,
403            release: true,
404            json: options.json,
405        }) {
406            Ok(path) => path,
407            Err(error) => {
408                return fail_publish_workflow(
409                    &options,
410                    &preflight,
411                    &mut events,
412                    "package.artifact",
413                    error.to_string(),
414                    None,
415                    None,
416                    None,
417                    None,
418                );
419            }
420        };
421        record_event(
422            &mut events,
423            "package.artifact",
424            "completed",
425            Some(path.display().to_string()),
426        );
427        path
428    };
429    options.artifact = Some(artifact_path.clone());
430
431    record_event(&mut events, "release.plan", "started", None);
432    let report = build_release_plan(&options, Some(&artifact_path))?;
433    record_event(
434        &mut events,
435        "release.plan",
436        report.status.as_str(),
437        Some(format!("{} requirement(s)", report.requirements.len())),
438    );
439    if options.dry_run && !options.json {
440        print_release_plan(&report, options.json)?;
441    }
442    if report.status == "blocked" {
443        record_event(&mut events, "workflow.blocked", "blocked", None);
444        let receipt_path = write_publish_workflow_receipt(
445            &options, &report, "blocked", None, None, None, None, &events,
446        )?;
447        if options.json {
448            print_publish_workflow_json(
449                &report,
450                None,
451                None,
452                None,
453                None,
454                &events,
455                Some(&receipt_path),
456            )?;
457        }
458        bail!("release readiness failed");
459    }
460    record_event(&mut events, "release.content_manifest", "started", None);
461    let content_manifest =
462        match content::materialize_release_content_manifest(&options.project_dir, options.provider)
463        {
464            Ok(manifest) => manifest,
465            Err(error) => {
466                let message = error.to_string();
467                let release_content = json!({
468                    "provider": options.provider.as_str(),
469                    "status": "failed",
470                    "stage": "release.content_manifest",
471                    "error": message,
472                });
473                return fail_publish_workflow(
474                    &options,
475                    &report,
476                    &mut events,
477                    "release.content_manifest",
478                    error.to_string(),
479                    None,
480                    Some(release_content),
481                    None,
482                    None,
483                );
484            }
485        };
486    record_event(
487        &mut events,
488        "release.content_manifest",
489        if content_manifest.is_some() {
490            "completed"
491        } else {
492            "skipped"
493        },
494        content_manifest
495            .as_ref()
496            .map(|path| path.display().to_string()),
497    );
498    record_event(&mut events, "release.metadata", "started", None);
499    let metadata = match maybe_sync_release_metadata(&options, &report, options.dry_run) {
500        Ok(metadata) => metadata,
501        Err(error) => {
502            let message = error.to_string();
503            let metadata = json!({
504                "provider": options.provider.as_str(),
505                "status": "failed",
506                "stage": "release.metadata",
507                "error": message,
508            });
509            return fail_publish_workflow(
510                &options,
511                &report,
512                &mut events,
513                "release.metadata",
514                error.to_string(),
515                Some(metadata),
516                None,
517                None,
518                None,
519            );
520        }
521    };
522    record_event(
523        &mut events,
524        "release.metadata",
525        if metadata.is_some() {
526            if options.dry_run {
527                "dry-run"
528            } else {
529                "completed"
530            }
531        } else {
532            "skipped"
533        },
534        metadata.as_ref().map(release_metadata_message_detail),
535    );
536    if !options.json {
537        if let Some(metadata) = &metadata {
538            println!("{}", release_metadata_message(metadata, options.dry_run));
539        }
540    }
541    record_event(&mut events, "release.content", "started", None);
542    let release_content = match maybe_sync_release_content(&options, &report, options.dry_run) {
543        Ok(release_content) => release_content,
544        Err(error) => {
545            let message = error.to_string();
546            let release_content = json!({
547                "provider": options.provider.as_str(),
548                "status": "failed",
549                "stage": "release.content",
550                "error": message,
551            });
552            return fail_publish_workflow(
553                &options,
554                &report,
555                &mut events,
556                "release.content",
557                error.to_string(),
558                metadata.clone(),
559                Some(release_content),
560                None,
561                None,
562            );
563        }
564    };
565    let release_content_event_status = release_content
566        .as_ref()
567        .map(|value| release_content_status(value, options.dry_run))
568        .unwrap_or_else(|| "skipped".to_string());
569    record_event(
570        &mut events,
571        "release.content",
572        &release_content_event_status,
573        release_content.as_ref().map(release_content_message_detail),
574    );
575    if !options.json {
576        if let Some(release_content) = &release_content {
577            println!(
578                "{}",
579                release_content_message(release_content, options.dry_run)
580            );
581        }
582    }
583    record_event(
584        &mut events,
585        "distribution.publish",
586        "started",
587        Some(options.provider.as_str().to_string()),
588    );
589    let distribution_outcome = match package_cmd::distribute_publish_outcome(distribute_options(
590        &options,
591        options.dry_run,
592    )) {
593        Ok(outcome) => outcome,
594        Err(error) => {
595            let message = error.to_string();
596            let distribution = json!({
597                "provider": options.provider.as_str(),
598                "status": "failed",
599                "error": message,
600            });
601            return fail_publish_workflow(
602                &options,
603                &report,
604                &mut events,
605                "distribution.publish",
606                error.to_string(),
607                metadata.clone(),
608                release_content.clone(),
609                Some(distribution.clone()),
610                None,
611            );
612        }
613    };
614    append_distribution_events(&mut events, &distribution_outcome.events);
615    let distribution = distribution_outcome.receipt;
616    let distribution_status = distribution
617        .get("status")
618        .and_then(Value::as_str)
619        .unwrap_or(if options.dry_run {
620            "dry-run"
621        } else {
622            "completed"
623        })
624        .to_string();
625    record_event(
626        &mut events,
627        "distribution.publish",
628        &distribution_status,
629        distribution
630            .get("deployment_id")
631            .and_then(Value::as_str)
632            .map(str::to_string),
633    );
634    let provider_status = observe_provider_status(&options, &mut events);
635    record_event(
636        &mut events,
637        "workflow.completed",
638        &distribution_status,
639        None,
640    );
641    let receipt_path = write_publish_workflow_receipt(
642        &options,
643        &report,
644        &distribution_status,
645        metadata.clone(),
646        release_content.clone(),
647        Some(distribution.clone()),
648        provider_status.clone(),
649        &events,
650    )?;
651    if options.json {
652        print_publish_workflow_json(
653            &report,
654            metadata,
655            release_content,
656            Some(distribution),
657            provider_status,
658            &events,
659            Some(&receipt_path),
660        )?;
661    } else {
662        print_distribution_summary(&options, &distribution);
663    }
664    Ok(())
665}
666
667fn publish_options_with_defaults(
668    mut options: PublishWorkflowOptions,
669) -> Result<PublishWorkflowOptions> {
670    let artifact_target_format = options
671        .artifact
672        .as_deref()
673        .filter(|path| path.exists())
674        .map(artifact_target_format)
675        .transpose()?;
676
677    if options.target.is_none() {
678        options.target = artifact_target_format
679            .as_ref()
680            .and_then(|(target, _)| *target)
681            .or_else(|| Some(package_cmd::default_target_for_provider(options.provider)));
682    }
683    if options.format.is_none() {
684        options.format = artifact_target_format
685            .as_ref()
686            .and_then(|(_, format)| *format)
687            .or_else(|| {
688                options.target.map(|target| {
689                    package_cmd::default_format_for_target_provider(target, options.provider)
690                })
691            });
692    }
693    if options.track.is_none() {
694        options.track =
695            package_cmd::default_track_for_provider(options.provider).map(str::to_string);
696    }
697    Ok(options)
698}
699
700fn maybe_sync_release_metadata(
701    options: &PublishWorkflowOptions,
702    report: &ReleasePlanReport,
703    dry_run: bool,
704) -> Result<Option<Value>> {
705    if !release_metadata_provider(options.provider) || !release_config_ready(report) {
706        return Ok(None);
707    }
708    let locales = (!options.locales.is_empty()).then(|| options.locales.join(","));
709    store_ops::release_config_push_value(
710        options.provider,
711        locales.as_deref(),
712        options.overwrite_remote,
713        dry_run,
714        true,
715        &options.project_dir,
716    )
717    .map(Some)
718}
719
720fn maybe_sync_release_content(
721    options: &PublishWorkflowOptions,
722    report: &ReleasePlanReport,
723    dry_run: bool,
724) -> Result<Option<Value>> {
725    if !release_content_provider(options.provider) || !release_content_ready(report) {
726        return Ok(None);
727    }
728    let locales = (!options.locales.is_empty()).then(|| options.locales.join(","));
729    store_ops::release_content_push_value(
730        options.provider,
731        locales.as_deref(),
732        dry_run,
733        true,
734        &options.project_dir,
735    )
736    .map(Some)
737}
738
739fn print_publish_workflow_json(
740    report: &ReleasePlanReport,
741    metadata: Option<Value>,
742    release_content: Option<Value>,
743    distribution: Option<Value>,
744    provider_status: Option<Value>,
745    events: &[PublishWorkflowEvent],
746    receipt_path: Option<&Path>,
747) -> Result<()> {
748    let status = distribution
749        .as_ref()
750        .and_then(|distribution| distribution.get("status"))
751        .and_then(Value::as_str)
752        .unwrap_or(report.status.as_str());
753    let mut value = json!({
754        "schema_version": 1,
755        "status": status,
756        "release_plan": report,
757        "release_metadata": metadata,
758        "release_content": release_content,
759        "distribution": distribution,
760        "provider_status": provider_status,
761        "events": events,
762        "workflow_receipt": receipt_path.map(|path| path.display().to_string()),
763    });
764    redact_json_value(&mut value);
765    println!("{}", serde_json::to_string_pretty(&value)?);
766    Ok(())
767}
768
769fn fail_publish_workflow(
770    options: &PublishWorkflowOptions,
771    report: &ReleasePlanReport,
772    events: &mut Vec<PublishWorkflowEvent>,
773    stage_id: &str,
774    message: String,
775    metadata: Option<Value>,
776    release_content: Option<Value>,
777    distribution: Option<Value>,
778    provider_status: Option<Value>,
779) -> Result<()> {
780    record_event(events, stage_id, "failed", Some(message.clone()));
781    record_event(events, "workflow.failed", "failed", Some(message.clone()));
782    let receipt_path = write_publish_workflow_receipt(
783        options,
784        report,
785        "failed",
786        metadata.clone(),
787        release_content.clone(),
788        distribution.clone(),
789        provider_status.clone(),
790        events,
791    )?;
792    if options.json {
793        print_publish_workflow_json(
794            report,
795            metadata,
796            release_content,
797            distribution,
798            provider_status,
799            events,
800            Some(&receipt_path),
801        )?;
802    }
803    bail!(
804        "{stage_id} failed: {}; workflow receipt: {}",
805        redact_sensitive_text(&message),
806        receipt_path.display()
807    )
808}
809
810fn write_publish_workflow_receipt(
811    options: &PublishWorkflowOptions,
812    report: &ReleasePlanReport,
813    status: &str,
814    metadata: Option<Value>,
815    release_content: Option<Value>,
816    distribution: Option<Value>,
817    provider_status: Option<Value>,
818    events: &[PublishWorkflowEvent],
819) -> Result<PathBuf> {
820    let created_at = now_unix_seconds();
821    let details = publish_receipt_details(
822        options,
823        report,
824        release_content.as_ref(),
825        distribution.as_ref(),
826    )?;
827    let receipt = PublishWorkflowReceipt {
828        schema_version: 1,
829        created_at_unix_seconds: created_at,
830        status: status.to_string(),
831        dry_run: options.dry_run,
832        provider: options.provider.as_str().to_string(),
833        target: options.target.map(|target| target.as_str().to_string()),
834        format: options.format.map(|format| format.as_str().to_string()),
835        artifact: options
836            .artifact
837            .as_ref()
838            .map(|path| path.display().to_string()),
839        track: options.track.clone(),
840        locales: options.locales.clone(),
841        release_id: details.release_id,
842        version: details.version,
843        build: details.build,
844        artifact_hash: details.artifact_hash,
845        artifact_manifest_sha256: details.artifact_manifest_sha256,
846        release_content_assets: details.release_content_assets,
847        uploaded_bytes: details.uploaded_bytes,
848        uploaded_assets: details.uploaded_assets,
849        provider_deployment_id: details.provider_deployment_id,
850        canonical_url: details.canonical_url,
851        preview_url: details.preview_url,
852        manual_follow_up: details.manual_follow_up,
853        omitted_requirements: details.omitted_requirements,
854        release_plan: report,
855        release_metadata: metadata,
856        release_content,
857        distribution,
858        provider_status,
859        events,
860    };
861    let dir = options
862        .project_dir
863        .join("target/fission/release-workflows/publish")
864        .join(options.provider.as_str());
865    fs::create_dir_all(&dir)?;
866    let path = unique_receipt_path(dir.join(format!("publish-{created_at}.json")));
867    let mut value =
868        serde_json::to_value(&receipt).context("failed to serialize publish workflow receipt")?;
869    redact_json_value(&mut value);
870    fs::write(&path, serde_json::to_vec_pretty(&value)?)
871        .with_context(|| format!("failed to write {}", path.display()))?;
872    Ok(path)
873}
874
875fn unique_receipt_path(path: PathBuf) -> PathBuf {
876    if !path.exists() {
877        return path;
878    }
879    let parent = path.parent().map(Path::to_path_buf).unwrap_or_default();
880    let stem = path
881        .file_stem()
882        .and_then(OsStr::to_str)
883        .unwrap_or("receipt");
884    let extension = path.extension().and_then(OsStr::to_str);
885    for index in 1u32.. {
886        let file_name = match extension {
887            Some(extension) => format!("{stem}-{index}.{extension}"),
888            None => format!("{stem}-{index}"),
889        };
890        let candidate = parent.join(file_name);
891        if !candidate.exists() {
892            return candidate;
893        }
894    }
895    unreachable!("unbounded receipt path search should always return")
896}
897
898fn publish_receipt_details(
899    options: &PublishWorkflowOptions,
900    report: &ReleasePlanReport,
901    release_content: Option<&Value>,
902    distribution: Option<&Value>,
903) -> Result<ReceiptDetails> {
904    let artifact_manifest = options
905        .artifact
906        .as_deref()
907        .filter(|path| path.exists())
908        .map(read_json_file)
909        .transpose()?;
910    let artifact_manifest_sha256 = options
911        .artifact
912        .as_deref()
913        .filter(|path| path.exists())
914        .map(sha256_file)
915        .transpose()?;
916
917    let version = distribution
918        .and_then(|value| string_field(value, "version"))
919        .or_else(|| {
920            artifact_manifest
921                .as_ref()
922                .and_then(|value| value.pointer("/project/version"))
923                .and_then(Value::as_str)
924                .map(str::to_string)
925        });
926    let build = distribution
927        .and_then(|value| u64_field(value, "build"))
928        .or_else(|| {
929            artifact_manifest
930                .as_ref()
931                .and_then(|value| value.pointer("/project/build"))
932                .and_then(value_as_u64)
933        });
934    let release_id = distribution
935        .and_then(|value| string_field(value, "release_id"))
936        .or_else(|| {
937            version
938                .as_ref()
939                .zip(build)
940                .map(|(version, build)| format!("{version}+{build}"))
941        });
942    let artifact_hash = distribution
943        .and_then(|value| string_field(value, "artifact_hash"))
944        .or_else(|| {
945            artifact_manifest
946                .as_ref()
947                .and_then(|value| value.pointer("/artifacts/0/sha256"))
948                .and_then(Value::as_str)
949                .map(str::to_string)
950        });
951    let release_content_assets = release_content_asset_values(release_content);
952    let mut uploaded_assets = distribution
953        .and_then(|value| value.get("uploaded_assets"))
954        .and_then(Value::as_array)
955        .cloned()
956        .unwrap_or_default();
957    if release_content
958        .and_then(|value| value.get("status"))
959        .and_then(Value::as_str)
960        .is_some_and(|status| matches!(status, "pushed" | "uploaded"))
961    {
962        uploaded_assets.extend(release_content_assets.clone());
963    }
964    let uploaded_bytes = distribution
965        .and_then(|value| value.get("uploaded_bytes"))
966        .and_then(Value::as_u64)
967        .unwrap_or_else(|| {
968            uploaded_assets
969                .iter()
970                .filter_map(|asset| asset.get("size_bytes").and_then(Value::as_u64))
971                .sum()
972        });
973    let mut manual_follow_up = distribution
974        .and_then(|value| value.get("manual_follow_up"))
975        .and_then(Value::as_array)
976        .into_iter()
977        .flatten()
978        .filter_map(Value::as_str)
979        .map(str::to_string)
980        .collect::<Vec<_>>();
981    manual_follow_up.extend(
982        release_content
983            .and_then(|value| value.get("manual_follow_up"))
984            .and_then(Value::as_array)
985            .into_iter()
986            .flatten()
987            .filter_map(Value::as_str)
988            .map(str::to_string),
989    );
990
991    Ok(ReceiptDetails {
992        release_id,
993        version,
994        build,
995        artifact_hash,
996        artifact_manifest_sha256: distribution
997            .and_then(|value| string_field(value, "artifact_manifest_sha256"))
998            .or(artifact_manifest_sha256),
999        release_content_assets,
1000        uploaded_bytes,
1001        uploaded_assets,
1002        provider_deployment_id: distribution.and_then(|value| string_field(value, "deployment_id")),
1003        canonical_url: distribution.and_then(|value| string_field(value, "canonical_url")),
1004        preview_url: distribution.and_then(|value| string_field(value, "preview_url")),
1005        manual_follow_up,
1006        omitted_requirements: omitted_requirements(report),
1007    })
1008}
1009
1010fn release_content_asset_values(release_content: Option<&Value>) -> Vec<Value> {
1011    let mut assets = Vec::new();
1012    for key in ["assets", "handoff_assets"] {
1013        assets.extend(
1014            release_content
1015                .and_then(|value| value.get(key))
1016                .and_then(Value::as_array)
1017                .into_iter()
1018                .flatten()
1019                .cloned(),
1020        );
1021    }
1022    assets
1023}
1024
1025fn omitted_requirements(report: &ReleasePlanReport) -> Vec<ReceiptRequirement> {
1026    report
1027        .requirements
1028        .iter()
1029        .filter(|requirement| requirement.status != RequirementStatus::Passed)
1030        .map(|requirement| ReceiptRequirement {
1031            id: requirement.id.clone(),
1032            level: requirement.level,
1033            status: requirement.status,
1034            summary: requirement.summary.clone(),
1035        })
1036        .collect()
1037}
1038
1039fn read_json_file(path: &Path) -> Result<Value> {
1040    serde_json::from_slice(
1041        &fs::read(path).with_context(|| format!("failed to read {}", path.display()))?,
1042    )
1043    .with_context(|| format!("failed to parse {}", path.display()))
1044}
1045
1046fn sha256_file(path: &Path) -> Result<String> {
1047    let bytes = fs::read(path).with_context(|| format!("failed to read {}", path.display()))?;
1048    let mut hasher = Sha256::new();
1049    hasher.update(&bytes);
1050    Ok(format!("{:x}", hasher.finalize()))
1051}
1052
1053fn string_field(value: &Value, key: &str) -> Option<String> {
1054    value
1055        .get(key)
1056        .and_then(Value::as_str)
1057        .filter(|text| !text.trim().is_empty())
1058        .map(str::to_string)
1059}
1060
1061fn u64_field(value: &Value, key: &str) -> Option<u64> {
1062    value.get(key).and_then(value_as_u64)
1063}
1064
1065fn value_as_u64(value: &Value) -> Option<u64> {
1066    value
1067        .as_u64()
1068        .or_else(|| value.as_str().and_then(|text| text.parse().ok()))
1069}
1070
1071fn record_event(
1072    events: &mut Vec<PublishWorkflowEvent>,
1073    id: &str,
1074    status: &str,
1075    details: Option<String>,
1076) {
1077    events.push(PublishWorkflowEvent {
1078        at_unix_seconds: now_unix_seconds(),
1079        id: id.to_string(),
1080        status: status.to_string(),
1081        details: details.map(|details| redact_sensitive_text(&details)),
1082    });
1083}
1084
1085fn append_distribution_events(
1086    events: &mut Vec<PublishWorkflowEvent>,
1087    distribution_events: &[package_cmd::DistributionEvent],
1088) {
1089    events.extend(distribution_events.iter().map(|event| {
1090        PublishWorkflowEvent {
1091            at_unix_seconds: event.at_unix_seconds,
1092            id: event.id.clone(),
1093            status: event.status.clone(),
1094            details: event
1095                .details
1096                .as_ref()
1097                .map(|details| redact_sensitive_text(details)),
1098        }
1099    }));
1100}
1101
1102fn redact_sensitive_text(text: &str) -> String {
1103    let mut redacted = text.to_string();
1104    for (key, value) in secret_env_values() {
1105        redacted = redacted.replace(&value, &format!("<redacted:{key}>"));
1106    }
1107    redacted
1108}
1109
1110fn redact_json_value(value: &mut Value) {
1111    match value {
1112        Value::String(text) => {
1113            *text = redact_sensitive_text(text);
1114        }
1115        Value::Array(items) => {
1116            for item in items {
1117                redact_json_value(item);
1118            }
1119        }
1120        Value::Object(object) => {
1121            for item in object.values_mut() {
1122                redact_json_value(item);
1123            }
1124        }
1125        _ => {}
1126    }
1127}
1128
1129fn secret_env_values() -> Vec<(String, String)> {
1130    let mut values = env::vars()
1131        .filter(|(key, value)| secretish_env_key(key) && value.len() >= 8)
1132        .map(|(key, value)| (key.to_ascii_uppercase(), value))
1133        .collect::<Vec<_>>();
1134    values.sort_by(|(_, left), (_, right)| right.len().cmp(&left.len()));
1135    values.dedup_by(|(_, left), (_, right)| left == right);
1136    values
1137}
1138
1139fn secretish_env_key(key: &str) -> bool {
1140    let key = key.to_ascii_uppercase();
1141    [
1142        "PASSWORD",
1143        "TOKEN",
1144        "SECRET",
1145        "PRIVATE",
1146        "CREDENTIAL",
1147        "KEYSTORE",
1148        "SERVICE_ACCOUNT",
1149        "API_KEY",
1150        "ACCESS_KEY",
1151        "CLIENT_SECRET",
1152        "CERTIFICATE",
1153        "P8",
1154        "P12",
1155        "PFX",
1156        "JKS",
1157    ]
1158    .iter()
1159    .any(|needle| key.contains(needle))
1160}
1161
1162fn observe_provider_status(
1163    options: &PublishWorkflowOptions,
1164    events: &mut Vec<PublishWorkflowEvent>,
1165) -> Option<Value> {
1166    if options.dry_run {
1167        record_event(
1168            events,
1169            "provider.observe",
1170            "skipped",
1171            Some("dry run does not query provider state".to_string()),
1172        );
1173        return None;
1174    }
1175
1176    record_event(
1177        events,
1178        "provider.observe",
1179        "started",
1180        Some(options.provider.as_str().to_string()),
1181    );
1182    match package_cmd::distribute_status_outcome(status_distribute_options(options)) {
1183        Ok(outcome) => {
1184            append_distribution_events(events, &outcome.events);
1185            let status = outcome.receipt;
1186            let observed_status = status
1187                .get("status")
1188                .and_then(Value::as_str)
1189                .unwrap_or("completed")
1190                .to_string();
1191            record_event(
1192                events,
1193                "provider.observe",
1194                &observed_status,
1195                status
1196                    .get("deployment_id")
1197                    .or_else(|| status.get("canonical_url"))
1198                    .and_then(Value::as_str)
1199                    .map(str::to_string),
1200            );
1201            Some(status)
1202        }
1203        Err(error) => {
1204            record_event(
1205                events,
1206                "provider.observe",
1207                "warning",
1208                Some(error.to_string()),
1209            );
1210            None
1211        }
1212    }
1213}
1214
1215fn release_metadata_message_detail(value: &Value) -> String {
1216    let provider = value
1217        .get("provider")
1218        .and_then(Value::as_str)
1219        .unwrap_or("provider");
1220    let status = value
1221        .get("status")
1222        .and_then(Value::as_str)
1223        .unwrap_or("complete");
1224    format!("{provider} {status}")
1225}
1226
1227fn print_distribution_summary(options: &PublishWorkflowOptions, value: &Value) {
1228    let status = value
1229        .get("status")
1230        .and_then(Value::as_str)
1231        .unwrap_or("complete");
1232    println!("{} publish status: {status}", options.provider.as_str());
1233    if let Some(url) = value.get("canonical_url").and_then(Value::as_str) {
1234        println!("URL: {url}");
1235    }
1236    if let Some(assets) = value.get("uploaded_assets").and_then(Value::as_array) {
1237        if !assets.is_empty() {
1238            println!("Uploaded/planned assets: {}", assets.len());
1239            for asset in assets.iter().take(10) {
1240                let relative = asset
1241                    .get("relative_path")
1242                    .and_then(Value::as_str)
1243                    .unwrap_or("<asset>");
1244                let destination = asset
1245                    .get("url")
1246                    .or_else(|| asset.get("provider_id"))
1247                    .and_then(Value::as_str)
1248                    .unwrap_or("<provider destination>");
1249                println!("  {relative} -> {destination}");
1250            }
1251            if assets.len() > 10 {
1252                println!("  ... {} more", assets.len() - 10);
1253            }
1254        }
1255    }
1256    if let Some(follow_up) = value.get("manual_follow_up").and_then(Value::as_array) {
1257        for item in follow_up.iter().filter_map(Value::as_str) {
1258            println!("Follow-up: {item}");
1259        }
1260    }
1261}
1262
1263fn release_metadata_provider(provider: DistributionProvider) -> bool {
1264    matches!(
1265        provider,
1266        DistributionProvider::PlayStore
1267            | DistributionProvider::AppStore
1268            | DistributionProvider::MicrosoftStore
1269    )
1270}
1271
1272fn release_content_provider(provider: DistributionProvider) -> bool {
1273    matches!(
1274        provider,
1275        DistributionProvider::PlayStore
1276            | DistributionProvider::AppStore
1277            | DistributionProvider::MicrosoftStore
1278    )
1279}
1280
1281fn release_config_ready(report: &ReleasePlanReport) -> bool {
1282    let mut saw_release_config = false;
1283    for requirement in &report.requirements {
1284        if !requirement.id.starts_with("release_config.") {
1285            continue;
1286        }
1287        saw_release_config = true;
1288        if !matches!(
1289            requirement.status,
1290            RequirementStatus::Passed | RequirementStatus::Skipped
1291        ) {
1292            return false;
1293        }
1294    }
1295    saw_release_config
1296}
1297
1298fn release_content_ready(report: &ReleasePlanReport) -> bool {
1299    let mut saw_release_content = false;
1300    let mut saw_asset = false;
1301    for requirement in &report.requirements {
1302        if !requirement.id.starts_with("release_content.") {
1303            continue;
1304        }
1305        saw_release_content = true;
1306        if requirement.id.contains(".rendered_assets")
1307            || requirement.id.contains(".required_assets")
1308            || requirement.id == "release_content.play_store.feature_graphic"
1309            || requirement.id == "release_content.play_store.screenshot_sets_dir"
1310        {
1311            saw_asset |= matches!(
1312                requirement.status,
1313                RequirementStatus::Passed | RequirementStatus::Skipped
1314            );
1315        }
1316        if requirement.level == RequirementLevel::ProviderRequired
1317            && matches!(
1318                requirement.status,
1319                RequirementStatus::Missing | RequirementStatus::Failed
1320            )
1321        {
1322            return false;
1323        }
1324    }
1325    saw_release_content && saw_asset
1326}
1327
1328fn release_content_message_detail(value: &Value) -> String {
1329    let provider = value
1330        .get("provider")
1331        .and_then(Value::as_str)
1332        .unwrap_or("provider");
1333    let status = value
1334        .get("status")
1335        .and_then(Value::as_str)
1336        .unwrap_or("complete");
1337    format!("{provider} {status}")
1338}
1339
1340fn release_content_status(value: &Value, dry_run: bool) -> String {
1341    if dry_run {
1342        return "dry-run".to_string();
1343    }
1344    value
1345        .get("status")
1346        .and_then(Value::as_str)
1347        .unwrap_or("completed")
1348        .to_string()
1349}
1350
1351fn release_metadata_message(value: &Value, dry_run: bool) -> String {
1352    let provider = value
1353        .get("provider")
1354        .and_then(Value::as_str)
1355        .unwrap_or("provider");
1356    let count = value
1357        .get("updated_locales")
1358        .or_else(|| value.get("pushed_locales"))
1359        .or_else(|| value.get("locales"))
1360        .and_then(Value::as_array)
1361        .map(Vec::len)
1362        .unwrap_or_default();
1363    if dry_run {
1364        format!("release metadata: would sync {count} {provider} locale(s)")
1365    } else {
1366        format!("release metadata: synced {count} {provider} locale(s)")
1367    }
1368}
1369
1370fn release_content_message(value: &Value, dry_run: bool) -> String {
1371    let provider = value
1372        .get("provider")
1373        .and_then(Value::as_str)
1374        .unwrap_or("provider");
1375    let status = value
1376        .get("status")
1377        .and_then(Value::as_str)
1378        .unwrap_or("completed");
1379    let count = value
1380        .get("assets")
1381        .and_then(Value::as_array)
1382        .map(Vec::len)
1383        .unwrap_or_default();
1384    if dry_run {
1385        format!("release content: would sync {count} {provider} asset(s)")
1386    } else if status == "staged" {
1387        format!("release content: staged {count} {provider} asset(s) for provider handoff")
1388    } else {
1389        format!("release content: synced {count} {provider} asset(s)")
1390    }
1391}
1392
1393fn distribute_options(options: &PublishWorkflowOptions, dry_run: bool) -> DistributeOptions {
1394    DistributeOptions {
1395        project_dir: options.project_dir.clone(),
1396        provider: options.provider,
1397        action: DistributeAction::Publish,
1398        target: options.target,
1399        format: options.format,
1400        artifact: options.artifact.clone(),
1401        site: options.site.clone(),
1402        deploy: options.deploy.clone(),
1403        track: options.track.clone(),
1404        locales: options.locales.clone(),
1405        dry_run,
1406        yes: options.yes,
1407        json: options.json,
1408    }
1409}
1410
1411fn status_distribute_options(options: &PublishWorkflowOptions) -> DistributeOptions {
1412    DistributeOptions {
1413        project_dir: options.project_dir.clone(),
1414        provider: options.provider,
1415        action: DistributeAction::Status,
1416        target: options.target,
1417        format: options.format,
1418        artifact: options.artifact.clone(),
1419        site: options.site.clone(),
1420        deploy: options.deploy.clone(),
1421        track: options.track.clone(),
1422        locales: options.locales.clone(),
1423        dry_run: false,
1424        yes: true,
1425        json: false,
1426    }
1427}
1428
1429fn build_release_plan(
1430    options: &PublishWorkflowOptions,
1431    artifact: Option<&Path>,
1432) -> Result<ReleasePlanReport> {
1433    let mut requirements = Vec::new();
1434    if let Some(target) = options.target {
1435        requirements.extend(
1436            package_cmd::package_readiness_checks_for_profile(
1437                &options.project_dir,
1438                Some(target),
1439                options.format,
1440                true,
1441            )?
1442            .into_iter()
1443            .map(package_requirement),
1444        );
1445        if release_target_requires_signing(target) {
1446            requirements.extend(
1447                signing_ops::status_checks(&options.project_dir, target)
1448                    .into_iter()
1449                    .map(|check| lifecycle_requirement(check, RequirementLevel::ProviderRequired)),
1450            );
1451        }
1452    }
1453    requirements.extend(
1454        package_cmd::distribution_readiness_checks(
1455            &options.project_dir,
1456            options.provider,
1457            &options.site,
1458            options.track.as_deref(),
1459            options.format,
1460            artifact,
1461        )?
1462        .into_iter()
1463        .map(package_requirement),
1464    );
1465
1466    let release_config =
1467        model::validate_release_config_model(&options.project_dir, Some(options.provider))?;
1468    requirements.extend(release_config.checks.into_iter().map(|check| {
1469        let level = if check.id.starts_with("release_config.secret_field.") {
1470            RequirementLevel::ProviderRequired
1471        } else {
1472            RequirementLevel::FissionRecommended
1473        };
1474        lifecycle_requirement(check, level)
1475    }));
1476    if release_metadata_provider(options.provider) {
1477        requirements.push(lifecycle_requirement(
1478            store_ops::release_config_lock_check(
1479                &options.project_dir,
1480                options.provider,
1481                options.overwrite_remote,
1482            ),
1483            RequirementLevel::ProviderRequired,
1484        ));
1485    }
1486
1487    let release_content =
1488        content::validate_release_content_model(&options.project_dir, Some(options.provider));
1489    requirements.extend(release_content.checks.into_iter().map(|check| {
1490        let level = release_content_requirement_level(options.provider, &check.id);
1491        lifecycle_requirement(check, level)
1492    }));
1493
1494    requirements.extend(version_requirements(options, artifact)?);
1495    apply_skipped_requirements(&options.project_dir, &mut requirements)?;
1496
1497    let status = if requirements.iter().any(|req| {
1498        req.level == RequirementLevel::ProviderRequired
1499            && matches!(
1500                req.status,
1501                RequirementStatus::Missing | RequirementStatus::Failed
1502            )
1503    }) {
1504        "blocked"
1505    } else if requirements.iter().any(|req| {
1506        !matches!(
1507            req.status,
1508            RequirementStatus::Passed | RequirementStatus::Skipped
1509        )
1510    }) {
1511        "warning"
1512    } else {
1513        "ready"
1514    }
1515    .to_string();
1516
1517    let steps = release_steps(&requirements, artifact.is_some(), &status);
1518    let capabilities = provider_capabilities(options.provider);
1519
1520    Ok(ReleasePlanReport {
1521        context: release_context_report(options),
1522        project_dir: options.project_dir.display().to_string(),
1523        provider: options.provider.as_str().to_string(),
1524        target: options.target.map(|target| target.as_str().to_string()),
1525        format: options.format.map(|format| format.as_str().to_string()),
1526        artifact: artifact.map(|path| path.display().to_string()),
1527        track: options.track.clone(),
1528        locales: options.locales.clone(),
1529        status,
1530        steps,
1531        capabilities,
1532        requirements,
1533    })
1534}
1535
1536fn release_plan_snapshot_from_report(report: ReleasePlanReport) -> ReleasePlanSnapshot {
1537    ReleasePlanSnapshot {
1538        context: ReleaseContextSnapshot {
1539            project_dir: report.context.project_dir,
1540            app_name: report.context.app_name,
1541            target: report.context.target,
1542            format: report.context.format,
1543            provider: report.context.provider,
1544            release_id: report.context.release_id,
1545            track: report.context.track,
1546            locales: report.context.locales,
1547            interactive: report.context.interactive,
1548            ci: report.context.ci,
1549        },
1550        project_dir: report.project_dir,
1551        provider: report.provider,
1552        target: report.target,
1553        format: report.format,
1554        artifact: report.artifact,
1555        track: report.track,
1556        locales: report.locales,
1557        status: report.status,
1558        steps: report
1559            .steps
1560            .into_iter()
1561            .map(|step| ReleaseStepSnapshot {
1562                id: step.id,
1563                title: step.title,
1564                status: step_status_label(step.status).to_string(),
1565                jobs: step
1566                    .jobs
1567                    .into_iter()
1568                    .map(|job| ReleaseJobSnapshot {
1569                        id: job.id,
1570                        title: job.title,
1571                        status: step_status_label(job.status).to_string(),
1572                    })
1573                    .collect(),
1574            })
1575            .collect(),
1576        capabilities: report
1577            .capabilities
1578            .into_iter()
1579            .map(|capability| ProviderCapabilitySnapshot {
1580                id: capability.id,
1581                status: provider_capability_status_label(capability.status).to_string(),
1582                summary: capability.summary,
1583                details: capability.details,
1584            })
1585            .collect(),
1586        requirements: report
1587            .requirements
1588            .into_iter()
1589            .map(|requirement| ReleaseRequirementSnapshot {
1590                id: requirement.id,
1591                level: requirement_level_label(requirement.level).to_string(),
1592                status: requirement_status_label(requirement.status).to_string(),
1593                summary: requirement.summary,
1594                details: requirement.details,
1595                remediation: requirement.remediation,
1596                can_fix_interactively: requirement.can_fix_interactively,
1597            })
1598            .collect(),
1599    }
1600}
1601
1602fn release_context_report(options: &PublishWorkflowOptions) -> ReleaseContextReport {
1603    let config = read_fission_toml_value(&options.project_dir).ok();
1604    ReleaseContextReport {
1605        project_dir: options.project_dir.display().to_string(),
1606        app_name: config
1607            .as_ref()
1608            .and_then(|value| value.pointer("/app/name"))
1609            .and_then(Value::as_str)
1610            .map(str::to_string),
1611        target: options.target.map(|target| target.as_str().to_string()),
1612        format: options.format.map(|format| format.as_str().to_string()),
1613        provider: options.provider.as_str().to_string(),
1614        release_id: config.as_ref().and_then(active_release_id_from_toml),
1615        track: options.track.clone(),
1616        locales: options.locales.clone(),
1617        interactive: !options.yes && !options.json,
1618        ci: options.yes && options.json,
1619    }
1620}
1621
1622fn release_target_requires_signing(target: Target) -> bool {
1623    matches!(
1624        target,
1625        Target::Android | Target::Ios | Target::Macos | Target::Windows
1626    )
1627}
1628
1629fn read_fission_toml_value(project_dir: &Path) -> Result<Value> {
1630    let path = project_dir.join("fission.toml");
1631    let text =
1632        fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
1633    let value: toml::Value =
1634        toml::from_str(&text).with_context(|| format!("failed to parse {}", path.display()))?;
1635    serde_json::to_value(value).context("failed to convert fission.toml to JSON value")
1636}
1637
1638fn active_release_id_from_toml(value: &Value) -> Option<String> {
1639    value
1640        .pointer("/release/active_release")
1641        .and_then(Value::as_str)
1642        .filter(|id| !id.trim().is_empty())
1643        .map(str::to_string)
1644}
1645
1646fn package_requirement(check: ReadinessCheck) -> ReleaseRequirement {
1647    ReleaseRequirement {
1648        id: check.id,
1649        level: match check.severity {
1650            CheckSeverity::Error => RequirementLevel::ProviderRequired,
1651            CheckSeverity::Warning => RequirementLevel::FissionRecommended,
1652            CheckSeverity::Info => RequirementLevel::Optional,
1653        },
1654        status: match check.status {
1655            CheckStatus::Passed => RequirementStatus::Passed,
1656            CheckStatus::Missing => RequirementStatus::Missing,
1657            CheckStatus::Failed => RequirementStatus::Failed,
1658            CheckStatus::Warning => RequirementStatus::Warning,
1659            CheckStatus::Skipped => RequirementStatus::Skipped,
1660        },
1661        summary: check.summary,
1662        details: check.details,
1663        remediation: check.remediation,
1664        can_fix_interactively: !matches!(check.status, CheckStatus::Passed | CheckStatus::Skipped)
1665            && check.severity != CheckSeverity::Info,
1666    }
1667}
1668
1669fn release_requirement_readiness_check(requirement: ReleaseRequirement) -> ReadinessCheck {
1670    ReadinessCheck {
1671        id: requirement.id,
1672        severity: match requirement.level {
1673            RequirementLevel::ProviderRequired => CheckSeverity::Error,
1674            RequirementLevel::FissionRecommended => CheckSeverity::Warning,
1675            RequirementLevel::Optional => CheckSeverity::Info,
1676        },
1677        status: match requirement.status {
1678            RequirementStatus::Passed => CheckStatus::Passed,
1679            RequirementStatus::Missing => CheckStatus::Missing,
1680            RequirementStatus::Failed => CheckStatus::Failed,
1681            RequirementStatus::Warning => CheckStatus::Warning,
1682            RequirementStatus::Skipped => CheckStatus::Skipped,
1683        },
1684        summary: requirement.summary,
1685        details: requirement.details,
1686        remediation: requirement.remediation,
1687    }
1688}
1689
1690fn release_content_requirement_level(provider: DistributionProvider, id: &str) -> RequirementLevel {
1691    let provider_prefix = format!("release_content.{}.", provider.as_str());
1692    if id == format!("{provider_prefix}required_assets")
1693        || id == "release_content.microsoft_store.required_logos"
1694        || (id.starts_with(&provider_prefix) && (id.contains(".image.") || id.contains(".video.")))
1695    {
1696        RequirementLevel::ProviderRequired
1697    } else {
1698        RequirementLevel::FissionRecommended
1699    }
1700}
1701
1702fn lifecycle_requirement(check: LifecycleCheck, level: RequirementLevel) -> ReleaseRequirement {
1703    ReleaseRequirement {
1704        id: check.id,
1705        level,
1706        status: match check.status.as_str() {
1707            "passed" | "ready" | "ok" => RequirementStatus::Passed,
1708            "missing" => RequirementStatus::Missing,
1709            "failed" | "blocked" => RequirementStatus::Failed,
1710            "warning" => RequirementStatus::Warning,
1711            "skipped" => RequirementStatus::Skipped,
1712            _ => RequirementStatus::Warning,
1713        },
1714        summary: check.summary,
1715        details: check.details,
1716        remediation: check.remediation,
1717        can_fix_interactively: !matches!(
1718            check.status.as_str(),
1719            "passed" | "ready" | "ok" | "skipped"
1720        ),
1721    }
1722}
1723
1724fn version_requirements(
1725    options: &PublishWorkflowOptions,
1726    artifact: Option<&Path>,
1727) -> Result<Vec<ReleaseRequirement>> {
1728    let mut requirements = Vec::new();
1729    let resolved = resolve_release_version_config(&options.project_dir, options.target)?;
1730    let artifact_release = artifact
1731        .filter(|path| path.exists())
1732        .map(artifact_release_version_config)
1733        .transpose()?
1734        .unwrap_or_default();
1735    let version = resolved
1736        .version
1737        .clone()
1738        .or(artifact_release.version.clone());
1739    let build = resolved.build.or(artifact_release.build);
1740    requirements.push(ReleaseRequirement {
1741        id: "release.version.resolved".to_string(),
1742        level: RequirementLevel::ProviderRequired,
1743        status: if version
1744            .as_deref()
1745            .is_some_and(|value| !value.trim().is_empty())
1746        {
1747            RequirementStatus::Passed
1748        } else {
1749            RequirementStatus::Missing
1750        },
1751        summary: "release version is resolved".to_string(),
1752        details: version.clone(),
1753        remediation: vec![
1754            "Set app.version, package target version fields, or add an active [[releases]] entry."
1755                .to_string(),
1756        ],
1757        can_fix_interactively: version.is_none(),
1758    });
1759    requirements.push(ReleaseRequirement {
1760        id: "release.build.resolved".to_string(),
1761        level: RequirementLevel::ProviderRequired,
1762        status: if build.is_some() {
1763            RequirementStatus::Passed
1764        } else {
1765            RequirementStatus::Missing
1766        },
1767        summary: "release build number is resolved".to_string(),
1768        details: build.map(|value| value.to_string()),
1769        remediation: vec![
1770            "Set app.build, package target build fields, or add an active [[releases]] entry."
1771                .to_string(),
1772        ],
1773        can_fix_interactively: build.is_none(),
1774    });
1775    if let (Some(config_version), Some(artifact_version)) = (
1776        resolved.version.as_deref(),
1777        artifact_release.version.as_deref(),
1778    ) {
1779        requirements.push(version_match_requirement(
1780            "release.version.artifact_matches_config",
1781            "artifact release version matches fission.toml",
1782            config_version == artifact_version,
1783            format!("config={config_version}, artifact={artifact_version}"),
1784            "Rebuild the artifact after changing app.version/release.version, or publish the artifact from the matching release record.",
1785        ));
1786    }
1787    if let (Some(config_build), Some(artifact_build)) = (resolved.build, artifact_release.build) {
1788        requirements.push(version_match_requirement(
1789            "release.build.artifact_matches_config",
1790            "artifact build number matches fission.toml",
1791            config_build == artifact_build,
1792            format!("config={config_build}, artifact={artifact_build}"),
1793            "Rebuild the artifact after changing app.build/release.build, or publish the artifact from the matching release record.",
1794        ));
1795    }
1796    Ok(requirements)
1797}
1798
1799fn artifact_release_version_config(
1800    artifact: &Path,
1801) -> Result<fission_command_core::ReleaseVersionConfig> {
1802    let value: Value = serde_json::from_slice(
1803        &std::fs::read(artifact)
1804            .with_context(|| format!("failed to read artifact manifest {}", artifact.display()))?,
1805    )
1806    .with_context(|| format!("failed to parse artifact manifest {}", artifact.display()))?;
1807    let project = value.get("project").unwrap_or(&Value::Null);
1808    let version = project
1809        .get("version")
1810        .and_then(Value::as_str)
1811        .filter(|value| !value.trim().is_empty())
1812        .map(str::to_string);
1813    let build = project.get("build").and_then(|value| {
1814        value
1815            .as_u64()
1816            .or_else(|| value.as_str().and_then(|value| value.parse::<u64>().ok()))
1817    });
1818    Ok(fission_command_core::ReleaseVersionConfig { version, build })
1819}
1820
1821fn artifact_target_format(artifact: &Path) -> Result<(Option<Target>, Option<PackageFormat>)> {
1822    let value = read_json_file(artifact)?;
1823    Ok((
1824        value
1825            .get("target")
1826            .and_then(Value::as_str)
1827            .and_then(target_from_manifest),
1828        value
1829            .get("format")
1830            .and_then(Value::as_str)
1831            .and_then(format_from_manifest),
1832    ))
1833}
1834
1835fn target_from_manifest(value: &str) -> Option<Target> {
1836    match value {
1837        "android" => Some(Target::Android),
1838        "ios" => Some(Target::Ios),
1839        "linux" => Some(Target::Linux),
1840        "macos" => Some(Target::Macos),
1841        "ssr" | "server" => Some(Target::Server),
1842        "static-site" | "site" => Some(Target::Site),
1843        "terminal" => Some(Target::Terminal),
1844        "web" => Some(Target::Web),
1845        "windows" => Some(Target::Windows),
1846        _ => None,
1847    }
1848}
1849
1850fn format_from_manifest(value: &str) -> Option<PackageFormat> {
1851    match value {
1852        "aab" => Some(PackageFormat::Aab),
1853        "apk" => Some(PackageFormat::Apk),
1854        "app" => Some(PackageFormat::App),
1855        "docker-image" => Some(PackageFormat::DockerImage),
1856        "exe" => Some(PackageFormat::Exe),
1857        "ipa" => Some(PackageFormat::Ipa),
1858        "msi" => Some(PackageFormat::Msi),
1859        "msix" => Some(PackageFormat::Msix),
1860        "pkg" => Some(PackageFormat::Pkg),
1861        "run" => Some(PackageFormat::Run),
1862        "static" => Some(PackageFormat::Static),
1863        _ => None,
1864    }
1865}
1866
1867fn apply_skipped_requirements(
1868    project_dir: &Path,
1869    requirements: &mut Vec<ReleaseRequirement>,
1870) -> Result<()> {
1871    let skipped = release_skipped_requirements(project_dir)?;
1872    if skipped.is_empty() {
1873        return Ok(());
1874    }
1875    let mut ignored_required = Vec::new();
1876    for requirement in requirements.iter_mut() {
1877        if !skipped.contains(&requirement.id) {
1878            continue;
1879        }
1880        if requirement.level == RequirementLevel::ProviderRequired {
1881            ignored_required.push(requirement.id.clone());
1882            continue;
1883        }
1884        requirement.status = RequirementStatus::Skipped;
1885        requirement.can_fix_interactively = false;
1886        requirement.details = Some(match requirement.details.take() {
1887            Some(details) => format!("{details}; explicitly skipped in release.skip_requirements"),
1888            None => "explicitly skipped in release.skip_requirements".to_string(),
1889        });
1890    }
1891    for id in ignored_required {
1892        requirements.push(ReleaseRequirement {
1893            id: format!("release.skip.provider_required.{}", check_id_fragment(&id)),
1894            level: RequirementLevel::FissionRecommended,
1895            status: RequirementStatus::Warning,
1896            summary: "provider-required requirement cannot be skipped".to_string(),
1897            details: Some(id),
1898            remediation: vec![
1899                "Remove this id from release.skip_requirements or satisfy the provider-required requirement."
1900                    .to_string(),
1901            ],
1902            can_fix_interactively: false,
1903        });
1904    }
1905    for id in skipped {
1906        if requirements.iter().any(|requirement| requirement.id == id) {
1907            continue;
1908        }
1909        requirements.push(ReleaseRequirement {
1910            id: format!("release.skip.{}", check_id_fragment(&id)),
1911            level: RequirementLevel::Optional,
1912            status: RequirementStatus::Warning,
1913            summary: "release.skip_requirements entry did not match a current requirement"
1914                .to_string(),
1915            details: Some(id),
1916            remediation: vec![
1917                "Remove stale skip entries or update them to match current release-plan requirement ids."
1918                    .to_string(),
1919            ],
1920            can_fix_interactively: false,
1921        });
1922    }
1923    Ok(())
1924}
1925
1926fn release_skipped_requirements(project_dir: &Path) -> Result<BTreeSet<String>> {
1927    let path = project_dir.join("fission.toml");
1928    if !path.exists() {
1929        return Ok(BTreeSet::new());
1930    }
1931    let config: ReleaseSkipToml = toml::from_str(
1932        &fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?,
1933    )
1934    .with_context(|| format!("failed to parse {}", path.display()))?;
1935    Ok(config
1936        .release
1937        .map(|release| release.skip_requirements)
1938        .unwrap_or_default()
1939        .into_iter()
1940        .filter(|id| !id.trim().is_empty())
1941        .collect())
1942}
1943
1944fn check_id_fragment(value: &str) -> String {
1945    value
1946        .chars()
1947        .map(|ch| {
1948            if ch.is_ascii_alphanumeric() {
1949                ch.to_ascii_lowercase()
1950            } else {
1951                '_'
1952            }
1953        })
1954        .collect()
1955}
1956
1957fn version_match_requirement(
1958    id: &str,
1959    summary: &str,
1960    matched: bool,
1961    details: String,
1962    remediation: &str,
1963) -> ReleaseRequirement {
1964    ReleaseRequirement {
1965        id: id.to_string(),
1966        level: RequirementLevel::FissionRecommended,
1967        status: if matched {
1968            RequirementStatus::Passed
1969        } else {
1970            RequirementStatus::Warning
1971        },
1972        summary: summary.to_string(),
1973        details: Some(details),
1974        remediation: vec![remediation.to_string()],
1975        can_fix_interactively: !matched,
1976    }
1977}
1978
1979#[cfg(test)]
1980#[path = "publish_workflow_tests.rs"]
1981mod publish_workflow_tests;