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