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