1use anyhow::{bail, Context, Result};
2use clap::ValueEnum;
3use fission_command_core::{DistributionProvider, FissionProject, Target};
4use fission_credentials as credentials;
5use serde::{Deserialize, Serialize};
6use sha2::{Digest, Sha256};
7use std::collections::BTreeMap;
8use std::env;
9use std::ffi::OsStr;
10use std::fs;
11use std::io::{self, Read, Write};
12use std::path::{Path, PathBuf};
13use std::process::{Command, Stdio};
14use std::time::{SystemTime, UNIX_EPOCH};
15
16mod docker_registry;
17mod files;
18mod github_releases;
19mod package;
20mod static_hosts;
21mod stores;
22
23const ARTIFACT_MANIFEST: &str = "artifact-manifest.json";
24
25#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
26pub enum PackageFormat {
27 Aab,
28 Apk,
29 App,
30 #[value(name = "docker-image")]
31 DockerImage,
32 Exe,
33 Ipa,
34 Msi,
35 Msix,
36 Pkg,
37 Run,
38 Static,
39}
40
41impl PackageFormat {
42 pub fn as_str(self) -> &'static str {
43 match self {
44 Self::Aab => "aab",
45 Self::Apk => "apk",
46 Self::App => "app",
47 Self::DockerImage => "docker-image",
48 Self::Exe => "exe",
49 Self::Ipa => "ipa",
50 Self::Msi => "msi",
51 Self::Msix => "msix",
52 Self::Pkg => "pkg",
53 Self::Run => "run",
54 Self::Static => "static",
55 }
56 }
57}
58
59#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
60pub enum DistributeAction {
61 Setup,
62 Publish,
63 Status,
64 Promote,
65 Rollback,
66}
67
68#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
69pub enum ReadinessKind {
70 Package,
71 Distribute,
72 Release,
73}
74
75#[derive(Clone, Debug)]
76pub struct PackageOptions {
77 pub project_dir: PathBuf,
78 pub target: Target,
79 pub format: PackageFormat,
80 pub release: bool,
81 pub json: bool,
82}
83
84#[derive(Clone, Debug)]
85pub struct DistributeOptions {
86 pub project_dir: PathBuf,
87 pub provider: DistributionProvider,
88 pub action: DistributeAction,
89 pub artifact: Option<PathBuf>,
90 pub site: String,
91 pub deploy: Option<String>,
92 pub track: Option<String>,
93 pub dry_run: bool,
94 pub yes: bool,
95 pub json: bool,
96}
97
98#[derive(Clone, Debug)]
99pub struct ReadinessOptions {
100 pub project_dir: PathBuf,
101 pub kind: ReadinessKind,
102 pub target: Option<Target>,
103 pub format: Option<PackageFormat>,
104 pub provider: Option<DistributionProvider>,
105 pub artifact: Option<PathBuf>,
106 pub site: String,
107 pub track: Option<String>,
108 pub json: bool,
109}
110
111#[derive(Debug, Serialize, Deserialize)]
112struct ArtifactManifest {
113 schema_version: u32,
114 created_at_unix_seconds: u64,
115 project: ArtifactProject,
116 target: String,
117 format: String,
118 profile: String,
119 root_dir: String,
120 artifacts: Vec<ArtifactFile>,
121 validation: ArtifactValidation,
122}
123
124#[derive(Debug, Serialize, Deserialize)]
125struct ArtifactProject {
126 app_id: String,
127 name: String,
128 version: Option<String>,
129}
130
131#[derive(Debug, Serialize, Deserialize)]
132struct ArtifactFile {
133 kind: String,
134 purpose: Option<String>,
135 platform: Option<String>,
136 upload_provider: Option<String>,
137 path: String,
138 relative_path: String,
139 sha256: String,
140 size_bytes: u64,
141 mime_type: String,
142}
143
144#[derive(Debug, Serialize, Deserialize)]
145struct ArtifactValidation {
146 state: String,
147 checks: Vec<ReadinessCheck>,
148}
149
150#[derive(Clone, Debug, Serialize, Deserialize)]
151struct ReadinessCheck {
152 id: String,
153 severity: CheckSeverity,
154 status: CheckStatus,
155 summary: String,
156 details: Option<String>,
157 remediation: Vec<String>,
158}
159
160#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
161#[serde(rename_all = "kebab-case")]
162enum CheckSeverity {
163 Error,
164 Warning,
165 Info,
166}
167
168#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
169#[serde(rename_all = "kebab-case")]
170enum CheckStatus {
171 Passed,
172 Missing,
173 Failed,
174 Warning,
175 Skipped,
176}
177
178#[derive(Debug, Serialize)]
179struct ReadinessReport {
180 project_dir: String,
181 target: Option<String>,
182 format: Option<String>,
183 provider: Option<String>,
184 site: Option<String>,
185 status: String,
186 checks: Vec<ReadinessCheck>,
187}
188
189#[derive(Debug, Serialize)]
190struct DistributionReceipt {
191 schema_version: u32,
192 created_at_unix_seconds: u64,
193 provider: String,
194 site: String,
195 action: String,
196 artifact_manifest: Option<String>,
197 deployment_id: Option<String>,
198 canonical_url: Option<String>,
199 preview_url: Option<String>,
200 custom_domain: Option<String>,
201 status: String,
202 stdout: Option<String>,
203 stderr: Option<String>,
204 manual_follow_up: Vec<String>,
205}
206
207#[derive(Debug, Deserialize, Default)]
208struct PublishManifest {
209 site: Option<SiteManifest>,
210 distribution: Option<DistributionManifest>,
211}
212
213#[derive(Debug, Deserialize, Default)]
214struct SiteManifest {
215 entry: Option<String>,
216 out_dir: Option<String>,
217}
218
219#[derive(Debug, Deserialize, Default)]
220struct DistributionManifest {
221 #[serde(default)]
222 s3: BTreeMap<String, S3Config>,
223 #[serde(default)]
224 google_drive: BTreeMap<String, GoogleDriveConfig>,
225 #[serde(default)]
226 onedrive: BTreeMap<String, OneDriveConfig>,
227 #[serde(default)]
228 dropbox: BTreeMap<String, DropboxConfig>,
229 play_store: Option<PlayStoreConfig>,
230 app_store: Option<AppStoreConfig>,
231 microsoft_store: Option<MicrosoftStoreConfig>,
232 #[serde(default)]
233 github_pages: BTreeMap<String, GithubPagesConfig>,
234 #[serde(default)]
235 github_releases: BTreeMap<String, GithubReleasesConfig>,
236 #[serde(default)]
237 cloudflare_pages: BTreeMap<String, CloudflarePagesConfig>,
238 #[serde(default)]
239 docker_registry: BTreeMap<String, DockerRegistryConfig>,
240 #[serde(default)]
241 netlify: BTreeMap<String, NetlifyConfig>,
242}
243
244#[derive(Clone, Debug, Deserialize, Default)]
245struct S3Config {
246 endpoint: Option<String>,
247 region: Option<String>,
248 bucket: Option<String>,
249 prefix: Option<String>,
250 profile: Option<String>,
251 path_style: Option<bool>,
252 visibility: Option<String>,
253 presign_ttl_seconds: Option<u64>,
254}
255
256#[derive(Clone, Debug, Deserialize, Default)]
257struct GoogleDriveConfig {
258 folder_id: Option<String>,
259 name_prefix: Option<String>,
260 share: Option<bool>,
261}
262
263#[derive(Clone, Debug, Deserialize, Default)]
264struct OneDriveConfig {
265 root: Option<String>,
266 path_prefix: Option<String>,
267 conflict_behavior: Option<String>,
268}
269
270#[derive(Clone, Debug, Deserialize, Default)]
271struct DropboxConfig {
272 path_prefix: Option<String>,
273 mode: Option<String>,
274 autorename: Option<bool>,
275}
276
277#[derive(Clone, Debug, Deserialize, Default)]
278struct PlayStoreConfig {
279 package_name: Option<String>,
280 default_track: Option<String>,
281 service_account: Option<String>,
282 release_status: Option<String>,
283}
284
285#[derive(Clone, Debug, Deserialize, Default)]
286struct AppStoreConfig {
287 app_id: Option<String>,
288 bundle_id: Option<String>,
289 issuer_id: Option<String>,
290 key_id: Option<String>,
291 api_key_path: Option<String>,
292 default_track: Option<String>,
293}
294
295#[derive(Clone, Debug, Deserialize, Default)]
296struct MicrosoftStoreConfig {
297 product_id: Option<String>,
298 package_identity_name: Option<String>,
299 tenant_id: Option<String>,
300 client_id: Option<String>,
301 seller_id: Option<String>,
302 package_url: Option<String>,
303 package_type: Option<String>,
304 flight_id: Option<String>,
305 package_rollout_percentage: Option<u8>,
306 msstore_project: Option<String>,
307 msstore_reconfigure: Option<bool>,
308 languages: Option<Vec<String>>,
309 architectures: Option<Vec<String>>,
310 is_silent_install: Option<bool>,
311 installer_parameters: Option<String>,
312 generic_doc_url: Option<String>,
313 submit: Option<bool>,
314}
315
316#[derive(Clone, Debug, Deserialize, Default)]
317struct GithubPagesConfig {
318 owner: Option<String>,
319 repo: Option<String>,
320 mode: Option<String>,
321 source: Option<String>,
322 source_branch: Option<String>,
323 source_path: Option<String>,
324 site_kind: Option<String>,
325 base_path: Option<String>,
326 custom_domain: Option<String>,
327 enforce_https: Option<bool>,
328 remote: Option<String>,
329 production_branch: Option<String>,
330 workflow: Option<String>,
331}
332
333#[derive(Clone, Debug, Deserialize, Default)]
334struct GithubReleasesConfig {
335 owner: Option<String>,
336 repo: Option<String>,
337 tag: Option<String>,
338 name: Option<String>,
339 target_commitish: Option<String>,
340 notes: Option<String>,
341 notes_file: Option<String>,
342 draft: Option<bool>,
343 prerelease: Option<bool>,
344 make_latest: Option<String>,
345 replace_assets: Option<bool>,
346 upload_artifact_manifest: Option<bool>,
347}
348
349#[derive(Clone, Debug, Deserialize, Default)]
350struct CloudflarePagesConfig {
351 account_id: Option<String>,
352 project_name: Option<String>,
353 environment: Option<String>,
354 custom_domain: Option<String>,
355 base_path: Option<String>,
356}
357
358#[derive(Clone, Debug, Deserialize, Default)]
359struct NetlifyConfig {
360 site_id: Option<String>,
361 team_slug: Option<String>,
362 production: Option<bool>,
363 custom_domain: Option<String>,
364 base_path: Option<String>,
365}
366
367#[derive(Clone, Debug, Deserialize, Default)]
368struct DockerRegistryConfig {
369 tags: Option<Vec<String>>,
370}
371
372pub fn package(options: PackageOptions) -> Result<()> {
373 let manifest = package::package_artifact(&options)?;
374 if options.json {
375 println!("{}", serde_json::to_string_pretty(&manifest)?);
376 } else {
377 println!(
378 "Packaged {} {} artifact into {}",
379 manifest.target, manifest.format, manifest.root_dir
380 );
381 println!("{} files", manifest.artifacts.len());
382 println!(
383 "{}",
384 Path::new(&manifest.root_dir)
385 .join(ARTIFACT_MANIFEST)
386 .display()
387 );
388 }
389 Ok(())
390}
391
392pub fn distribute(options: DistributeOptions) -> Result<()> {
393 let config = load_publish_manifest(&options.project_dir)?;
394 match options.action {
395 DistributeAction::Setup => setup_provider(&options, &config),
396 DistributeAction::Status => provider_status(&options, &config),
397 DistributeAction::Promote | DistributeAction::Rollback => {
398 provider_lifecycle(&options, &config)
399 }
400 DistributeAction::Publish => publish_artifact(&options, &config),
401 }
402}
403
404pub fn readiness(options: ReadinessOptions) -> Result<()> {
405 let checks = match options.kind {
406 ReadinessKind::Package => {
407 readiness_package(&options.project_dir, options.target, options.format)
408 }
409 ReadinessKind::Release => {
410 let config = load_publish_manifest(&options.project_dir)?;
411 let mut checks =
412 readiness_package(&options.project_dir, options.target, options.format)?;
413 let provider = options
414 .provider
415 .context("readiness release requires --provider")?;
416 checks.extend(readiness_distribute(
417 &options.project_dir,
418 provider,
419 &options.site,
420 options.track.as_deref(),
421 options.artifact.as_deref(),
422 &config,
423 )?);
424 Ok(checks)
425 }
426 ReadinessKind::Distribute => {
427 let config = load_publish_manifest(&options.project_dir)?;
428 let provider = options
429 .provider
430 .context("readiness distribute requires --provider")?;
431 let artifact = options.artifact.as_deref();
432 readiness_distribute(
433 &options.project_dir,
434 provider,
435 &options.site,
436 options.track.as_deref(),
437 artifact,
438 &config,
439 )
440 }
441 }?;
442 let report = ReadinessReport {
443 project_dir: options.project_dir.display().to_string(),
444 target: options.target.map(|target| target.as_str().to_string()),
445 format: options.format.map(|format| format.as_str().to_string()),
446 provider: options
447 .provider
448 .map(|provider| provider.as_str().to_string()),
449 site: matches!(
450 options.kind,
451 ReadinessKind::Distribute | ReadinessKind::Release
452 )
453 .then(|| options.site.clone()),
454 status: report_status(&checks).to_string(),
455 checks,
456 };
457 if options.json {
458 println!("{}", serde_json::to_string_pretty(&report)?);
459 } else {
460 print_readiness_report(&report);
461 if report.status == "blocked" {
462 bail!("readiness checks failed");
463 }
464 }
465 Ok(())
466}
467
468fn setup_provider(options: &DistributeOptions, config: &PublishManifest) -> Result<()> {
469 match options.provider {
470 DistributionProvider::GithubPages => setup_github_pages(options, config),
471 DistributionProvider::GithubReleases => github_releases::setup(options, config),
472 DistributionProvider::DockerRegistry => setup_non_static_provider(options, config),
473 DistributionProvider::CloudflarePages => {
474 let cfg = cloudflare_config(config, &options.site)?;
475 println!("Cloudflare Pages setup checks for `{}`", options.site);
476 println!(
477 "account_id: {}",
478 cfg.account_id.as_deref().unwrap_or("<missing>")
479 );
480 println!(
481 "project_name: {}",
482 cfg.project_name.as_deref().unwrap_or("<missing>")
483 );
484 println!("Run `fission readiness distribute --provider cloudflare-pages --site {} --project-dir {}` before publishing.", options.site, options.project_dir.display());
485 Ok(())
486 }
487 DistributionProvider::Netlify => {
488 let cfg = netlify_config(config, &options.site)?;
489 println!("Netlify setup checks for `{}`", options.site);
490 println!("site_id: {}", cfg.site_id.as_deref().unwrap_or("<missing>"));
491 println!(
492 "team_slug: {}",
493 cfg.team_slug.as_deref().unwrap_or("<missing>")
494 );
495 println!("Run `fission readiness distribute --provider netlify --site {} --project-dir {}` before publishing.", options.site, options.project_dir.display());
496 Ok(())
497 }
498 DistributionProvider::S3
499 | DistributionProvider::GoogleDrive
500 | DistributionProvider::OneDrive
501 | DistributionProvider::Dropbox
502 | DistributionProvider::PlayStore
503 | DistributionProvider::AppStore
504 | DistributionProvider::MicrosoftStore => setup_non_static_provider(options, config),
505 }
506}
507
508fn publish_artifact(options: &DistributeOptions, config: &PublishManifest) -> Result<()> {
509 let artifact_path = options
510 .artifact
511 .as_deref()
512 .map(PathBuf::from)
513 .unwrap_or_else(|| {
514 if options.provider == DistributionProvider::DockerRegistry {
515 default_artifact_manifest_path_for_format(
516 &options.project_dir,
517 Target::Server,
518 PackageFormat::DockerImage,
519 true,
520 )
521 } else {
522 default_artifact_manifest_path(&options.project_dir, Target::Site, true)
523 }
524 });
525 let manifest = read_artifact_manifest(&artifact_path)?;
526 let checks = readiness_distribute(
527 &options.project_dir,
528 options.provider,
529 &options.site,
530 options.track.as_deref(),
531 Some(&artifact_path),
532 config,
533 )?;
534 let errors = checks
535 .iter()
536 .filter(|check| {
537 check.severity == CheckSeverity::Error && check.status != CheckStatus::Passed
538 })
539 .collect::<Vec<_>>();
540 if !errors.is_empty() {
541 print_checks(&checks);
542 bail!("distribution readiness failed");
543 }
544
545 let receipt = match options.provider {
546 DistributionProvider::GithubPages => {
547 publish_github_pages(options, config, &artifact_path, &manifest)?
548 }
549 DistributionProvider::GithubReleases => {
550 github_releases::publish(options, config, &artifact_path, &manifest)?
551 }
552 DistributionProvider::DockerRegistry => {
553 docker_registry::publish(options, config, &artifact_path, &manifest)?
554 }
555 DistributionProvider::CloudflarePages => {
556 publish_cloudflare_pages(options, config, &artifact_path, &manifest)?
557 }
558 DistributionProvider::Netlify => {
559 static_hosts::publish_netlify(options, config, &artifact_path, &manifest)?
560 }
561 DistributionProvider::S3 => files::publish_s3(options, config, &artifact_path, &manifest)?,
562 DistributionProvider::GoogleDrive => {
563 files::publish_google_drive(options, config, &artifact_path, &manifest)?
564 }
565 DistributionProvider::OneDrive => {
566 files::publish_onedrive(options, config, &artifact_path, &manifest)?
567 }
568 DistributionProvider::Dropbox => {
569 files::publish_dropbox(options, config, &artifact_path, &manifest)?
570 }
571 DistributionProvider::PlayStore => {
572 stores::publish_play_store(options, config, &artifact_path, &manifest)?
573 }
574 DistributionProvider::AppStore => {
575 stores::publish_app_store(options, config, &artifact_path, &manifest)?
576 }
577 DistributionProvider::MicrosoftStore => {
578 stores::publish_microsoft_store(options, config, &artifact_path, &manifest)?
579 }
580 };
581 write_receipt(&options.project_dir, &receipt)?;
582 print_distribution_receipt(options, &receipt)
583}
584
585fn print_distribution_receipt(
586 options: &DistributeOptions,
587 receipt: &DistributionReceipt,
588) -> Result<()> {
589 if options.json {
590 println!("{}", serde_json::to_string_pretty(&receipt)?);
591 } else {
592 println!(
593 "{} {} status: {}",
594 receipt.provider, receipt.action, receipt.status
595 );
596 if let Some(url) = &receipt.canonical_url {
597 println!("URL: {url}");
598 }
599 for item in &receipt.manual_follow_up {
600 println!("Follow-up: {item}");
601 }
602 }
603 Ok(())
604}
605
606fn provider_status(options: &DistributeOptions, config: &PublishManifest) -> Result<()> {
607 let receipt = match options.provider {
608 DistributionProvider::GithubPages => github_pages_status(options, config)?,
609 DistributionProvider::GithubReleases => github_releases::status(options, config)?,
610 DistributionProvider::DockerRegistry => docker_registry::status(options, config)?,
611 DistributionProvider::CloudflarePages => cloudflare_pages_status(options, config)?,
612 DistributionProvider::Netlify => static_hosts::netlify_status(options, config)?,
613 DistributionProvider::PlayStore => stores::play_store_status(options, config)?,
614 DistributionProvider::S3 => files::s3_status(options, config)?,
615 DistributionProvider::GoogleDrive => files::google_drive_status(options, config)?,
616 DistributionProvider::OneDrive => files::onedrive_status(options, config)?,
617 DistributionProvider::Dropbox => files::dropbox_status(options, config)?,
618 DistributionProvider::AppStore => stores::app_store_status(options, config)?,
619 DistributionProvider::MicrosoftStore => stores::microsoft_store_status(options, config)?,
620 };
621 if options.json {
622 println!("{}", serde_json::to_string_pretty(&receipt)?);
623 } else {
624 println!("{} status: {}", receipt.provider, receipt.status);
625 if let Some(stdout) = &receipt.stdout {
626 print!("{stdout}");
627 }
628 }
629 Ok(())
630}
631
632fn provider_lifecycle(options: &DistributeOptions, config: &PublishManifest) -> Result<()> {
633 let receipt = match options.provider {
634 DistributionProvider::Netlify => static_hosts::netlify_lifecycle(options, config)?,
635 DistributionProvider::CloudflarePages => cloudflare_pages_lifecycle(options, config)?,
636 _ => bail!(
637 "{} currently supports setup, publish, and status; {} is not exposed by this provider backend",
638 options.provider.as_str(),
639 match options.action {
640 DistributeAction::Promote => "promote",
641 DistributeAction::Rollback => "rollback",
642 _ => "this operation",
643 }
644 ),
645 };
646 write_receipt(&options.project_dir, &receipt)?;
647 print_distribution_receipt(options, &receipt)
648}
649
650fn cloudflare_pages_lifecycle(
651 options: &DistributeOptions,
652 config: &PublishManifest,
653) -> Result<DistributionReceipt> {
654 let cfg = cloudflare_config(config, &options.site)?;
655 let account_id = cfg
656 .account_id
657 .clone()
658 .or_else(|| env::var("CLOUDFLARE_ACCOUNT_ID").ok())
659 .context(
660 "distribution.cloudflare_pages.<site>.account_id or CLOUDFLARE_ACCOUNT_ID is required",
661 )?;
662 let project_name = cfg
663 .project_name
664 .as_deref()
665 .context("distribution.cloudflare_pages.<site>.project_name is required")?;
666 let deploy = options
667 .deploy
668 .as_deref()
669 .context("cloudflare-pages promote/rollback requires --deploy <deployment-id>")?;
670 if options.dry_run {
671 return Ok(DistributionReceipt {
672 schema_version: 1,
673 created_at_unix_seconds: now_unix_seconds(),
674 provider: "cloudflare-pages".to_string(),
675 site: options.site.clone(),
676 action: match options.action {
677 DistributeAction::Promote => "promote",
678 DistributeAction::Rollback => "rollback",
679 _ => "lifecycle",
680 }
681 .to_string(),
682 artifact_manifest: None,
683 deployment_id: Some(deploy.to_string()),
684 canonical_url: cloudflare_url(&cfg),
685 preview_url: None,
686 custom_domain: non_empty(cfg.custom_domain.clone()),
687 status: "dry-run".to_string(),
688 stdout: None,
689 stderr: None,
690 manual_follow_up: vec![format!(
691 "Would make Cloudflare Pages deployment {deploy} live by calling the provider rollback endpoint."
692 )],
693 });
694 }
695 let token = credentials::provider_secret(
696 DistributionProvider::CloudflarePages,
697 &["CLOUDFLARE_API_TOKEN"],
698 )?
699 .context("CLOUDFLARE_API_TOKEN or Fission vault credentials are required")?;
700 let url = format!(
701 "https://api.cloudflare.com/client/v4/accounts/{account_id}/pages/projects/{project_name}/deployments/{deploy}/rollback"
702 );
703 let response = reqwest::blocking::Client::builder()
704 .user_agent("cargo-fission-publish/0.1")
705 .build()?
706 .post(url)
707 .bearer_auth(token)
708 .send()
709 .context("failed to rollback Cloudflare Pages deployment")?;
710 let status = response.status();
711 let text = response.text()?;
712 if !status.is_success() {
713 bail!("Cloudflare Pages rollback failed with {status}: {text}");
714 }
715 let value: serde_json::Value = serde_json::from_str(&text)
716 .with_context(|| format!("failed to parse Cloudflare Pages rollback response: {text}"))?;
717 let result = value.get("result").unwrap_or(&value);
718 Ok(DistributionReceipt {
719 schema_version: 1,
720 created_at_unix_seconds: now_unix_seconds(),
721 provider: "cloudflare-pages".to_string(),
722 site: options.site.clone(),
723 action: match options.action {
724 DistributeAction::Promote => "promote",
725 DistributeAction::Rollback => "rollback",
726 _ => "lifecycle",
727 }
728 .to_string(),
729 artifact_manifest: None,
730 deployment_id: result
731 .get("id")
732 .and_then(serde_json::Value::as_str)
733 .map(str::to_string)
734 .or_else(|| Some(deploy.to_string())),
735 canonical_url: cloudflare_url(&cfg),
736 preview_url: result
737 .get("url")
738 .and_then(serde_json::Value::as_str)
739 .map(str::to_string),
740 custom_domain: non_empty(cfg.custom_domain.clone()),
741 status: result
742 .pointer("/latest_stage/status")
743 .or_else(|| result.get("status"))
744 .and_then(serde_json::Value::as_str)
745 .unwrap_or("rollback-requested")
746 .to_string(),
747 stdout: Some(serde_json::to_string_pretty(&value)?),
748 stderr: None,
749 manual_follow_up: Vec::new(),
750 })
751}
752
753fn setup_non_static_provider(options: &DistributeOptions, config: &PublishManifest) -> Result<()> {
754 let checks = readiness_distribute(
755 &options.project_dir,
756 options.provider,
757 &options.site,
758 options.track.as_deref(),
759 options.artifact.as_deref(),
760 config,
761 )?;
762 if options.json {
763 let report = ReadinessReport {
764 project_dir: options.project_dir.display().to_string(),
765 target: None,
766 format: None,
767 provider: Some(options.provider.as_str().to_string()),
768 site: Some(options.site.clone()),
769 status: report_status(&checks).to_string(),
770 checks,
771 };
772 println!("{}", serde_json::to_string_pretty(&report)?);
773 } else {
774 println!(
775 "{} setup checks for `{}`",
776 options.provider.as_str(),
777 options.site
778 );
779 print_checks(&checks);
780 }
781 Ok(())
782}
783
784fn setup_github_pages(options: &DistributeOptions, config: &PublishManifest) -> Result<()> {
785 let cfg = github_config(config, &options.site)?;
786 let workflow = cfg
787 .workflow
788 .clone()
789 .unwrap_or_else(|| "fission-pages.yml".to_string());
790 let workflow_path = github_workflow_path(&options.project_dir, &cfg, &workflow);
791 let content = render_github_pages_workflow(&options.project_dir, &cfg);
792 if options.dry_run {
793 println!("Would write {}:\n{}", workflow_path.display(), content);
794 return Ok(());
795 }
796 if workflow_path.exists() && !options.yes {
797 bail!(
798 "{} already exists; pass --yes to overwrite or edit it manually",
799 workflow_path.display()
800 );
801 }
802 if let Some(parent) = workflow_path.parent() {
803 fs::create_dir_all(parent)?;
804 }
805 fs::write(&workflow_path, content)
806 .with_context(|| format!("failed to write {}", workflow_path.display()))?;
807 println!("Wrote {}", workflow_path.display());
808 if cfg
809 .custom_domain
810 .as_deref()
811 .filter(|s| !s.is_empty())
812 .is_some()
813 {
814 println!("Custom domains for GitHub Actions Pages must be configured in repository Pages settings or via the GitHub Pages API.");
815 }
816 Ok(())
817}
818
819fn publish_github_pages(
820 options: &DistributeOptions,
821 config: &PublishManifest,
822 artifact_path: &Path,
823 manifest: &ArtifactManifest,
824) -> Result<DistributionReceipt> {
825 let cfg = github_config(config, &options.site)?;
826 let mode = cfg.mode.as_deref().unwrap_or("actions");
827 match mode {
828 "branch" => publish_github_pages_branch(options, &cfg, artifact_path, manifest),
829 "actions" => {
830 let owner = cfg
831 .owner
832 .clone()
833 .or_else(|| infer_github_owner(&options.project_dir));
834 let repo = cfg
835 .repo
836 .clone()
837 .or_else(|| infer_github_repo(&options.project_dir));
838 let workflow = cfg
839 .workflow
840 .clone()
841 .unwrap_or_else(|| "fission-pages.yml".to_string());
842 let mut follow_up = vec![format!(
843 "Commit the generated workflow and push the configured production branch so GitHub Actions deploys the exact static site build."
844 )];
845 if let (Some(owner), Some(repo)) = (&owner, &repo) {
846 follow_up.push(format!(
847 "Repository Pages URL should be https://{owner}.github.io/{repo}/ unless a custom domain is configured."
848 ));
849 }
850 let workflow_path = github_workflow_path(&options.project_dir, &cfg, &workflow);
851 if !workflow_path.exists() {
852 follow_up.push(format!(
853 "Run `fission distribute setup --provider github-pages --site {} --project-dir {}` to generate {}.",
854 options.site,
855 options.project_dir.display(),
856 workflow_path.display()
857 ));
858 }
859 Ok(DistributionReceipt {
860 schema_version: 1,
861 created_at_unix_seconds: now_unix_seconds(),
862 provider: "github-pages".to_string(),
863 site: options.site.clone(),
864 action: "publish".to_string(),
865 artifact_manifest: Some(artifact_path.display().to_string()),
866 deployment_id: None,
867 canonical_url: github_pages_url(&cfg, owner.as_deref(), repo.as_deref()),
868 preview_url: None,
869 custom_domain: non_empty(cfg.custom_domain.clone()),
870 status: "workflow-required".to_string(),
871 stdout: None,
872 stderr: None,
873 manual_follow_up: follow_up,
874 })
875 }
876 other => bail!("unsupported github-pages mode `{other}`; expected actions or branch"),
877 }
878}
879
880fn publish_github_pages_branch(
881 options: &DistributeOptions,
882 cfg: &GithubPagesConfig,
883 artifact_path: &Path,
884 manifest: &ArtifactManifest,
885) -> Result<DistributionReceipt> {
886 let remote = cfg.remote.as_deref().unwrap_or("origin");
887 let branch = cfg.source_branch.as_deref().unwrap_or("gh-pages");
888 let source_path = cfg.source_path.as_deref().unwrap_or("/");
889 let repo_root = git_output(&options.project_dir, ["rev-parse", "--show-toplevel"])?;
890 let repo_root = PathBuf::from(repo_root.trim());
891 let remote_url = git_output(&repo_root, ["remote", "get-url", remote])?;
892 let worktree = options
893 .project_dir
894 .join("target/fission/publish/github-pages")
895 .join(&options.site);
896 if options.dry_run {
897 println!(
898 "Would publish {} to {remote}:{branch} at {}",
899 manifest.root_dir, source_path
900 );
901 return Ok(DistributionReceipt {
902 schema_version: 1,
903 created_at_unix_seconds: now_unix_seconds(),
904 provider: "github-pages".to_string(),
905 site: options.site.clone(),
906 action: "publish".to_string(),
907 artifact_manifest: Some(artifact_path.display().to_string()),
908 deployment_id: Some(format!("{remote}:{branch}")),
909 canonical_url: github_pages_url(cfg, cfg.owner.as_deref(), cfg.repo.as_deref()),
910 preview_url: None,
911 custom_domain: non_empty(cfg.custom_domain.clone()),
912 status: "dry-run".to_string(),
913 stdout: None,
914 stderr: None,
915 manual_follow_up: Vec::new(),
916 });
917 }
918 if worktree.exists() {
919 fs::remove_dir_all(&worktree)
920 .with_context(|| format!("failed to clean {}", worktree.display()))?;
921 }
922 if let Some(parent) = worktree.parent() {
923 fs::create_dir_all(parent)?;
924 }
925
926 let clone_status = Command::new("git")
927 .args([
928 "clone",
929 "--depth",
930 "1",
931 "--branch",
932 branch,
933 remote_url.trim(),
934 ])
935 .arg(&worktree)
936 .status()
937 .context("failed to run git clone for GitHub Pages branch")?;
938 if !clone_status.success() {
939 let status = Command::new("git")
940 .args(["clone", "--depth", "1", remote_url.trim()])
941 .arg(&worktree)
942 .status()
943 .context("failed to run git clone for GitHub Pages repository")?;
944 if !status.success() {
945 bail!("failed to clone {remote} for GitHub Pages publishing");
946 }
947 run_git(&worktree, ["checkout", "--orphan", branch])?;
948 }
949
950 let publish_root = if source_path == "/" || source_path == "." {
951 worktree.clone()
952 } else {
953 worktree.join(source_path.trim_start_matches('/'))
954 };
955 clean_publish_root(&publish_root)?;
956 copy_dir_contents(Path::new(&manifest.root_dir), &publish_root)?;
957 fs::write(publish_root.join(".nojekyll"), "")?;
958 if let Some(domain) = non_empty(cfg.custom_domain.clone()) {
959 fs::write(publish_root.join("CNAME"), format!("{}\n", domain.trim()))?;
960 }
961
962 run_git(&worktree, ["add", "--all"])?;
963 let commit = Command::new("git")
964 .args(["commit", "-m", "Publish Fission static site"])
965 .current_dir(&worktree)
966 .output()
967 .context("failed to run git commit for GitHub Pages")?;
968 if !commit.status.success() {
969 let stderr = String::from_utf8_lossy(&commit.stderr);
970 if !stderr.contains("nothing to commit") && !stderr.contains("no changes added") {
971 io::stderr().write_all(&commit.stderr).ok();
972 bail!("git commit failed for GitHub Pages publish");
973 }
974 }
975 run_git(&worktree, ["push", remote, branch])?;
976 Ok(DistributionReceipt {
977 schema_version: 1,
978 created_at_unix_seconds: now_unix_seconds(),
979 provider: "github-pages".to_string(),
980 site: options.site.clone(),
981 action: "publish".to_string(),
982 artifact_manifest: Some(artifact_path.display().to_string()),
983 deployment_id: Some(format!("{remote}:{branch}")),
984 canonical_url: github_pages_url(cfg, cfg.owner.as_deref(), cfg.repo.as_deref()),
985 preview_url: None,
986 custom_domain: non_empty(cfg.custom_domain.clone()),
987 status: "published".to_string(),
988 stdout: None,
989 stderr: None,
990 manual_follow_up: github_pages_follow_up(cfg),
991 })
992}
993
994fn publish_cloudflare_pages(
995 options: &DistributeOptions,
996 config: &PublishManifest,
997 artifact_path: &Path,
998 manifest: &ArtifactManifest,
999) -> Result<DistributionReceipt> {
1000 let cfg = cloudflare_config(config, &options.site)?;
1001 let project_name = cfg
1002 .project_name
1003 .as_deref()
1004 .context("distribution.cloudflare_pages.<site>.project_name is required")?;
1005 let mut args = vec![
1006 "pages".to_string(),
1007 "deploy".to_string(),
1008 manifest.root_dir.clone(),
1009 "--project-name".to_string(),
1010 project_name.to_string(),
1011 ];
1012 if let Some(environment) = cfg
1013 .environment
1014 .as_deref()
1015 .filter(|value| *value != "production")
1016 {
1017 args.push("--branch".to_string());
1018 args.push(environment.to_string());
1019 }
1020 run_publish_command(
1021 options,
1022 "cloudflare-pages",
1023 "wrangler",
1024 args,
1025 artifact_path,
1026 || cloudflare_url(&cfg),
1027 )
1028}
1029
1030fn run_publish_command<F>(
1031 options: &DistributeOptions,
1032 provider: &str,
1033 program: &str,
1034 args: Vec<String>,
1035 artifact_path: &Path,
1036 canonical_url: F,
1037) -> Result<DistributionReceipt>
1038where
1039 F: FnOnce() -> Option<String>,
1040{
1041 if options.dry_run {
1042 println!("Would run: {} {}", program, args.join(" "));
1043 return Ok(DistributionReceipt {
1044 schema_version: 1,
1045 created_at_unix_seconds: now_unix_seconds(),
1046 provider: provider.to_string(),
1047 site: options.site.clone(),
1048 action: "publish".to_string(),
1049 artifact_manifest: Some(artifact_path.display().to_string()),
1050 deployment_id: None,
1051 canonical_url: canonical_url(),
1052 preview_url: None,
1053 custom_domain: None,
1054 status: "dry-run".to_string(),
1055 stdout: None,
1056 stderr: None,
1057 manual_follow_up: Vec::new(),
1058 });
1059 }
1060 let output = Command::new(program)
1061 .args(&args)
1062 .stdout(Stdio::piped())
1063 .stderr(Stdio::piped())
1064 .output()
1065 .with_context(|| {
1066 format!("failed to run {program}; install it or run readiness for remediation")
1067 })?;
1068 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
1069 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
1070 if !output.status.success() {
1071 eprint!("{stderr}");
1072 bail!("{provider} publish failed with {}", output.status);
1073 }
1074 Ok(DistributionReceipt {
1075 schema_version: 1,
1076 created_at_unix_seconds: now_unix_seconds(),
1077 provider: provider.to_string(),
1078 site: options.site.clone(),
1079 action: "publish".to_string(),
1080 artifact_manifest: Some(artifact_path.display().to_string()),
1081 deployment_id: None,
1082 canonical_url: canonical_url(),
1083 preview_url: first_url(&stdout),
1084 custom_domain: None,
1085 status: "published".to_string(),
1086 stdout: Some(stdout),
1087 stderr: (!stderr.trim().is_empty()).then_some(stderr),
1088 manual_follow_up: Vec::new(),
1089 })
1090}
1091
1092fn github_pages_status(
1093 options: &DistributeOptions,
1094 config: &PublishManifest,
1095) -> Result<DistributionReceipt> {
1096 let cfg = github_config(config, &options.site)?;
1097 let owner = cfg
1098 .owner
1099 .clone()
1100 .or_else(|| infer_github_owner(&options.project_dir));
1101 let repo = cfg
1102 .repo
1103 .clone()
1104 .or_else(|| infer_github_repo(&options.project_dir));
1105 let (Some(owner), Some(repo)) = (owner, repo) else {
1106 bail!("github-pages status requires owner and repo in fission.toml or a GitHub remote");
1107 };
1108 command_status_receipt(
1109 options,
1110 "github-pages",
1111 "gh",
1112 vec!["api".to_string(), format!("repos/{owner}/{repo}/pages")],
1113 )
1114}
1115
1116fn cloudflare_pages_status(
1117 options: &DistributeOptions,
1118 config: &PublishManifest,
1119) -> Result<DistributionReceipt> {
1120 let cfg = cloudflare_config(config, &options.site)?;
1121 let account_id = cfg
1122 .account_id
1123 .clone()
1124 .or_else(|| env::var("CLOUDFLARE_ACCOUNT_ID").ok())
1125 .context(
1126 "distribution.cloudflare_pages.<site>.account_id or CLOUDFLARE_ACCOUNT_ID is required",
1127 )?;
1128 let project_name = cfg
1129 .project_name
1130 .as_deref()
1131 .context("distribution.cloudflare_pages.<site>.project_name is required")?;
1132 let token = credentials::provider_secret(
1133 DistributionProvider::CloudflarePages,
1134 &["CLOUDFLARE_API_TOKEN"],
1135 )?
1136 .context("CLOUDFLARE_API_TOKEN or Fission vault credentials are required")?;
1137 let url = format!(
1138 "https://api.cloudflare.com/client/v4/accounts/{account_id}/pages/projects/{project_name}/deployments"
1139 );
1140 let response = reqwest::blocking::Client::builder()
1141 .user_agent("cargo-fission-publish/0.1")
1142 .build()?
1143 .get(url)
1144 .bearer_auth(token)
1145 .send()
1146 .context("failed to query Cloudflare Pages deployments")?;
1147 let status = response.status();
1148 let text = response.text()?;
1149 if !status.is_success() {
1150 bail!("Cloudflare Pages status failed with {status}: {text}");
1151 }
1152 let value: serde_json::Value = serde_json::from_str(&text)
1153 .with_context(|| format!("failed to parse Cloudflare Pages status response: {text}"))?;
1154 let latest = value
1155 .get("result")
1156 .and_then(serde_json::Value::as_array)
1157 .and_then(|items| items.first());
1158 Ok(DistributionReceipt {
1159 schema_version: 1,
1160 created_at_unix_seconds: now_unix_seconds(),
1161 provider: "cloudflare-pages".to_string(),
1162 site: options.site.clone(),
1163 action: "status".to_string(),
1164 artifact_manifest: None,
1165 deployment_id: latest
1166 .and_then(|item| item.get("id"))
1167 .and_then(serde_json::Value::as_str)
1168 .map(str::to_string),
1169 canonical_url: cloudflare_url(&cfg),
1170 preview_url: latest
1171 .and_then(|item| item.get("url"))
1172 .and_then(serde_json::Value::as_str)
1173 .map(str::to_string),
1174 custom_domain: non_empty(cfg.custom_domain.clone()),
1175 status: latest
1176 .and_then(|item| item.pointer("/latest_stage/status"))
1177 .or_else(|| latest.and_then(|item| item.get("deployment_trigger")))
1178 .and_then(serde_json::Value::as_str)
1179 .unwrap_or("ok")
1180 .to_string(),
1181 stdout: Some(serde_json::to_string_pretty(&value)?),
1182 stderr: None,
1183 manual_follow_up: Vec::new(),
1184 })
1185}
1186
1187fn command_status_receipt(
1188 options: &DistributeOptions,
1189 provider: &str,
1190 program: &str,
1191 args: Vec<String>,
1192) -> Result<DistributionReceipt> {
1193 let output = Command::new(program)
1194 .args(&args)
1195 .output()
1196 .with_context(|| format!("failed to run {program}; install it or authenticate first"))?;
1197 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
1198 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
1199 Ok(DistributionReceipt {
1200 schema_version: 1,
1201 created_at_unix_seconds: now_unix_seconds(),
1202 provider: provider.to_string(),
1203 site: options.site.clone(),
1204 action: "status".to_string(),
1205 artifact_manifest: None,
1206 deployment_id: options.deploy.clone(),
1207 canonical_url: first_url(&stdout),
1208 preview_url: None,
1209 custom_domain: None,
1210 status: if output.status.success() {
1211 "ok"
1212 } else {
1213 "failed"
1214 }
1215 .to_string(),
1216 stdout: Some(stdout),
1217 stderr: (!stderr.trim().is_empty()).then_some(stderr),
1218 manual_follow_up: Vec::new(),
1219 })
1220}
1221
1222fn readiness_package(
1223 project_dir: &Path,
1224 target: Option<Target>,
1225 format: Option<PackageFormat>,
1226) -> Result<Vec<ReadinessCheck>> {
1227 let target = target.unwrap_or(Target::Site);
1228 let format = format.unwrap_or(PackageFormat::Static);
1229 let mut checks = Vec::new();
1230 let format_supported = package_format_supported(target, format);
1231 checks.push(check(
1232 "release.package.format_supported",
1233 CheckSeverity::Error,
1234 if format_supported {
1235 CheckStatus::Passed
1236 } else {
1237 CheckStatus::Failed
1238 },
1239 "package format is supported for the selected target",
1240 Some(format!(
1241 "--target {} --format {}",
1242 target.as_str(),
1243 format.as_str()
1244 )),
1245 vec!["Use a valid target/format pair, such as site/static, site/docker-image, server/docker-image, linux/run, macos/app, macos/pkg, windows/exe, windows/msi, windows/msix, android/apk, android/aab, or ios/ipa."],
1246 ));
1247 checks.push(check_path(
1248 "release.package.fission_toml_exists",
1249 project_dir.join("fission.toml"),
1250 "fission.toml exists",
1251 "Run `fission init .` or point --project-dir at a Fission project.",
1252 ));
1253 if let Ok(project) = fission_command_core::read_project_config(project_dir) {
1254 checks.push(check(
1255 "release.package.target_configured",
1256 CheckSeverity::Error,
1257 if project.targets.contains(&target) {
1258 CheckStatus::Passed
1259 } else {
1260 CheckStatus::Missing
1261 },
1262 "target is configured in fission.toml",
1263 Some(format!("target = {}", target.as_str())),
1264 vec!["Run `fission add-target <target> --project-dir .` before packaging."],
1265 ));
1266 }
1267 if matches!(target, Target::Site) {
1268 let has_content = project_dir.join("content").exists();
1269 let has_entry = load_publish_manifest(project_dir)
1270 .ok()
1271 .and_then(|manifest| manifest.site.and_then(|site| site.entry))
1272 .is_some_and(|entry| !entry.trim().is_empty());
1273 checks.push(check(
1274 "release.package.site_content_or_entry",
1275 CheckSeverity::Error,
1276 if has_content || has_entry {
1277 CheckStatus::Passed
1278 } else {
1279 CheckStatus::Missing
1280 },
1281 "default content directory exists or custom site entry handles routing",
1282 Some(format!(
1283 "content: {}, site.entry: {}",
1284 project_dir.join("content").display(),
1285 has_entry
1286 )),
1287 vec!["Add content/ or configure [site].entry for a custom static site."],
1288 ));
1289 }
1290 readiness_package_tools(project_dir, target, format, &mut checks);
1291 package::readiness_secondary_artifacts(project_dir, &mut checks);
1292 Ok(checks)
1293}
1294
1295fn package_format_supported(target: Target, format: PackageFormat) -> bool {
1296 matches!(
1297 (target, format),
1298 (Target::Site, PackageFormat::Static)
1299 | (Target::Web, PackageFormat::Static)
1300 | (Target::Site, PackageFormat::DockerImage)
1301 | (Target::Server, PackageFormat::DockerImage)
1302 | (Target::Linux, PackageFormat::Run)
1303 | (Target::Macos, PackageFormat::App)
1304 | (Target::Macos, PackageFormat::Pkg)
1305 | (Target::Windows, PackageFormat::Exe)
1306 | (Target::Windows, PackageFormat::Msi)
1307 | (Target::Windows, PackageFormat::Msix)
1308 | (Target::Android, PackageFormat::Apk)
1309 | (Target::Android, PackageFormat::Aab)
1310 | (Target::Ios, PackageFormat::Ipa)
1311 )
1312}
1313
1314fn readiness_package_tools(
1315 project_dir: &Path,
1316 target: Target,
1317 format: PackageFormat,
1318 checks: &mut Vec<ReadinessCheck>,
1319) {
1320 match (target, format) {
1321 (Target::Site, PackageFormat::Static) | (Target::Web, PackageFormat::Static) => {
1322 checks.push(check_tool(
1323 "release.package.cargo_available",
1324 "cargo",
1325 "Install Rust from https://rustup.rs/ and ensure cargo is on PATH.",
1326 ));
1327 }
1328 (Target::Site, PackageFormat::DockerImage)
1329 | (Target::Server, PackageFormat::DockerImage) => {
1330 checks.push(check_tool(
1331 "release.package.cargo_available",
1332 "cargo",
1333 "Install Rust from https://rustup.rs/ and ensure cargo is on PATH.",
1334 ));
1335 checks.push(check_tool(
1336 "release.package.docker_available",
1337 "docker",
1338 "Install Docker and ensure the docker CLI can reach a running Docker engine.",
1339 ));
1340 if target == Target::Server {
1341 checks.push(check(
1342 "release.package.server_entry_configured",
1343 CheckSeverity::Error,
1344 if server_entry_configured(project_dir) {
1345 CheckStatus::Passed
1346 } else {
1347 CheckStatus::Missing
1348 },
1349 "server entry is configured",
1350 Some("[server].entry".to_string()),
1351 vec!["Add [server].entry to fission.toml so the Docker image can run the server app."],
1352 ));
1353 }
1354 }
1355 (Target::Linux, PackageFormat::Run) => {
1356 checks.push(host_os_check("release.package.host_is_linux", "linux"));
1357 checks.push(check_tool(
1358 "release.package.cargo_available",
1359 "cargo",
1360 "Install Rust from https://rustup.rs/ and ensure cargo is on PATH.",
1361 ));
1362 }
1363 (Target::Macos, PackageFormat::App) => {
1364 checks.push(host_os_check("release.package.host_is_macos", "macos"));
1365 checks.push(check_tool(
1366 "release.package.cargo_available",
1367 "cargo",
1368 "Install Rust from https://rustup.rs/ and ensure cargo is on PATH.",
1369 ));
1370 checks.push(check_tool(
1371 "release.package.codesign_available",
1372 "codesign",
1373 "Install Xcode command line tools so Fission can verify signed .app bundles.",
1374 ));
1375 }
1376 (Target::Macos, PackageFormat::Pkg) => {
1377 checks.push(host_os_check("release.package.host_is_macos", "macos"));
1378 checks.push(check_tool(
1379 "release.package.cargo_available",
1380 "cargo",
1381 "Install Rust from https://rustup.rs/ and ensure cargo is on PATH.",
1382 ));
1383 checks.push(check_tool(
1384 "release.package.pkgbuild_available",
1385 "pkgbuild",
1386 "Install Xcode command line tools.",
1387 ));
1388 checks.push(check_tool(
1389 "release.package.productbuild_available",
1390 "productbuild",
1391 "Install Xcode command line tools.",
1392 ));
1393 checks.push(check_tool(
1394 "release.package.pkgutil_available",
1395 "pkgutil",
1396 "Install macOS package tools so Fission can inspect produced .pkg files.",
1397 ));
1398 }
1399 (Target::Windows, PackageFormat::Exe) => {
1400 checks.push(host_os_check("release.package.host_is_windows", "windows"));
1401 checks.push(check_tool(
1402 "release.package.cargo_available",
1403 "cargo",
1404 "Install Rust from https://rustup.rs/ and ensure cargo is on PATH.",
1405 ));
1406 }
1407 (Target::Windows, PackageFormat::Msi) => {
1408 checks.push(host_os_check("release.package.host_is_windows", "windows"));
1409 checks.push(check_path(
1410 "release.package.windows_msi_script_exists",
1411 project_dir.join("platforms/windows/package-msi.ps1"),
1412 "Windows MSI packaging script exists",
1413 "Configure platforms/windows/package-msi.ps1 or install the Windows packaging target template.",
1414 ));
1415 checks.push(check_any_tool(
1416 "release.package.windows_msi_builder_available",
1417 &["wix", "candle"],
1418 "WiX MSI packaging tooling is available",
1419 "Install WiX Toolset or configure platforms/windows/package-msi.ps1 to call the approved MSI packager.",
1420 ));
1421 checks.push(check_tool(
1422 "release.package.signtool_available",
1423 "signtool",
1424 "Install Windows SDK signing tools and ensure signtool is on PATH.",
1425 ));
1426 }
1427 (Target::Windows, PackageFormat::Msix) => {
1428 checks.push(host_os_check("release.package.host_is_windows", "windows"));
1429 checks.push(check_path(
1430 "release.package.windows_msix_script_exists",
1431 project_dir.join("platforms/windows/package-msix.ps1"),
1432 "Windows MSIX packaging script exists",
1433 "Configure platforms/windows/package-msix.ps1 or install the Windows packaging target template.",
1434 ));
1435 checks.push(check_tool(
1436 "release.package.makeappx_available",
1437 "makeappx",
1438 "Install Windows SDK MSIX packaging tools and ensure makeappx is on PATH.",
1439 ));
1440 checks.push(check_tool(
1441 "release.package.signtool_available",
1442 "signtool",
1443 "Install Windows SDK signing tools and ensure signtool is on PATH.",
1444 ));
1445 }
1446 (Target::Android, PackageFormat::Apk) => {
1447 checks.push(check_path(
1448 "release.package.android_apk_script_exists",
1449 project_dir.join("platforms/android/package-apk.sh"),
1450 "Android APK packaging script exists",
1451 "Run `fission add-target android --project-dir .` or restore platforms/android/package-apk.sh.",
1452 ));
1453 android_packaging_checks(checks);
1454 }
1455 (Target::Android, PackageFormat::Aab) => {
1456 checks.push(check_path(
1457 "release.package.android_aab_script_exists",
1458 project_dir.join("platforms/android/package-aab.sh"),
1459 "Android AAB packaging script exists",
1460 "Add platforms/android/package-aab.sh once release AAB packaging is configured.",
1461 ));
1462 android_packaging_checks(checks);
1463 checks.push(check_env_or_tool(
1464 "release.package.bundletool_available",
1465 &["BUNDLETOOL"],
1466 &["bundletool"],
1467 "Android bundletool is available for AAB validation",
1468 "Install bundletool or set BUNDLETOOL to the bundletool jar/path used by the project packaging script.",
1469 ));
1470 }
1471 (Target::Ios, PackageFormat::Ipa) => {
1472 checks.push(host_os_check("release.package.host_is_macos", "macos"));
1473 checks.push(check_path(
1474 "release.package.ios_ipa_script_exists",
1475 project_dir.join("platforms/ios/package-ipa.sh"),
1476 "iOS IPA packaging script exists",
1477 "Add platforms/ios/package-ipa.sh once release IPA export is configured.",
1478 ));
1479 checks.push(check_tool(
1480 "release.package.xcrun_available",
1481 "xcrun",
1482 "Install Xcode command line tools and select an Xcode installation.",
1483 ));
1484 checks.push(check_tool(
1485 "release.package.xcodebuild_available",
1486 "xcodebuild",
1487 "Install Xcode so Fission can archive and export iOS IPA files.",
1488 ));
1489 checks.push(check_tool(
1490 "release.package.codesign_available",
1491 "codesign",
1492 "Install Xcode command line tools so Fission can verify iOS signing.",
1493 ));
1494 }
1495 _ => {}
1496 }
1497}
1498
1499fn server_entry_configured(project_dir: &Path) -> bool {
1500 fs::read_to_string(project_dir.join("fission.toml"))
1501 .ok()
1502 .and_then(|data| toml::from_str::<toml::Value>(&data).ok())
1503 .and_then(|value| {
1504 value
1505 .get("server")
1506 .and_then(|server| server.get("entry"))
1507 .and_then(toml::Value::as_str)
1508 .map(|entry| !entry.trim().is_empty())
1509 })
1510 .unwrap_or(false)
1511}
1512
1513fn android_packaging_checks(checks: &mut Vec<ReadinessCheck>) {
1514 checks.push(check_tool(
1515 "release.package.cargo_available",
1516 "cargo",
1517 "Install Rust from https://rustup.rs/ and ensure cargo is on PATH.",
1518 ));
1519 checks.push(check_any_env(
1520 "release.package.android_sdk_configured",
1521 &["ANDROID_HOME", "ANDROID_SDK_ROOT"],
1522 "Android SDK path is configured",
1523 "Set ANDROID_HOME or ANDROID_SDK_ROOT to the installed Android SDK.",
1524 ));
1525 checks.push(check_any_env(
1526 "release.package.android_ndk_configured",
1527 &["ANDROID_NDK_HOME", "ANDROID_NDK_ROOT"],
1528 "Android NDK path is configured",
1529 "Set ANDROID_NDK_HOME or ANDROID_NDK_ROOT to the installed Android NDK used by Rust cross-compilation.",
1530 ));
1531 checks.push(check_tool(
1532 "release.package.aapt2_available",
1533 "aapt2",
1534 "Install Android SDK build-tools and ensure aapt2 is on PATH.",
1535 ));
1536 checks.push(check_tool(
1537 "release.package.zipalign_available",
1538 "zipalign",
1539 "Install Android SDK build-tools and ensure zipalign is on PATH.",
1540 ));
1541 checks.push(check_tool(
1542 "release.package.apksigner_available",
1543 "apksigner",
1544 "Install Android SDK build-tools and ensure apksigner is on PATH.",
1545 ));
1546}
1547
1548fn readiness_distribute(
1549 project_dir: &Path,
1550 provider: DistributionProvider,
1551 site: &str,
1552 track: Option<&str>,
1553 artifact: Option<&Path>,
1554 config: &PublishManifest,
1555) -> Result<Vec<ReadinessCheck>> {
1556 let mut checks = Vec::new();
1557 if let Some(path) = artifact {
1558 checks.push(check_path(
1559 "release.distribution.artifact_manifest_exists",
1560 path.to_path_buf(),
1561 "artifact manifest exists",
1562 "Run `fission package --target site --format static --release` first.",
1563 ));
1564 if path.exists() {
1565 let manifest = read_artifact_manifest(path)?;
1566 if provider_requires_static_root(provider) {
1567 checks.push(check(
1568 "release.distribution.static_root_exists",
1569 CheckSeverity::Error,
1570 if Path::new(&manifest.root_dir).join("index.html").exists() {
1571 CheckStatus::Passed
1572 } else {
1573 CheckStatus::Missing
1574 },
1575 "static artifact root contains index.html",
1576 Some(manifest.root_dir),
1577 vec!["Rebuild the static package and ensure the output includes index.html."],
1578 ));
1579 }
1580 }
1581 }
1582
1583 match provider {
1584 DistributionProvider::GithubPages => {
1585 readiness_github_pages(project_dir, site, config, &mut checks)?
1586 }
1587 DistributionProvider::GithubReleases => {
1588 github_releases::readiness(project_dir, site, artifact, config, &mut checks)?
1589 }
1590 DistributionProvider::DockerRegistry => {
1591 docker_registry::readiness(site, artifact, config, &mut checks)?
1592 }
1593 DistributionProvider::CloudflarePages => {
1594 readiness_cloudflare_pages(site, config, &mut checks)?
1595 }
1596 DistributionProvider::Netlify => readiness_netlify(site, config, &mut checks)?,
1597 DistributionProvider::S3 => files::readiness_s3(site, config, &mut checks)?,
1598 DistributionProvider::GoogleDrive => {
1599 files::readiness_google_drive(site, config, &mut checks)?
1600 }
1601 DistributionProvider::OneDrive => files::readiness_onedrive(site, config, &mut checks)?,
1602 DistributionProvider::Dropbox => files::readiness_dropbox(site, config, &mut checks)?,
1603 DistributionProvider::PlayStore => {
1604 stores::readiness_play_store(track, artifact, config, &mut checks)?
1605 }
1606 DistributionProvider::AppStore => {
1607 stores::readiness_app_store(track, artifact, config, &mut checks)?
1608 }
1609 DistributionProvider::MicrosoftStore => {
1610 stores::readiness_microsoft_store(track, artifact, config, &mut checks)?
1611 }
1612 }
1613 Ok(checks)
1614}
1615
1616fn provider_requires_static_root(provider: DistributionProvider) -> bool {
1617 matches!(
1618 provider,
1619 DistributionProvider::GithubPages
1620 | DistributionProvider::CloudflarePages
1621 | DistributionProvider::Netlify
1622 )
1623}
1624
1625fn readiness_github_pages(
1626 project_dir: &Path,
1627 site: &str,
1628 config: &PublishManifest,
1629 checks: &mut Vec<ReadinessCheck>,
1630) -> Result<()> {
1631 let cfg = github_config(config, site)?;
1632 let owner = cfg
1633 .owner
1634 .clone()
1635 .or_else(|| infer_github_owner(project_dir));
1636 let repo = cfg.repo.clone().or_else(|| infer_github_repo(project_dir));
1637 checks.push(required_value(
1638 "release.github_pages.owner_configured",
1639 owner.as_deref(),
1640 "GitHub owner is configured or inferable from git remote",
1641 "Set distribution.github_pages.<site>.owner or configure an origin GitHub remote.",
1642 ));
1643 checks.push(required_value(
1644 "release.github_pages.repo_configured",
1645 repo.as_deref(),
1646 "GitHub repository is configured or inferable from git remote",
1647 "Set distribution.github_pages.<site>.repo or configure an origin GitHub remote.",
1648 ));
1649 let mode = cfg.mode.as_deref().unwrap_or("actions");
1650 checks.push(check(
1651 "release.github_pages.mode_supported",
1652 CheckSeverity::Error,
1653 if matches!(mode, "actions" | "branch" | "manual") {
1654 CheckStatus::Passed
1655 } else {
1656 CheckStatus::Failed
1657 },
1658 "GitHub Pages mode is supported",
1659 Some(mode.to_string()),
1660 vec!["Use mode = \"actions\", \"branch\", or \"manual\"."],
1661 ));
1662 if mode == "branch" {
1663 checks.push(check_tool(
1664 "release.github_pages.git_available",
1665 "git",
1666 "Install Git and authenticate to the repository remote.",
1667 ));
1668 } else {
1669 checks.push(check(
1670 "release.github_pages.source_is_actions",
1671 CheckSeverity::Warning,
1672 if cfg.source.as_deref().unwrap_or("github-actions") == "github-actions" {
1673 CheckStatus::Passed
1674 } else {
1675 CheckStatus::Warning
1676 },
1677 "GitHub Pages source is configured for Actions publishing",
1678 cfg.source.clone(),
1679 vec!["Set distribution.github_pages.<site>.source = \"github-actions\" for Actions-based Pages publishing."],
1680 ));
1681 let workflow = cfg.workflow.as_deref().unwrap_or("fission-pages.yml");
1682 checks.push(check_path(
1683 "release.github_pages.workflow_exists",
1684 github_workflow_path(project_dir, &cfg, workflow),
1685 "GitHub Pages workflow exists",
1686 "Run `fission distribute setup --provider github-pages --site production` to generate it.",
1687 ));
1688 checks.push(check(
1689 "release.github_pages.local_api_token_optional",
1690 CheckSeverity::Info,
1691 if env::var_os("GH_TOKEN").is_some()
1692 || env::var_os("GITHUB_TOKEN").is_some()
1693 || credentials::provider_secret(DistributionProvider::GithubPages, &[])
1694 .ok()
1695 .flatten()
1696 .is_some()
1697 {
1698 CheckStatus::Passed
1699 } else {
1700 CheckStatus::Skipped
1701 },
1702 "GitHub API token is available for local status/domain setup",
1703 None,
1704 vec!["For local Pages status or future domain setup automation, set GH_TOKEN/GITHUB_TOKEN or import a GitHub credential into the Fission vault."],
1705 ));
1706 }
1707 let base = cfg.base_path.as_deref().unwrap_or("/");
1708 let expected = expected_github_base_path(&cfg, repo.as_deref());
1709 checks.push(check(
1710 "release.github_pages.base_path_matches_domain_mode",
1711 CheckSeverity::Warning,
1712 if base == expected { CheckStatus::Passed } else { CheckStatus::Warning },
1713 "GitHub Pages base path matches custom-domain/project-site mode",
1714 Some(format!("configured {base}, expected {expected}")),
1715 vec!["Set distribution.github_pages.<site>.base_path to the expected value or adjust the site renderer base URL."],
1716 ));
1717 checks.push(check(
1718 "release.github_pages.https_policy_set",
1719 CheckSeverity::Info,
1720 if cfg.enforce_https.unwrap_or(true) {
1721 CheckStatus::Passed
1722 } else {
1723 CheckStatus::Warning
1724 },
1725 "GitHub Pages HTTPS policy is explicit",
1726 Some(format!("enforce_https = {}", cfg.enforce_https.unwrap_or(true))),
1727 vec!["Keep enforce_https = true for public production sites unless there is a provider limitation."],
1728 ));
1729 Ok(())
1730}
1731
1732fn readiness_cloudflare_pages(
1733 site: &str,
1734 config: &PublishManifest,
1735 checks: &mut Vec<ReadinessCheck>,
1736) -> Result<()> {
1737 let cfg = cloudflare_config(config, site)?;
1738 let env_account_id = env::var("CLOUDFLARE_ACCOUNT_ID").ok();
1739 checks.push(required_value(
1740 "release.cloudflare_pages.account_id_configured",
1741 cfg.account_id.as_deref().or(env_account_id.as_deref()),
1742 "Cloudflare account id is configured",
1743 "Set distribution.cloudflare_pages.<site>.account_id or CLOUDFLARE_ACCOUNT_ID.",
1744 ));
1745 checks.push(required_value(
1746 "release.cloudflare_pages.project_name_configured",
1747 cfg.project_name.as_deref(),
1748 "Cloudflare Pages project name is configured",
1749 "Set distribution.cloudflare_pages.<site>.project_name.",
1750 ));
1751 checks.push(required_provider_secret(
1752 "release.cloudflare_pages.token_available",
1753 DistributionProvider::CloudflarePages,
1754 &["CLOUDFLARE_API_TOKEN"],
1755 "Create a Cloudflare API token with Pages Edit permission and store it in CI secrets or the Fission release vault.",
1756 ));
1757 checks.push(check_tool(
1758 "release.cloudflare_pages.wrangler_available",
1759 "wrangler",
1760 "Install Wrangler and authenticate it; Cloudflare Pages upload intentionally uses the provider CLI backend.",
1761 ));
1762 checks.push(base_path_check(
1763 "release.cloudflare_pages.base_path_root",
1764 cfg.base_path.as_deref(),
1765 ));
1766 Ok(())
1767}
1768
1769fn readiness_netlify(
1770 site: &str,
1771 config: &PublishManifest,
1772 checks: &mut Vec<ReadinessCheck>,
1773) -> Result<()> {
1774 let cfg = netlify_config(config, site)?;
1775 checks.push(required_value(
1776 "release.netlify.site_configured",
1777 cfg.site_id.as_deref(),
1778 "Netlify site id is configured",
1779 "Set distribution.netlify.<site>.site_id or run provider setup after creating a Netlify site.",
1780 ));
1781 checks.push(required_provider_secret(
1782 "release.netlify.token_available",
1783 DistributionProvider::Netlify,
1784 &["NETLIFY_AUTH_TOKEN"],
1785 "Create a Netlify access token and store it in CI secrets, your shell environment, or the Fission release vault.",
1786 ));
1787 checks.push(base_path_check(
1788 "release.netlify.base_path_root",
1789 cfg.base_path.as_deref(),
1790 ));
1791 Ok(())
1792}
1793
1794fn build_artifact_manifest(
1795 project: &FissionProject,
1796 options: &PackageOptions,
1797 root: &Path,
1798 profile: &str,
1799) -> Result<ArtifactManifest> {
1800 let mut files = Vec::new();
1801 collect_artifacts(root, root, &mut files)?;
1802 files.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
1803 Ok(ArtifactManifest {
1804 schema_version: 1,
1805 created_at_unix_seconds: now_unix_seconds(),
1806 project: ArtifactProject {
1807 app_id: project.app.app_id.clone(),
1808 name: project.app.name.clone(),
1809 version: cargo_package_version(&options.project_dir),
1810 },
1811 target: options.target.as_str().to_string(),
1812 format: options.format.as_str().to_string(),
1813 profile: profile.to_string(),
1814 root_dir: root.display().to_string(),
1815 artifacts: files,
1816 validation: ArtifactValidation {
1817 state: "passed".to_string(),
1818 checks: Vec::new(),
1819 },
1820 })
1821}
1822
1823fn collect_artifacts(root: &Path, current: &Path, files: &mut Vec<ArtifactFile>) -> Result<()> {
1824 for entry in
1825 fs::read_dir(current).with_context(|| format!("failed to read {}", current.display()))?
1826 {
1827 let entry = entry?;
1828 let path = entry.path();
1829 let file_type = entry.file_type()?;
1830 if file_type.is_dir() {
1831 if entry.file_name() == ".git" {
1832 continue;
1833 }
1834 collect_artifacts(root, &path, files)?;
1835 } else if file_type.is_file() {
1836 if path.file_name().and_then(OsStr::to_str) == Some(ARTIFACT_MANIFEST) {
1837 continue;
1838 }
1839 let relative = path
1840 .strip_prefix(root)?
1841 .to_string_lossy()
1842 .replace('\\', "/");
1843 let (sha256, size_bytes) = hash_file(&path)?;
1844 files.push(ArtifactFile {
1845 kind: if relative == "index.html" {
1846 "entry"
1847 } else {
1848 "asset"
1849 }
1850 .to_string(),
1851 purpose: None,
1852 platform: None,
1853 upload_provider: None,
1854 path: path.display().to_string(),
1855 relative_path: relative,
1856 sha256,
1857 size_bytes,
1858 mime_type: content_type(&path).to_string(),
1859 });
1860 }
1861 }
1862 Ok(())
1863}
1864
1865fn hash_file(path: &Path) -> Result<(String, u64)> {
1866 let mut file = fs::File::open(path)?;
1867 let mut hasher = Sha256::new();
1868 let mut size = 0u64;
1869 let mut buf = [0u8; 8192];
1870 loop {
1871 let read = file.read(&mut buf)?;
1872 if read == 0 {
1873 break;
1874 }
1875 size += read as u64;
1876 hasher.update(&buf[..read]);
1877 }
1878 Ok((hex_lower(&hasher.finalize()), size))
1879}
1880
1881fn hex_lower(bytes: &[u8]) -> String {
1882 const HEX: &[u8; 16] = b"0123456789abcdef";
1883 let mut out = String::with_capacity(bytes.len() * 2);
1884 for byte in bytes {
1885 out.push(HEX[(byte >> 4) as usize] as char);
1886 out.push(HEX[(byte & 0xf) as usize] as char);
1887 }
1888 out
1889}
1890
1891fn content_type(path: &Path) -> &'static str {
1892 if path.file_name().and_then(OsStr::to_str) == Some("Dockerfile") {
1893 return "text/plain; charset=utf-8";
1894 }
1895 match path.extension().and_then(OsStr::to_str).unwrap_or("") {
1896 "html" => "text/html; charset=utf-8",
1897 "css" => "text/css; charset=utf-8",
1898 "js" | "mjs" => "text/javascript; charset=utf-8",
1899 "wasm" => "application/wasm",
1900 "json" | "webmanifest" => "application/json; charset=utf-8",
1901 "png" => "image/png",
1902 "jpg" | "jpeg" => "image/jpeg",
1903 "svg" => "image/svg+xml",
1904 "ico" => "image/x-icon",
1905 "txt" => "text/plain; charset=utf-8",
1906 "xml" => "application/xml; charset=utf-8",
1907 _ => "application/octet-stream",
1908 }
1909}
1910
1911fn copy_dir_contents(source: &Path, dest: &Path) -> Result<()> {
1912 for entry in
1913 fs::read_dir(source).with_context(|| format!("failed to read {}", source.display()))?
1914 {
1915 let entry = entry?;
1916 let source_path = entry.path();
1917 let dest_path = dest.join(entry.file_name());
1918 if entry.file_type()?.is_dir() {
1919 fs::create_dir_all(&dest_path)?;
1920 copy_dir_contents(&source_path, &dest_path)?;
1921 } else {
1922 if let Some(parent) = dest_path.parent() {
1923 fs::create_dir_all(parent)?;
1924 }
1925 fs::copy(&source_path, &dest_path).with_context(|| {
1926 format!(
1927 "failed to copy {} to {}",
1928 source_path.display(),
1929 dest_path.display()
1930 )
1931 })?;
1932 }
1933 }
1934 Ok(())
1935}
1936
1937fn clean_publish_root(root: &Path) -> Result<()> {
1938 fs::create_dir_all(root)?;
1939 for entry in fs::read_dir(root)? {
1940 let entry = entry?;
1941 if entry.file_name() == ".git" {
1942 continue;
1943 }
1944 let path = entry.path();
1945 if entry.file_type()?.is_dir() {
1946 fs::remove_dir_all(path)?;
1947 } else {
1948 fs::remove_file(path)?;
1949 }
1950 }
1951 Ok(())
1952}
1953
1954fn load_publish_manifest(project_dir: &Path) -> Result<PublishManifest> {
1955 let path = project_dir.join("fission.toml");
1956 let data =
1957 fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
1958 toml::from_str(&data).with_context(|| format!("failed to parse {}", path.display()))
1959}
1960
1961fn site_output_dir(project_dir: &Path) -> Result<PathBuf> {
1962 let manifest = load_publish_manifest(project_dir)?;
1963 Ok(manifest
1964 .site
1965 .and_then(|site| site.out_dir)
1966 .map(|path| resolve_project_path(project_dir, path))
1967 .unwrap_or_else(|| project_dir.join("target/fission/site")))
1968}
1969
1970fn read_artifact_manifest(path: &Path) -> Result<ArtifactManifest> {
1971 let data = fs::read_to_string(path)
1972 .with_context(|| format!("failed to read artifact manifest {}", path.display()))?;
1973 serde_json::from_str(&data)
1974 .with_context(|| format!("failed to parse artifact manifest {}", path.display()))
1975}
1976
1977fn default_artifact_manifest_path(project_dir: &Path, target: Target, release: bool) -> PathBuf {
1978 default_artifact_manifest_path_for_format(project_dir, target, PackageFormat::Static, release)
1979}
1980
1981fn default_artifact_manifest_path_for_format(
1982 project_dir: &Path,
1983 target: Target,
1984 format: PackageFormat,
1985 release: bool,
1986) -> PathBuf {
1987 project_dir
1988 .join("target/fission")
1989 .join(if release { "release" } else { "debug" })
1990 .join(target.as_str())
1991 .join(format.as_str())
1992 .join(ARTIFACT_MANIFEST)
1993}
1994
1995fn github_config(config: &PublishManifest, site: &str) -> Result<GithubPagesConfig> {
1996 Ok(config
1997 .distribution
1998 .as_ref()
1999 .and_then(|distribution| distribution.github_pages.get(site))
2000 .cloned()
2001 .unwrap_or_default())
2002}
2003
2004fn github_releases_config(config: &PublishManifest, site: &str) -> Result<GithubReleasesConfig> {
2005 config
2006 .distribution
2007 .as_ref()
2008 .and_then(|distribution| distribution.github_releases.get(site))
2009 .cloned()
2010 .with_context(|| format!("missing [distribution.github_releases.{site}] in fission.toml"))
2011}
2012
2013fn docker_registry_config(config: &PublishManifest, site: &str) -> Result<DockerRegistryConfig> {
2014 Ok(config
2015 .distribution
2016 .as_ref()
2017 .and_then(|distribution| distribution.docker_registry.get(site))
2018 .cloned()
2019 .unwrap_or_default())
2020}
2021
2022fn s3_config(config: &PublishManifest, site: &str) -> Result<S3Config> {
2023 config
2024 .distribution
2025 .as_ref()
2026 .and_then(|distribution| distribution.s3.get(site))
2027 .cloned()
2028 .with_context(|| format!("missing [distribution.s3.{site}] in fission.toml"))
2029}
2030
2031fn google_drive_config(config: &PublishManifest, site: &str) -> Result<GoogleDriveConfig> {
2032 Ok(config
2033 .distribution
2034 .as_ref()
2035 .and_then(|distribution| distribution.google_drive.get(site))
2036 .cloned()
2037 .unwrap_or_default())
2038}
2039
2040fn onedrive_config(config: &PublishManifest, site: &str) -> Result<OneDriveConfig> {
2041 Ok(config
2042 .distribution
2043 .as_ref()
2044 .and_then(|distribution| distribution.onedrive.get(site))
2045 .cloned()
2046 .unwrap_or_default())
2047}
2048
2049fn dropbox_config(config: &PublishManifest, site: &str) -> Result<DropboxConfig> {
2050 Ok(config
2051 .distribution
2052 .as_ref()
2053 .and_then(|distribution| distribution.dropbox.get(site))
2054 .cloned()
2055 .unwrap_or_default())
2056}
2057
2058fn cloudflare_config(config: &PublishManifest, site: &str) -> Result<CloudflarePagesConfig> {
2059 config
2060 .distribution
2061 .as_ref()
2062 .and_then(|distribution| distribution.cloudflare_pages.get(site))
2063 .cloned()
2064 .with_context(|| format!("missing [distribution.cloudflare_pages.{site}] in fission.toml"))
2065}
2066
2067fn netlify_config(config: &PublishManifest, site: &str) -> Result<NetlifyConfig> {
2068 config
2069 .distribution
2070 .as_ref()
2071 .and_then(|distribution| distribution.netlify.get(site))
2072 .cloned()
2073 .with_context(|| format!("missing [distribution.netlify.{site}] in fission.toml"))
2074}
2075
2076fn github_workflow_path(project_dir: &Path, _cfg: &GithubPagesConfig, workflow: &str) -> PathBuf {
2077 git_repo_root(project_dir)
2078 .unwrap_or_else(|| project_dir.to_path_buf())
2079 .join(".github/workflows")
2080 .join(workflow)
2081}
2082
2083fn project_dir_argument_for_workflow(project_dir: &Path) -> String {
2084 let Some(repo_root) = git_repo_root(project_dir) else {
2085 return ".".to_string();
2086 };
2087 let Ok(project_dir) = fs::canonicalize(project_dir) else {
2088 return ".".to_string();
2089 };
2090 let Ok(repo_root) = fs::canonicalize(repo_root) else {
2091 return ".".to_string();
2092 };
2093 if project_dir == repo_root {
2094 ".".to_string()
2095 } else {
2096 project_dir
2097 .strip_prefix(&repo_root)
2098 .map(|path| path.to_string_lossy().replace('\\', "/"))
2099 .unwrap_or_else(|_| ".".to_string())
2100 }
2101}
2102
2103fn render_github_pages_workflow(project_dir: &Path, cfg: &GithubPagesConfig) -> String {
2104 let branch = cfg.production_branch.as_deref().unwrap_or("main");
2105 let package_project_dir = project_dir_argument_for_workflow(project_dir);
2106 let artifact_path = if package_project_dir == "." {
2107 "target/fission/release/site/static".to_string()
2108 } else {
2109 format!("{package_project_dir}/target/fission/release/site/static")
2110 };
2111 format!(
2112 r#"name: Publish Fission site
2113
2114on:
2115 push:
2116 branches:
2117 - {branch}
2118 workflow_dispatch:
2119
2120permissions:
2121 contents: read
2122 pages: write
2123 id-token: write
2124
2125concurrency:
2126 group: github-pages
2127 cancel-in-progress: true
2128
2129jobs:
2130 build:
2131 runs-on: ubuntu-latest
2132 environment:
2133 name: github-pages
2134 steps:
2135 - name: Check out repository
2136 uses: actions/checkout@v4
2137 with:
2138 submodules: recursive
2139
2140 - name: Set up Rust
2141 uses: dtolnay/rust-toolchain@stable
2142
2143 - name: Build Fission static package
2144 run: fission package --project-dir {package_project_dir} --target site --format static --release
2145
2146 - name: Upload GitHub Pages artifact
2147 uses: actions/upload-pages-artifact@v3
2148 with:
2149 path: {artifact_path}
2150
2151 deploy:
2152 needs: build
2153 runs-on: ubuntu-latest
2154 environment:
2155 name: github-pages
2156 url: ${{{{ steps.deployment.outputs.page_url }}}}
2157 steps:
2158 - name: Deploy to GitHub Pages
2159 id: deployment
2160 uses: actions/deploy-pages@v4
2161"#,
2162 branch = branch,
2163 package_project_dir = package_project_dir,
2164 artifact_path = artifact_path,
2165 )
2166}
2167
2168fn write_receipt(project_dir: &Path, receipt: &DistributionReceipt) -> Result<()> {
2169 let dir = project_dir
2170 .join("target/fission/distribution")
2171 .join(&receipt.provider)
2172 .join(&receipt.site);
2173 fs::create_dir_all(&dir)?;
2174 let path = dir.join(format!(
2175 "{}-{}.json",
2176 receipt.action, receipt.created_at_unix_seconds
2177 ));
2178 fs::write(&path, serde_json::to_vec_pretty(receipt)?)
2179 .with_context(|| format!("failed to write {}", path.display()))?;
2180 Ok(())
2181}
2182
2183fn print_readiness_report(report: &ReadinessReport) {
2184 println!("Readiness: {}", report.status);
2185 print_checks(&report.checks);
2186}
2187
2188fn print_checks(checks: &[ReadinessCheck]) {
2189 for check in checks {
2190 println!(
2191 "[{:?}/{:?}] {} - {}",
2192 check.severity, check.status, check.id, check.summary
2193 );
2194 if let Some(details) = &check.details {
2195 println!(" {details}");
2196 }
2197 for remediation in &check.remediation {
2198 println!(" fix: {remediation}");
2199 }
2200 }
2201}
2202
2203fn report_status(checks: &[ReadinessCheck]) -> &'static str {
2204 if checks
2205 .iter()
2206 .any(|check| check.severity == CheckSeverity::Error && check.status != CheckStatus::Passed)
2207 {
2208 "blocked"
2209 } else if checks
2210 .iter()
2211 .any(|check| check.status == CheckStatus::Warning)
2212 {
2213 "warning"
2214 } else {
2215 "ready"
2216 }
2217}
2218
2219fn check(
2220 id: impl Into<String>,
2221 severity: CheckSeverity,
2222 status: CheckStatus,
2223 summary: impl Into<String>,
2224 details: Option<String>,
2225 remediation: Vec<&str>,
2226) -> ReadinessCheck {
2227 ReadinessCheck {
2228 id: id.into(),
2229 severity,
2230 status,
2231 summary: summary.into(),
2232 details,
2233 remediation: remediation.into_iter().map(str::to_string).collect(),
2234 }
2235}
2236
2237fn check_path(id: &str, path: PathBuf, summary: &str, remediation: &str) -> ReadinessCheck {
2238 check(
2239 id,
2240 CheckSeverity::Error,
2241 if path.exists() {
2242 CheckStatus::Passed
2243 } else {
2244 CheckStatus::Missing
2245 },
2246 summary,
2247 Some(path.display().to_string()),
2248 vec![remediation],
2249 )
2250}
2251
2252fn required_value(
2253 id: &str,
2254 value: Option<&str>,
2255 summary: &str,
2256 remediation: &str,
2257) -> ReadinessCheck {
2258 check(
2259 id,
2260 CheckSeverity::Error,
2261 if value.is_some_and(|value| !value.trim().is_empty()) {
2262 CheckStatus::Passed
2263 } else {
2264 CheckStatus::Missing
2265 },
2266 summary,
2267 value.map(str::to_string),
2268 vec![remediation],
2269 )
2270}
2271
2272fn required_provider_secret(
2273 id: &str,
2274 provider: DistributionProvider,
2275 env_names: &[&str],
2276 remediation: &str,
2277) -> ReadinessCheck {
2278 let env_name = env_names.iter().find(|name| env::var_os(name).is_some());
2279 let vault_present = credentials::provider_secret(provider, &[])
2280 .ok()
2281 .flatten()
2282 .is_some();
2283 check(
2284 id,
2285 CheckSeverity::Error,
2286 if env_name.is_some() || vault_present {
2287 CheckStatus::Passed
2288 } else {
2289 CheckStatus::Missing
2290 },
2291 "provider credentials are available",
2292 env_name
2293 .map(|name| format!("environment variable {name}"))
2294 .or_else(|| vault_present.then(|| "Fission release vault".to_string())),
2295 vec![remediation],
2296 )
2297}
2298
2299fn base_path_check(id: &str, base_path: Option<&str>) -> ReadinessCheck {
2300 let value = base_path.unwrap_or("/");
2301 check(
2302 id,
2303 CheckSeverity::Warning,
2304 if value == "/" {
2305 CheckStatus::Passed
2306 } else {
2307 CheckStatus::Warning
2308 },
2309 "static hosting provider base path is root",
2310 Some(format!("base_path = {value}")),
2311 vec!["Dedicated static hosting providers usually serve production sites from `/`; use a non-root base path only when deliberately hosting below a subpath."],
2312 )
2313}
2314
2315fn host_os_check(id: &str, expected: &str) -> ReadinessCheck {
2316 let current = env::consts::OS;
2317 check(
2318 id,
2319 CheckSeverity::Error,
2320 if current == expected {
2321 CheckStatus::Passed
2322 } else {
2323 CheckStatus::Failed
2324 },
2325 format!("host operating system is {expected}"),
2326 Some(format!("current host: {current}")),
2327 vec!["Run this package format on the platform that owns the native packaging/signing toolchain."],
2328 )
2329}
2330
2331fn check_tool(id: &str, tool: &str, remediation: &str) -> ReadinessCheck {
2332 check(
2333 id,
2334 CheckSeverity::Error,
2335 if find_in_path(tool).is_some() {
2336 CheckStatus::Passed
2337 } else {
2338 CheckStatus::Missing
2339 },
2340 format!("{tool} is available on PATH"),
2341 find_in_path(tool).map(|path| path.display().to_string()),
2342 vec![remediation],
2343 )
2344}
2345
2346fn check_any_tool(id: &str, tools: &[&str], summary: &str, remediation: &str) -> ReadinessCheck {
2347 let found = tools
2348 .iter()
2349 .find_map(|tool| find_in_path(tool).map(|path| (*tool, path)));
2350 check(
2351 id,
2352 CheckSeverity::Error,
2353 if found.is_some() {
2354 CheckStatus::Passed
2355 } else {
2356 CheckStatus::Missing
2357 },
2358 summary,
2359 found
2360 .map(|(tool, path)| format!("{tool}: {}", path.display()))
2361 .or_else(|| Some(format!("checked: {}", tools.join(", ")))),
2362 vec![remediation],
2363 )
2364}
2365
2366fn check_any_env(id: &str, names: &[&str], summary: &str, remediation: &str) -> ReadinessCheck {
2367 let found = names.iter().find(|name| env::var_os(name).is_some());
2368 check(
2369 id,
2370 CheckSeverity::Error,
2371 if found.is_some() {
2372 CheckStatus::Passed
2373 } else {
2374 CheckStatus::Missing
2375 },
2376 summary,
2377 found.map(|name| format!("{name}={}", env::var(name).unwrap_or_default())),
2378 vec![remediation],
2379 )
2380}
2381
2382fn check_env_or_tool(
2383 id: &str,
2384 env_names: &[&str],
2385 tools: &[&str],
2386 summary: &str,
2387 remediation: &str,
2388) -> ReadinessCheck {
2389 let found_env = env_names.iter().find(|name| env::var_os(name).is_some());
2390 let found_tool = tools
2391 .iter()
2392 .find_map(|tool| find_in_path(tool).map(|path| (*tool, path)));
2393 check(
2394 id,
2395 CheckSeverity::Error,
2396 if found_env.is_some() || found_tool.is_some() {
2397 CheckStatus::Passed
2398 } else {
2399 CheckStatus::Missing
2400 },
2401 summary,
2402 found_env
2403 .map(|name| format!("{name}={}", env::var(name).unwrap_or_default()))
2404 .or_else(|| found_tool.map(|(tool, path)| format!("{tool}: {}", path.display()))),
2405 vec![remediation],
2406 )
2407}
2408
2409fn find_in_path(name: &str) -> Option<PathBuf> {
2410 let path = env::var_os("PATH")?;
2411 for dir in env::split_paths(&path) {
2412 let candidate = dir.join(name);
2413 if candidate.exists() {
2414 return Some(candidate);
2415 }
2416 if cfg!(windows) {
2417 for extension in ["exe", "cmd", "bat", "ps1"] {
2418 let candidate = dir.join(format!("{name}.{extension}"));
2419 if candidate.exists() {
2420 return Some(candidate);
2421 }
2422 }
2423 }
2424 }
2425 None
2426}
2427
2428fn now_unix_seconds() -> u64 {
2429 SystemTime::now()
2430 .duration_since(UNIX_EPOCH)
2431 .unwrap_or_default()
2432 .as_secs()
2433}
2434
2435fn cargo_package_version(project_dir: &Path) -> Option<String> {
2436 let data = fs::read_to_string(project_dir.join("Cargo.toml")).ok()?;
2437 let value: toml::Value = toml::from_str(&data).ok()?;
2438 value
2439 .get("package")
2440 .and_then(|package| package.get("version"))
2441 .and_then(|version| version.as_str())
2442 .map(str::to_string)
2443}
2444
2445fn resolve_project_path(project_dir: &Path, path: String) -> PathBuf {
2446 let path = PathBuf::from(path);
2447 if path.is_absolute() {
2448 path
2449 } else {
2450 project_dir.join(path)
2451 }
2452}
2453
2454fn non_empty(value: Option<String>) -> Option<String> {
2455 value.and_then(|value| {
2456 let trimmed = value.trim();
2457 (!trimmed.is_empty()).then(|| trimmed.to_string())
2458 })
2459}
2460
2461fn expected_github_base_path(cfg: &GithubPagesConfig, repo: Option<&str>) -> String {
2462 if cfg
2463 .custom_domain
2464 .as_deref()
2465 .is_some_and(|value| !value.trim().is_empty())
2466 {
2467 "/".to_string()
2468 } else if cfg.site_kind.as_deref() == Some("user")
2469 || cfg.site_kind.as_deref() == Some("organization")
2470 {
2471 "/".to_string()
2472 } else {
2473 repo.map(|repo| format!("/{repo}/"))
2474 .unwrap_or_else(|| "/".to_string())
2475 }
2476}
2477
2478fn github_pages_url(
2479 cfg: &GithubPagesConfig,
2480 owner: Option<&str>,
2481 repo: Option<&str>,
2482) -> Option<String> {
2483 if let Some(domain) = cfg
2484 .custom_domain
2485 .as_ref()
2486 .filter(|value| !value.trim().is_empty())
2487 {
2488 return Some(format!("https://{}", domain.trim()));
2489 }
2490 let owner = owner?;
2491 if cfg.site_kind.as_deref() == Some("user") || cfg.site_kind.as_deref() == Some("organization")
2492 {
2493 Some(format!("https://{owner}.github.io/"))
2494 } else {
2495 repo.map(|repo| format!("https://{owner}.github.io/{repo}/"))
2496 }
2497}
2498
2499fn cloudflare_url(cfg: &CloudflarePagesConfig) -> Option<String> {
2500 if let Some(domain) = cfg
2501 .custom_domain
2502 .as_ref()
2503 .filter(|value| !value.trim().is_empty())
2504 {
2505 Some(format!("https://{}", domain.trim()))
2506 } else {
2507 cfg.project_name
2508 .as_ref()
2509 .map(|name| format!("https://{name}.pages.dev"))
2510 }
2511}
2512
2513fn github_pages_follow_up(cfg: &GithubPagesConfig) -> Vec<String> {
2514 let mut follow_up = Vec::new();
2515 if cfg
2516 .custom_domain
2517 .as_deref()
2518 .filter(|value| !value.is_empty())
2519 .is_some()
2520 {
2521 follow_up.push(
2522 "Verify the GitHub Pages custom domain and HTTPS state in repository settings."
2523 .to_string(),
2524 );
2525 }
2526 follow_up
2527}
2528
2529fn infer_github_owner(project_dir: &Path) -> Option<String> {
2530 parse_github_remote(project_dir).map(|(owner, _)| owner)
2531}
2532
2533fn infer_github_repo(project_dir: &Path) -> Option<String> {
2534 parse_github_remote(project_dir).map(|(_, repo)| repo)
2535}
2536
2537fn parse_github_remote(project_dir: &Path) -> Option<(String, String)> {
2538 let remote = git_output(project_dir, ["remote", "get-url", "origin"]).ok()?;
2539 let remote = remote.trim().trim_end_matches(".git");
2540 if let Some(rest) = remote.strip_prefix("git@github.com:") {
2541 let (owner, repo) = rest.split_once('/')?;
2542 return Some((owner.to_string(), repo.to_string()));
2543 }
2544 if let Some(rest) = remote.strip_prefix("https://github.com/") {
2545 let (owner, repo) = rest.split_once('/')?;
2546 return Some((owner.to_string(), repo.to_string()));
2547 }
2548 None
2549}
2550
2551fn git_repo_root(project_dir: &Path) -> Option<PathBuf> {
2552 git_output(project_dir, ["rev-parse", "--show-toplevel"])
2553 .ok()
2554 .map(|value| PathBuf::from(value.trim()))
2555}
2556
2557fn git_output<'a, I>(dir: &Path, args: I) -> Result<String>
2558where
2559 I: IntoIterator<Item = &'a str>,
2560{
2561 let output = Command::new("git")
2562 .args(args)
2563 .current_dir(dir)
2564 .output()
2565 .context("failed to run git")?;
2566 if !output.status.success() {
2567 bail!(
2568 "git command failed: {}",
2569 String::from_utf8_lossy(&output.stderr)
2570 );
2571 }
2572 Ok(String::from_utf8_lossy(&output.stdout).to_string())
2573}
2574
2575fn run_git<'a, I>(dir: &Path, args: I) -> Result<()>
2576where
2577 I: IntoIterator<Item = &'a str>,
2578{
2579 let status = Command::new("git")
2580 .args(args)
2581 .current_dir(dir)
2582 .status()
2583 .context("failed to run git")?;
2584 if !status.success() {
2585 bail!("git command failed with {status}");
2586 }
2587 Ok(())
2588}
2589
2590fn first_url(text: &str) -> Option<String> {
2591 text.split_whitespace()
2592 .find(|part| part.starts_with("https://") || part.starts_with("http://"))
2593 .map(|value| {
2594 value
2595 .trim_matches(|c| c == ',' || c == ')' || c == '(')
2596 .to_string()
2597 })
2598}
2599
2600#[cfg(test)]
2601mod tests {
2602 use super::*;
2603
2604 fn unique_dir(name: &str) -> PathBuf {
2605 let dir =
2606 std::env::temp_dir().join(format!("fission-publish-{}-{}", name, std::process::id()));
2607 let _ = fs::remove_dir_all(&dir);
2608 dir
2609 }
2610
2611 fn write_minimal_site(dir: &Path) {
2612 fs::create_dir_all(dir.join("content")).unwrap();
2613 fs::write(
2614 dir.join("fission.toml"),
2615 r#"targets = ["site"]
2616
2617[app]
2618name = "site-demo"
2619app_id = "com.example.site_demo"
2620
2621[site]
2622title = "Site Demo"
2623out_dir = "dist/site"
2624generate_sitemap = false
2625generate_robots = false
2626
2627[distribution.github_pages.production]
2628owner = "example"
2629repo = "site-demo"
2630mode = "actions"
2631site_kind = "project"
2632base_path = "/site-demo/"
2633
2634[distribution.github_releases.production]
2635owner = "example"
2636repo = "site-demo"
2637tag = "v1.2.3"
2638name = "Site Demo 1.2.3"
2639draft = true
2640prerelease = false
2641replace_assets = true
2642upload_artifact_manifest = true
2643"#,
2644 )
2645 .unwrap();
2646 fs::write(
2647 dir.join("content/index.md"),
2648 "---\ntitle: Home\n---\n# Home\n",
2649 )
2650 .unwrap();
2651 }
2652
2653 #[test]
2654 fn static_package_builds_artifact_manifest() {
2655 let dir = unique_dir("package");
2656 write_minimal_site(&dir);
2657 let manifest = package::package_static(&PackageOptions {
2658 project_dir: dir.clone(),
2659 target: Target::Site,
2660 format: PackageFormat::Static,
2661 release: true,
2662 json: false,
2663 })
2664 .unwrap();
2665 assert_eq!(manifest.target, "site");
2666 assert!(dir
2667 .join("target/fission/release/site/static/artifact-manifest.json")
2668 .exists());
2669 assert!(manifest
2670 .artifacts
2671 .iter()
2672 .any(|file| file.relative_path == "index.html"));
2673 assert!(dir
2674 .join("target/fission/release/site/static/fission-route-manifest.json")
2675 .exists());
2676 assert!(dir
2677 .join("target/fission/release/site/static/fission-mime-map.json")
2678 .exists());
2679 assert!(manifest
2680 .validation
2681 .checks
2682 .iter()
2683 .any(|check| check.id == "release.package.artifact.primary_present"));
2684 }
2685
2686 #[test]
2687 fn static_package_includes_configured_secondary_artifacts() {
2688 let dir = unique_dir("secondary-artifacts");
2689 write_minimal_site(&dir);
2690 fs::create_dir_all(dir.join("release-content/symbols")).unwrap();
2691 fs::write(dir.join("release-content/symbols/app.dSYM.zip"), b"symbols").unwrap();
2692 let mut toml = fs::read_to_string(dir.join("fission.toml")).unwrap();
2693 toml.push_str(
2694 r#"
2695[[package.symbols]]
2696path = "release-content/symbols/app.dSYM.zip"
2697platform = "ios"
2698upload_provider = "crash-service"
2699"#,
2700 );
2701 fs::write(dir.join("fission.toml"), toml).unwrap();
2702
2703 let manifest = package::package_static(&PackageOptions {
2704 project_dir: dir.clone(),
2705 target: Target::Site,
2706 format: PackageFormat::Static,
2707 release: true,
2708 json: false,
2709 })
2710 .unwrap();
2711
2712 let symbols = manifest
2713 .artifacts
2714 .iter()
2715 .find(|file| file.kind == "debug_symbols")
2716 .expect("debug symbols should be present");
2717 assert_eq!(symbols.platform.as_deref(), Some("ios"));
2718 assert_eq!(symbols.upload_provider.as_deref(), Some("crash-service"));
2719 }
2720
2721 #[test]
2722 fn github_pages_setup_writes_workflow() {
2723 let dir = unique_dir("github-setup");
2724 write_minimal_site(&dir);
2725 let config = load_publish_manifest(&dir).unwrap();
2726 setup_github_pages(
2727 &DistributeOptions {
2728 project_dir: dir.clone(),
2729 provider: DistributionProvider::GithubPages,
2730 action: DistributeAction::Setup,
2731 artifact: None,
2732 site: "production".to_string(),
2733 deploy: None,
2734 track: None,
2735 dry_run: false,
2736 yes: true,
2737 json: false,
2738 },
2739 &config,
2740 )
2741 .unwrap();
2742 let workflow = fs::read_to_string(dir.join(".github/workflows/fission-pages.yml")).unwrap();
2743 assert!(workflow.contains("actions/upload-pages-artifact"));
2744 assert!(workflow.contains("actions/deploy-pages"));
2745 assert!(workflow.contains("fission package"));
2746 }
2747
2748 #[test]
2749 fn github_base_path_accounts_for_custom_domain() {
2750 let cfg = GithubPagesConfig {
2751 custom_domain: Some("docs.example.com".to_string()),
2752 repo: Some("repo".to_string()),
2753 ..Default::default()
2754 };
2755 assert_eq!(expected_github_base_path(&cfg, Some("repo")), "/");
2756 let cfg = GithubPagesConfig {
2757 repo: Some("repo".to_string()),
2758 ..Default::default()
2759 };
2760 assert_eq!(expected_github_base_path(&cfg, Some("repo")), "/repo/");
2761 }
2762
2763 #[test]
2764 fn android_aab_readiness_checks_official_toolchain() {
2765 let dir = unique_dir("android-aab-readiness");
2766 write_minimal_site(&dir);
2767 let checks = readiness_package(&dir, Some(Target::Android), Some(PackageFormat::Aab))
2768 .expect("readiness should produce checks even when blocked");
2769 for id in [
2770 "release.package.android_aab_script_exists",
2771 "release.package.android_sdk_configured",
2772 "release.package.android_ndk_configured",
2773 "release.package.aapt2_available",
2774 "release.package.zipalign_available",
2775 "release.package.apksigner_available",
2776 "release.package.bundletool_available",
2777 ] {
2778 assert!(checks.iter().any(|check| check.id == id), "missing {id}");
2779 }
2780 }
2781
2782 #[test]
2783 fn cloudflare_readiness_requires_wrangler_backend() {
2784 let dir = unique_dir("cloudflare-readiness");
2785 write_minimal_site(&dir);
2786 let mut toml = fs::read_to_string(dir.join("fission.toml")).unwrap();
2787 toml.push_str(
2788 r#"
2789[distribution.cloudflare_pages.production]
2790account_id = "account"
2791project_name = "site-demo"
2792"#,
2793 );
2794 fs::write(dir.join("fission.toml"), toml).unwrap();
2795 let config = load_publish_manifest(&dir).unwrap();
2796 let checks = readiness_distribute(
2797 &dir,
2798 DistributionProvider::CloudflarePages,
2799 "production",
2800 None,
2801 None,
2802 &config,
2803 )
2804 .unwrap();
2805 assert!(checks
2806 .iter()
2807 .any(|check| check.id == "release.cloudflare_pages.wrangler_available"));
2808 }
2809
2810 #[test]
2811 fn github_releases_readiness_is_not_static_site_specific() {
2812 let dir = unique_dir("github-releases-readiness");
2813 write_minimal_site(&dir);
2814 let artifact_root = dir.join("target/fission/release/linux/run");
2815 fs::create_dir_all(&artifact_root).unwrap();
2816 let binary = artifact_root.join("site-demo.run");
2817 fs::write(&binary, b"run").unwrap();
2818 let manifest_path = artifact_root.join(ARTIFACT_MANIFEST);
2819 fs::write(
2820 &manifest_path,
2821 serde_json::to_vec_pretty(&ArtifactManifest {
2822 schema_version: 1,
2823 created_at_unix_seconds: 0,
2824 project: ArtifactProject {
2825 app_id: "com.example.site_demo".to_string(),
2826 name: "site-demo".to_string(),
2827 version: Some("1.2.3".to_string()),
2828 },
2829 target: "linux".to_string(),
2830 format: "run".to_string(),
2831 profile: "release".to_string(),
2832 root_dir: artifact_root.display().to_string(),
2833 artifacts: vec![ArtifactFile {
2834 kind: "asset".to_string(),
2835 purpose: None,
2836 platform: None,
2837 upload_provider: None,
2838 path: binary.display().to_string(),
2839 relative_path: "site-demo.run".to_string(),
2840 sha256: "abc".to_string(),
2841 size_bytes: 3,
2842 mime_type: "application/octet-stream".to_string(),
2843 }],
2844 validation: ArtifactValidation {
2845 state: "passed".to_string(),
2846 checks: Vec::new(),
2847 },
2848 })
2849 .unwrap(),
2850 )
2851 .unwrap();
2852 let config = load_publish_manifest(&dir).unwrap();
2853 let checks = readiness_distribute(
2854 &dir,
2855 DistributionProvider::GithubReleases,
2856 "production",
2857 None,
2858 Some(&manifest_path),
2859 &config,
2860 )
2861 .unwrap();
2862 assert!(checks.iter().any(|check| {
2863 check.id == "release.github_releases.assets_available"
2864 && check.status == CheckStatus::Passed
2865 }));
2866 assert!(!checks
2867 .iter()
2868 .any(|check| check.id == "release.distribution.static_root_exists"));
2869 }
2870
2871 #[test]
2872 fn microsoft_store_msix_readiness_uses_msstore_not_package_url() {
2873 let dir = unique_dir("microsoft-msix-readiness");
2874 write_minimal_site(&dir);
2875 let mut toml = fs::read_to_string(dir.join("fission.toml")).unwrap();
2876 toml.push_str(
2877 r#"
2878[distribution.microsoft_store]
2879product_id = "9N1234567890"
2880package_identity_name = "Example.SiteDemo"
2881package_type = "msix"
2882"#,
2883 );
2884 fs::write(dir.join("fission.toml"), toml).unwrap();
2885
2886 let artifact_root = dir.join("target/fission/release/windows/msix");
2887 fs::create_dir_all(&artifact_root).unwrap();
2888 let package = artifact_root.join("site-demo.msixupload");
2889 fs::write(&package, b"msix").unwrap();
2890 let manifest_path = artifact_root.join(ARTIFACT_MANIFEST);
2891 fs::write(
2892 &manifest_path,
2893 serde_json::to_vec_pretty(&ArtifactManifest {
2894 schema_version: 1,
2895 created_at_unix_seconds: 0,
2896 project: ArtifactProject {
2897 app_id: "com.example.site_demo".to_string(),
2898 name: "site-demo".to_string(),
2899 version: Some("1.2.3".to_string()),
2900 },
2901 target: "windows".to_string(),
2902 format: "msix".to_string(),
2903 profile: "release".to_string(),
2904 root_dir: artifact_root.display().to_string(),
2905 artifacts: vec![ArtifactFile {
2906 kind: "installer".to_string(),
2907 purpose: Some("store-upload".to_string()),
2908 platform: Some("windows".to_string()),
2909 upload_provider: Some("microsoft-store".to_string()),
2910 path: package.display().to_string(),
2911 relative_path: "site-demo.msixupload".to_string(),
2912 sha256: "abc".to_string(),
2913 size_bytes: 4,
2914 mime_type: "application/vnd.ms-appx".to_string(),
2915 }],
2916 validation: ArtifactValidation {
2917 state: "passed".to_string(),
2918 checks: Vec::new(),
2919 },
2920 })
2921 .unwrap(),
2922 )
2923 .unwrap();
2924
2925 let config = load_publish_manifest(&dir).unwrap();
2926 let checks = readiness_distribute(
2927 &dir,
2928 DistributionProvider::MicrosoftStore,
2929 "production",
2930 None,
2931 Some(&manifest_path),
2932 &config,
2933 )
2934 .unwrap();
2935
2936 assert!(checks
2937 .iter()
2938 .any(|check| check.id == "release.microsoft_store.msstore_available"));
2939 assert!(checks.iter().any(|check| {
2940 check.id == "release.microsoft_store.msix_upload_artifact_present"
2941 && check.status == CheckStatus::Passed
2942 }));
2943 assert!(!checks
2944 .iter()
2945 .any(|check| check.id == "release.microsoft_store.package_url_configured"));
2946 }
2947}