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;