Skip to main content

fission_command_package/
lib.rs

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}