use super::*;
use anyhow::{bail, Context, Result};
use fission_credentials as credentials;
use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
use reqwest::blocking::{Client, Response};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::Duration;
const PLAY_API: &str = "https://androidpublisher.googleapis.com";
const PLAY_UPLOAD_API: &str = "https://androidpublisher.googleapis.com/upload";
const GOOGLE_PLAY_SCOPE: &str = "https://www.googleapis.com/auth/androidpublisher";
const GOOGLE_TOKEN_URI: &str = "https://oauth2.googleapis.com/token";
const APP_STORE_API_PRIVATE_KEYS_DIR: &str = "API_PRIVATE_KEYS_DIR";
const MICROSOFT_STORE_API: &str = "https://api.store.microsoft.com";
const APP_STORE_API: &str = "https://api.appstoreconnect.apple.com";
const MICROSOFT_STORE_SCOPE: &str = "https://api.store.microsoft.com/.default";
const MICROSOFT_STORE_MSIX_TYPES: &[&str] = &["msix", "msixupload"];
#[derive(Debug, Deserialize)]
struct GoogleServiceAccount {
client_email: String,
private_key: String,
#[serde(default)]
token_uri: Option<String>,
}
#[derive(Debug, Serialize)]
struct GoogleJwtClaims<'a> {
iss: &'a str,
scope: &'a str,
aud: &'a str,
iat: u64,
exp: u64,
}
#[derive(Debug, Serialize)]
struct AppStoreJwtClaims<'a> {
iss: &'a str,
aud: &'a str,
iat: u64,
exp: u64,
}
#[derive(Debug, Deserialize)]
struct OAuthTokenResponse {
access_token: String,
#[serde(default)]
token_type: Option<String>,
#[serde(default)]
expires_in: Option<u64>,
}
pub(super) fn publish_play_store(
options: &DistributeOptions,
config: &PublishManifest,
artifact_path: &Path,
manifest: &ArtifactManifest,
) -> Result<DistributionReceipt> {
let cfg = play_store_config(config);
let package_name = cfg
.package_name
.as_deref()
.or_else(|| package_name_from_project(manifest))
.context("distribution.play_store.package_name is required")?;
let track = options
.track
.as_deref()
.or(cfg.default_track.as_deref())
.unwrap_or("internal");
let release_status = cfg.release_status.as_deref().unwrap_or("completed");
let artifact = primary_artifact_with_extensions(manifest, &["aab", "apk"])?;
let artifact_kind = artifact
.extension()
.and_then(|value| value.to_str())
.unwrap_or_default();
if options.dry_run {
return Ok(store_receipt(
options,
"play-store",
artifact_path,
"dry-run",
None,
Some(format!(
"https://play.google.com/console/u/0/developers/app/{package_name}/tracks/{track}"
)),
vec![format!(
"Would upload {} to Google Play package {package_name} track {track} with release status {release_status}.",
artifact.display()
)],
));
}
let client = http_client()?;
let token = google_play_access_token(&cfg, &client)?;
let edit_id = create_play_edit(&client, &token, package_name)?;
let version_code = upload_play_artifact(
&client,
&token,
package_name,
&edit_id,
&artifact,
artifact_kind,
)?;
update_play_track(
&client,
&token,
package_name,
&edit_id,
track,
release_status,
&version_code,
)?;
validate_play_edit(&client, &token, package_name, &edit_id)?;
commit_play_edit(&client, &token, package_name, &edit_id)?;
Ok(store_receipt(
options,
"play-store",
artifact_path,
"published",
Some(format!("edit:{edit_id}/version:{version_code}")),
Some(format!(
"https://play.google.com/console/u/0/developers/app/{package_name}/tracks/{track}"
)),
vec![format!(
"Google Play accepted version code {version_code} on track {track}; provider-side review or processing may still apply."
)],
))
}
pub(super) fn publish_app_store(
options: &DistributeOptions,
config: &PublishManifest,
artifact_path: &Path,
manifest: &ArtifactManifest,
) -> Result<DistributionReceipt> {
let cfg = app_store_config(config);
let issuer_id = env_value("APP_STORE_CONNECT_ISSUER_ID")
.or(cfg.issuer_id.clone())
.context("distribution.app_store.issuer_id or APP_STORE_CONNECT_ISSUER_ID is required")?;
let key_id = env_value("APP_STORE_CONNECT_KEY_ID")
.or(cfg.key_id.clone())
.context("distribution.app_store.key_id or APP_STORE_CONNECT_KEY_ID is required")?;
let api_key_path = env_value("APP_STORE_CONNECT_API_KEY_PATH").or(cfg.api_key_path.clone());
let ipa = primary_artifact_with_extensions(manifest, &["ipa"])?;
let track = options
.track
.as_deref()
.or(cfg.default_track.as_deref())
.unwrap_or("testflight");
if options.dry_run {
return Ok(store_receipt(
options,
"app-store",
artifact_path,
"dry-run",
None,
Some("https://appstoreconnect.apple.com/apps".to_string()),
vec![format!(
"Would upload {} to App Store Connect with API key {key_id} for track {track}.",
ipa.display()
)],
));
}
let mut command = Command::new("xcrun");
command
.args([
"altool",
"--upload-app",
"-f",
ipa.to_string_lossy().as_ref(),
"-t",
"ios",
"--apiKey",
&key_id,
"--apiIssuer",
&issuer_id,
"--output-format",
"json",
])
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if let Some(api_key_path) = api_key_path
.as_deref()
.filter(|value| !value.trim().is_empty())
{
let path = Path::new(api_key_path);
if let Some(parent) = path.parent() {
command.env(APP_STORE_API_PRIVATE_KEYS_DIR, parent);
}
}
let output = command
.output()
.context("failed to run xcrun altool; install Xcode and App Store Connect upload tools")?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
if !output.status.success() {
bail!(
"App Store Connect upload failed with {}: {}",
output.status,
stderr.trim()
);
}
Ok(DistributionReceipt {
schema_version: 1,
created_at_unix_seconds: now_unix_seconds(),
provider: "app-store".to_string(),
site: options.site.clone(),
action: "publish".to_string(),
artifact_manifest: Some(artifact_path.display().to_string()),
deployment_id: None,
canonical_url: Some("https://appstoreconnect.apple.com/apps".to_string()),
preview_url: None,
custom_domain: None,
status: "uploaded".to_string(),
stdout: (!stdout.trim().is_empty()).then_some(stdout),
stderr: (!stderr.trim().is_empty()).then_some(stderr),
manual_follow_up: vec![format!(
"App Store Connect accepted the upload; wait for build processing, then assign the build to {track} or App Review."
)],
})
}
pub(super) fn app_store_status(
options: &DistributeOptions,
config: &PublishManifest,
) -> Result<DistributionReceipt> {
let cfg = app_store_config(config);
let client = http_client()?;
let token = app_store_access_token(&cfg)?;
let app_id = app_store_app_id(&cfg, &client, &token)?;
let url = format!(
"{APP_STORE_API}/v1/apps/{app_id}/builds?limit=10&sort=-uploadedDate&fields[builds]=version,uploadedDate,processingState,expired,minOsVersion,usesNonExemptEncryption"
);
let response = client
.get(url)
.bearer_auth(token)
.send()
.context("failed to query App Store Connect build status")?;
let value = json_response(response, "App Store Connect build status")?;
Ok(DistributionReceipt {
schema_version: 1,
created_at_unix_seconds: now_unix_seconds(),
provider: "app-store".to_string(),
site: options.site.clone(),
action: "status".to_string(),
artifact_manifest: None,
deployment_id: Some(app_id),
canonical_url: Some("https://appstoreconnect.apple.com/apps".to_string()),
preview_url: None,
custom_domain: None,
status: "ok".to_string(),
stdout: Some(serde_json::to_string_pretty(&value)?),
stderr: None,
manual_follow_up: Vec::new(),
})
}
pub(super) fn publish_microsoft_store(
options: &DistributeOptions,
config: &PublishManifest,
artifact_path: &Path,
manifest: &ArtifactManifest,
) -> Result<DistributionReceipt> {
let cfg = microsoft_store_config(config);
let product_id = cfg
.product_id
.as_deref()
.context("distribution.microsoft_store.product_id is required")?;
let package_type = microsoft_store_package_type(&cfg, manifest);
if is_microsoft_store_msix_type(&package_type) {
return publish_microsoft_store_msix(options, &cfg, product_id, artifact_path, manifest);
}
let seller_id = microsoft_store_seller_id(&cfg).context(
"distribution.microsoft_store.seller_id, MICROSOFT_STORE_SELLER_ID, or PARTNER_CENTER_SELLER_ID is required",
)?;
let package_url = options
.deploy
.as_deref()
.filter(|value| value.starts_with("https://") || value.starts_with("http://"))
.map(str::to_string)
.or(cfg.package_url.clone())
.context("Microsoft Store MSI/EXE submission requires a package_url in fission.toml or --deploy <https-url>; publish the artifact to S3/static hosting first")?;
if !matches!(package_type.as_str(), "exe" | "msi") {
bail!(
"Microsoft Store direct package automation supports exe/msi through the Store submission API and msix/msixupload through msstore; package_type `{package_type}` is unsupported"
);
}
if options.dry_run {
return Ok(store_receipt(
options,
"microsoft-store",
artifact_path,
"dry-run",
None,
Some(format!("https://partner.microsoft.com/dashboard/products/{product_id}")),
vec![format!(
"Would update Microsoft Store package metadata for product {product_id} with {package_url}."
)],
));
}
let client = http_client()?;
let token = microsoft_store_access_token(&cfg, &client)?;
let packages = json!({
"packages": [{
"packageUrl": package_url,
"languages": cfg.languages.clone().unwrap_or_else(|| vec!["en-us".to_string()]),
"architectures": cfg.architectures.clone().unwrap_or_else(|| vec!["Neutral".to_string()]),
"isSilentInstall": cfg.is_silent_install.unwrap_or(true),
"installerParameters": cfg.installer_parameters.clone().unwrap_or_default(),
"genericDocUrl": cfg.generic_doc_url.clone().unwrap_or_default(),
"packageType": package_type,
}]
});
let packages_url = format!("{MICROSOFT_STORE_API}/submission/v1/product/{product_id}/packages");
let package_response = client
.put(&packages_url)
.bearer_auth(&token)
.header("X-Seller-Account-Id", &seller_id)
.json(&packages)
.send()
.context("failed to update Microsoft Store package metadata")?;
let package_value = json_response(package_response, "Microsoft Store package update")?;
microsoft_store_success(&package_value, "Microsoft Store package update")?;
let commit_url =
format!("{MICROSOFT_STORE_API}/submission/v1/product/{product_id}/packages/commit");
let commit_response = client
.post(&commit_url)
.bearer_auth(&token)
.header("X-Seller-Account-Id", &seller_id)
.send()
.context("failed to commit Microsoft Store package metadata")?;
let commit_value = json_response(commit_response, "Microsoft Store package commit")?;
microsoft_store_success(&commit_value, "Microsoft Store package commit")?;
let polling_url = commit_value
.pointer("/responseData/pollingUrl")
.and_then(Value::as_str)
.map(|value| {
if value.starts_with("http") {
value.to_string()
} else {
format!("{MICROSOFT_STORE_API}{value}")
}
});
let mut follow_up = vec![
"Microsoft Store package update was committed; poll Partner Center processing before submitting to certification.".to_string(),
];
let mut status = "package-committed".to_string();
if cfg.submit.unwrap_or(false) || options.track.as_deref() == Some("public") && options.yes {
let submit_url = format!("{MICROSOFT_STORE_API}/submission/v1/product/{product_id}/submit");
let submit_response = client
.post(&submit_url)
.bearer_auth(&token)
.header("X-Seller-Account-Id", &seller_id)
.send()
.context("failed to create Microsoft Store submission")?;
let submit_value = json_response(submit_response, "Microsoft Store submission")?;
microsoft_store_success(&submit_value, "Microsoft Store submission")?;
status = "submitted".to_string();
follow_up.push("Microsoft Store submission was created; certification/review continues in Partner Center.".to_string());
} else {
follow_up.push("Set distribution.microsoft_store.submit = true or pass --track public --yes when you are ready to submit the draft to certification.".to_string());
}
Ok(store_receipt(
options,
"microsoft-store",
artifact_path,
&status,
polling_url,
Some(format!(
"https://partner.microsoft.com/dashboard/products/{product_id}"
)),
follow_up,
))
}
fn publish_microsoft_store_msix(
options: &DistributeOptions,
cfg: &MicrosoftStoreConfig,
product_id: &str,
artifact_path: &Path,
manifest: &ArtifactManifest,
) -> Result<DistributionReceipt> {
let artifact = primary_artifact_with_extensions(manifest, MICROSOFT_STORE_MSIX_TYPES)?;
let flight_id = microsoft_store_flight_id(options.track.as_deref(), cfg)?;
let rollout = microsoft_store_rollout_percentage(cfg)?;
let should_submit = microsoft_store_should_submit(options, cfg);
let project_path = microsoft_store_msstore_project(options, cfg);
let publish_args = msstore_publish_args(
&project_path,
&artifact,
product_id,
flight_id.as_deref(),
rollout,
should_submit,
);
if options.dry_run {
return Ok(store_receipt(
options,
"microsoft-store",
artifact_path,
"dry-run",
None,
Some(format!(
"https://partner.microsoft.com/dashboard/products/{product_id}"
)),
vec![format!(
"Would run `{}` to publish {} through Microsoft Store Developer CLI.",
command_line("msstore", &publish_args),
artifact.display()
)],
));
}
let mut stdout_parts = Vec::new();
let mut stderr_parts = Vec::new();
if cfg.msstore_reconfigure.unwrap_or(false) {
let (stdout, stderr) = run_msstore_reconfigure(cfg)?;
if !stdout.trim().is_empty() {
stdout_parts.push(stdout);
}
if !stderr.trim().is_empty() {
stderr_parts.push(stderr);
}
}
let (stdout, stderr) = run_msstore(&publish_args, "Microsoft Store MSIX publish")?;
if !stdout.trim().is_empty() {
stdout_parts.push(stdout);
}
if !stderr.trim().is_empty() {
stderr_parts.push(stderr);
}
Ok(DistributionReceipt {
schema_version: 1,
created_at_unix_seconds: now_unix_seconds(),
provider: "microsoft-store".to_string(),
site: options.site.clone(),
action: "publish".to_string(),
artifact_manifest: Some(artifact_path.display().to_string()),
deployment_id: flight_id
.map(|flight| format!("product:{product_id}/flight:{flight}"))
.or_else(|| Some(format!("product:{product_id}"))),
canonical_url: Some(format!(
"https://partner.microsoft.com/dashboard/products/{product_id}"
)),
preview_url: None,
custom_domain: None,
status: if should_submit {
"submitted".to_string()
} else {
"draft-updated".to_string()
},
stdout: (!stdout_parts.is_empty()).then(|| stdout_parts.join("\n")),
stderr: (!stderr_parts.is_empty()).then(|| stderr_parts.join("\n")),
manual_follow_up: if should_submit {
vec!["Microsoft Store Developer CLI committed the submission; certification/review continues in Partner Center.".to_string()]
} else {
vec!["The MSIX submission remains a Partner Center draft because --noCommit was used. Set distribution.microsoft_store.submit = true or pass --track public --yes when you are ready to commit it.".to_string()]
},
})
}
pub(super) fn play_store_status(
options: &DistributeOptions,
config: &PublishManifest,
) -> Result<DistributionReceipt> {
let cfg = play_store_config(config);
let package_name = cfg
.package_name
.as_deref()
.context("distribution.play_store.package_name is required")?;
let track = options
.track
.as_deref()
.or(cfg.default_track.as_deref())
.unwrap_or("internal");
let client = http_client()?;
let token = google_play_access_token(&cfg, &client)?;
let edit_id = create_play_edit(&client, &token, package_name)?;
let url = format!(
"{PLAY_API}/androidpublisher/v3/applications/{package_name}/edits/{edit_id}/tracks/{track}"
);
let response = client
.get(url)
.bearer_auth(&token)
.send()
.with_context(|| format!("failed to read Google Play track {track}"))?;
let value = json_response(response, "Google Play track get")?;
Ok(DistributionReceipt {
schema_version: 1,
created_at_unix_seconds: now_unix_seconds(),
provider: "play-store".to_string(),
site: options.site.clone(),
action: "status".to_string(),
artifact_manifest: None,
deployment_id: Some(format!("edit:{edit_id}/track:{track}")),
canonical_url: Some(format!(
"https://play.google.com/console/u/0/developers/app/{package_name}/tracks/{track}"
)),
preview_url: None,
custom_domain: None,
status: "ok".to_string(),
stdout: Some(serde_json::to_string_pretty(&value)?),
stderr: None,
manual_follow_up: Vec::new(),
})
}
pub(super) fn microsoft_store_status(
options: &DistributeOptions,
config: &PublishManifest,
) -> Result<DistributionReceipt> {
let cfg = microsoft_store_config(config);
let product_id = cfg
.product_id
.as_deref()
.context("distribution.microsoft_store.product_id is required")?;
if cfg
.package_type
.as_deref()
.map(|value| is_microsoft_store_msix_type(&value.to_ascii_lowercase()))
.unwrap_or(false)
{
return microsoft_store_msix_status(options, &cfg, product_id);
}
let seller_id = microsoft_store_seller_id(&cfg).context(
"distribution.microsoft_store.seller_id, MICROSOFT_STORE_SELLER_ID, or PARTNER_CENTER_SELLER_ID is required",
)?;
let client = http_client()?;
let token = microsoft_store_access_token(&cfg, &client)?;
let url = options
.deploy
.as_deref()
.filter(|value| !value.trim().is_empty())
.map(|value| {
if value.starts_with("http") {
value.to_string()
} else if value.starts_with('/') {
format!("{MICROSOFT_STORE_API}{value}")
} else {
format!("{MICROSOFT_STORE_API}/submission/v1/product/{product_id}/submission/{value}/status")
}
})
.unwrap_or_else(|| format!("{MICROSOFT_STORE_API}/submission/v1/product/{product_id}/status"));
let response = client
.get(url)
.bearer_auth(&token)
.header("X-Seller-Account-Id", &seller_id)
.send()
.context("failed to query Microsoft Store submission status")?;
let value = json_response(response, "Microsoft Store status")?;
microsoft_store_success(&value, "Microsoft Store status")?;
let status = value
.pointer("/responseData/publishingStatus")
.or_else(|| value.pointer("/responseData/packageUploadStatus"))
.and_then(Value::as_str)
.unwrap_or("ok")
.to_ascii_lowercase();
Ok(DistributionReceipt {
schema_version: 1,
created_at_unix_seconds: now_unix_seconds(),
provider: "microsoft-store".to_string(),
site: options.site.clone(),
action: "status".to_string(),
artifact_manifest: None,
deployment_id: options.deploy.clone(),
canonical_url: Some(format!(
"https://partner.microsoft.com/dashboard/products/{product_id}"
)),
preview_url: None,
custom_domain: None,
status,
stdout: Some(serde_json::to_string_pretty(&value)?),
stderr: None,
manual_follow_up: Vec::new(),
})
}
fn microsoft_store_msix_status(
options: &DistributeOptions,
cfg: &MicrosoftStoreConfig,
product_id: &str,
) -> Result<DistributionReceipt> {
let flight_id = microsoft_store_flight_id(options.track.as_deref(), cfg)?;
let args = if let Some(flight_id) = flight_id.as_deref() {
vec![
"flights".to_string(),
"submission".to_string(),
"status".to_string(),
product_id.to_string(),
flight_id.to_string(),
]
} else {
vec![
"submission".to_string(),
"status".to_string(),
product_id.to_string(),
]
};
let (stdout, stderr) = run_msstore(&args, "Microsoft Store MSIX submission status")?;
Ok(DistributionReceipt {
schema_version: 1,
created_at_unix_seconds: now_unix_seconds(),
provider: "microsoft-store".to_string(),
site: options.site.clone(),
action: "status".to_string(),
artifact_manifest: None,
deployment_id: flight_id
.map(|flight| format!("product:{product_id}/flight:{flight}"))
.or_else(|| Some(format!("product:{product_id}"))),
canonical_url: Some(format!(
"https://partner.microsoft.com/dashboard/products/{product_id}"
)),
preview_url: None,
custom_domain: None,
status: "ok".to_string(),
stdout: (!stdout.trim().is_empty()).then_some(stdout),
stderr: (!stderr.trim().is_empty()).then_some(stderr),
manual_follow_up: Vec::new(),
})
}
pub(super) fn readiness_play_store(
track: Option<&str>,
artifact: Option<&Path>,
config: &PublishManifest,
checks: &mut Vec<ReadinessCheck>,
) -> Result<()> {
let cfg = play_store_config(config);
checks.push(required_value(
"release.play_store.package_name_configured",
cfg.package_name.as_deref(),
"Google Play package name is configured",
"Set distribution.play_store.package_name to the Android application id registered in Play Console.",
));
checks.push(secret_check(
"release.play_store.credentials_available",
&["PLAY_STORE_ACCESS_TOKEN", "PLAY_STORE_SERVICE_ACCOUNT_JSON", "GOOGLE_APPLICATION_CREDENTIALS"],
DistributionProvider::PlayStore,
"Set PLAY_STORE_SERVICE_ACCOUNT_JSON to a service-account JSON path/value, set PLAY_STORE_ACCESS_TOKEN, or import credentials with `fission auth import play-store --from file:<service-account.json> --yes`.",
));
let selected_track = track.or(cfg.default_track.as_deref()).unwrap_or("internal");
checks.push(check(
"release.play_store.track_supported",
CheckSeverity::Error,
if matches!(selected_track, "internal" | "closed" | "open" | "production") {
CheckStatus::Passed
} else {
CheckStatus::Failed
},
"Google Play track is supported",
Some(selected_track.to_string()),
vec!["Use internal, closed, open, or production. Internal app sharing will be a separate explicit provider mode."],
));
if let Some(path) = artifact.filter(|path| path.exists()) {
let manifest = read_artifact_manifest(path)?;
checks.push(artifact_format_check(
"release.play_store.artifact_format",
&manifest,
&["aab", "apk"],
"Google Play accepts Android App Bundles for production publishing and APKs for legacy/test flows.",
));
}
checks.push(check(
"release.play_store.first_setup_manual_steps",
CheckSeverity::Warning,
CheckStatus::Warning,
"first Google Play setup may require Play Console work",
cfg.package_name.clone(),
vec!["Create the Play Console app, configure Play App Signing, complete policy/listing/data-safety requirements, and grant the service account access before first automation."],
));
Ok(())
}
pub(super) fn readiness_app_store(
track: Option<&str>,
artifact: Option<&Path>,
config: &PublishManifest,
checks: &mut Vec<ReadinessCheck>,
) -> Result<()> {
let cfg = app_store_config(config);
checks.push(required_value(
"release.app_store.bundle_id_configured",
cfg.bundle_id.as_deref(),
"App Store bundle id is configured",
"Set distribution.app_store.bundle_id to the Bundle ID registered in App Store Connect.",
));
checks.push(required_value(
"release.app_store.issuer_id_configured",
cfg.issuer_id
.as_deref()
.or_else(|| env_value_ref("APP_STORE_CONNECT_ISSUER_ID")),
"App Store Connect issuer id is configured",
"Set distribution.app_store.issuer_id or APP_STORE_CONNECT_ISSUER_ID.",
));
checks.push(required_value(
"release.app_store.key_id_configured",
cfg.key_id
.as_deref()
.or_else(|| env_value_ref("APP_STORE_CONNECT_KEY_ID")),
"App Store Connect key id is configured",
"Set distribution.app_store.key_id or APP_STORE_CONNECT_KEY_ID.",
));
checks.push(secret_check(
"release.app_store.credentials_available",
&["APP_STORE_CONNECT_API_KEY", "APP_STORE_CONNECT_API_KEY_PATH"],
DistributionProvider::AppStore,
"Set APP_STORE_CONNECT_API_KEY_PATH to AuthKey_<KEYID>.p8, set APP_STORE_CONNECT_API_KEY, or import credentials with `fission auth import app-store`.",
));
checks.push(check_tool(
"release.app_store.xcrun_available",
"xcrun",
"Install Xcode and select it with xcode-select before uploading IPA files.",
));
let selected_track = track
.or(cfg.default_track.as_deref())
.unwrap_or("testflight");
checks.push(check(
"release.app_store.track_supported",
CheckSeverity::Error,
if matches!(
selected_track,
"testflight" | "app-store-review" | "app-store-release"
) {
CheckStatus::Passed
} else {
CheckStatus::Failed
},
"App Store destination is supported",
Some(selected_track.to_string()),
vec!["Use testflight, app-store-review, or app-store-release."],
));
if let Some(path) = artifact.filter(|path| path.exists()) {
let manifest = read_artifact_manifest(path)?;
checks.push(artifact_format_check(
"release.app_store.artifact_format",
&manifest,
&["ipa"],
"App Store Connect binary upload requires an IPA artifact.",
));
}
checks.push(check(
"release.app_store.first_setup_manual_steps",
CheckSeverity::Warning,
CheckStatus::Warning,
"first App Store setup may require App Store Connect work",
cfg.bundle_id.clone(),
vec!["Create the Bundle ID, certificates, provisioning profiles, App Store Connect app record, metadata, privacy, pricing, and beta groups before first automation."],
));
Ok(())
}
pub(super) fn readiness_microsoft_store(
track: Option<&str>,
artifact: Option<&Path>,
config: &PublishManifest,
checks: &mut Vec<ReadinessCheck>,
) -> Result<()> {
let cfg = microsoft_store_config(config);
let artifact_manifest = artifact
.filter(|path| path.exists())
.map(read_artifact_manifest)
.transpose()?;
let package_type = artifact_manifest
.as_ref()
.map(|manifest| microsoft_store_package_type(&cfg, manifest))
.or_else(|| {
cfg.package_type
.clone()
.map(|value| value.to_ascii_lowercase())
})
.unwrap_or_else(|| "exe".to_string());
let uses_msix = is_microsoft_store_msix_type(&package_type);
checks.push(required_value(
"release.microsoft_store.product_id_configured",
cfg.product_id.as_deref(),
"Microsoft Store product id is configured",
"Set distribution.microsoft_store.product_id after reserving the product in Partner Center.",
));
checks.push(required_value(
"release.microsoft_store.package_identity_configured",
cfg.package_identity_name.as_deref(),
"Microsoft Store package identity name is configured",
"Set distribution.microsoft_store.package_identity_name to the Partner Center package identity.",
));
if uses_msix {
checks.push(check_tool(
"release.microsoft_store.msstore_available",
"msstore",
"Install Microsoft Store Developer CLI, run `msstore` once to configure it, or set distribution.microsoft_store.msstore_reconfigure = true with Partner Center credentials.",
));
checks.push(check(
"release.microsoft_store.msix_uses_msstore",
CheckSeverity::Info,
CheckStatus::Passed,
"MSIX submission uses Microsoft Store Developer CLI",
Some(package_type.clone()),
vec!["Fission calls `msstore publish --inputFile ... --appId ...`; no durable package_url is required for MSIX/MSIXUPLOAD submissions."],
));
if cfg.msstore_reconfigure.unwrap_or(false) {
checks.push(required_value(
"release.microsoft_store.seller_id_configured",
microsoft_store_seller_id(&cfg).as_deref(),
"Microsoft Store seller id is configured for msstore reconfigure",
"Set distribution.microsoft_store.seller_id, MICROSOFT_STORE_SELLER_ID, or PARTNER_CENTER_SELLER_ID.",
));
checks.push(required_value(
"release.microsoft_store.tenant_id_configured",
microsoft_store_tenant_id(&cfg).as_deref(),
"Microsoft Entra tenant id is configured for msstore reconfigure",
"Set distribution.microsoft_store.tenant_id, AZURE_TENANT_ID, or PARTNER_CENTER_TENANT_ID.",
));
checks.push(required_value(
"release.microsoft_store.client_id_configured",
microsoft_store_client_id(&cfg).as_deref(),
"Microsoft Entra client id is configured for msstore reconfigure",
"Set distribution.microsoft_store.client_id, AZURE_CLIENT_ID, or PARTNER_CENTER_CLIENT_ID.",
));
checks.push(secret_check(
"release.microsoft_store.credentials_available",
&[
"MICROSOFT_STORE_CLIENT_SECRET",
"PARTNER_CENTER_CLIENT_SECRET",
],
DistributionProvider::MicrosoftStore,
"Set MICROSOFT_STORE_CLIENT_SECRET, PARTNER_CENTER_CLIENT_SECRET, or import the Partner Center client secret with `fission auth import microsoft-store --from env:MICROSOFT_STORE_CLIENT_SECRET --yes`.",
));
} else {
checks.push(check(
"release.microsoft_store.msstore_config_external",
CheckSeverity::Warning,
CheckStatus::Warning,
"Microsoft Store Developer CLI credentials are managed by msstore",
None,
vec!["Run `msstore` interactively once, run `msstore reconfigure ...` in CI, or set distribution.microsoft_store.msstore_reconfigure = true so Fission configures msstore before publishing."],
));
}
} else {
checks.push(required_value(
"release.microsoft_store.seller_id_configured",
microsoft_store_seller_id(&cfg).as_deref(),
"Microsoft Store seller id is configured",
"Set distribution.microsoft_store.seller_id, MICROSOFT_STORE_SELLER_ID, or PARTNER_CENTER_SELLER_ID.",
));
checks.push(required_value(
"release.microsoft_store.tenant_id_configured",
microsoft_store_tenant_id(&cfg).as_deref(),
"Microsoft Entra tenant id is configured",
"Set distribution.microsoft_store.tenant_id, AZURE_TENANT_ID, or PARTNER_CENTER_TENANT_ID.",
));
checks.push(required_value(
"release.microsoft_store.client_id_configured",
microsoft_store_client_id(&cfg).as_deref(),
"Microsoft Entra client id is configured",
"Set distribution.microsoft_store.client_id, AZURE_CLIENT_ID, or PARTNER_CENTER_CLIENT_ID.",
));
checks.push(secret_check(
"release.microsoft_store.credentials_available",
&[
"MICROSOFT_STORE_CLIENT_SECRET",
"PARTNER_CENTER_CLIENT_SECRET",
],
DistributionProvider::MicrosoftStore,
"Set MICROSOFT_STORE_CLIENT_SECRET, PARTNER_CENTER_CLIENT_SECRET, or import the Partner Center client secret with `fission auth import microsoft-store --from env:MICROSOFT_STORE_CLIENT_SECRET --yes`.",
));
checks.push(required_value(
"release.microsoft_store.package_url_configured",
cfg.package_url.as_deref(),
"Microsoft Store package URL is configured for MSI/EXE submissions",
"Upload the package to a durable HTTPS URL first, then set distribution.microsoft_store.package_url or pass --deploy <https-url>.",
));
}
let selected_track = track.unwrap_or("public");
if uses_msix && selected_track == "private" {
checks.push(required_value(
"release.microsoft_store.flight_id_configured",
cfg.flight_id.as_deref(),
"Microsoft Store package flight id is configured",
"Set distribution.microsoft_store.flight_id or pass the Partner Center package-flight id directly with --track <flight-id>.",
));
}
checks.push(check(
"release.microsoft_store.track_supported",
CheckSeverity::Warning,
if selected_track == "public"
|| selected_track == "private"
|| uses_msix && !selected_track.trim().is_empty()
{
CheckStatus::Passed
} else {
CheckStatus::Warning
},
"Microsoft Store destination is understood",
Some(selected_track.to_string()),
vec!["Use public, private, or an MSIX package-flight id when publishing through Microsoft Store Developer CLI."],
));
if let Some(manifest) = artifact_manifest.as_ref() {
checks.push(artifact_format_check(
"release.microsoft_store.artifact_format",
manifest,
if uses_msix {
&["msix", "msixupload"]
} else {
&["exe", "msi"]
},
if uses_msix {
"Build a Windows MSIX artifact before publishing to the Microsoft Store MSIX path."
} else {
"Build a Windows MSI or EXE artifact before using the Store MSI/EXE submission API."
},
));
if uses_msix {
checks.push(check(
"release.microsoft_store.msix_upload_artifact_present",
CheckSeverity::Error,
if has_artifact_with_extension(manifest, MICROSOFT_STORE_MSIX_TYPES) {
CheckStatus::Passed
} else {
CheckStatus::Missing
},
"artifact manifest contains an MSIX/MSIXUPLOAD file",
Some(format!("checked: {}", MICROSOFT_STORE_MSIX_TYPES.join(", "))),
vec!["Rebuild the Windows MSIX package and ensure the artifact manifest includes the .msix or .msixupload file."],
));
}
}
checks.push(check(
"release.microsoft_store.first_setup_manual_steps",
CheckSeverity::Warning,
CheckStatus::Warning,
"first Microsoft Store setup may require Partner Center work",
cfg.product_id.clone(),
vec!["Reserve the app, complete first submission/ratings/pricing, associate the Entra app with Partner Center, and verify package identity before first automation."],
));
Ok(())
}
fn create_play_edit(client: &Client, token: &str, package_name: &str) -> Result<String> {
let url = format!("{PLAY_API}/androidpublisher/v3/applications/{package_name}/edits");
let response = client
.post(url)
.bearer_auth(token)
.json(&json!({}))
.send()
.context("failed to create Google Play edit")?;
let value = json_response(response, "Google Play edit insert")?;
value
.get("id")
.and_then(Value::as_str)
.map(str::to_string)
.context("Google Play edit insert response did not contain id")
}
fn upload_play_artifact(
client: &Client,
token: &str,
package_name: &str,
edit_id: &str,
path: &Path,
artifact_kind: &str,
) -> Result<String> {
let endpoint = match artifact_kind {
"aab" => "bundles",
"apk" => "apks",
other => bail!("Google Play upload expected .aab or .apk, got .{other}"),
};
let url = format!(
"{PLAY_UPLOAD_API}/androidpublisher/v3/applications/{package_name}/edits/{edit_id}/{endpoint}?uploadType=media"
);
let bytes = fs::read(path).with_context(|| format!("failed to read {}", path.display()))?;
let response = client
.post(url)
.bearer_auth(token)
.header("Content-Type", "application/octet-stream")
.body(bytes)
.send()
.with_context(|| format!("failed to upload {} to Google Play", path.display()))?;
let value = json_response(response, "Google Play artifact upload")?;
let version = value
.get("versionCode")
.and_then(|value| {
value
.as_i64()
.map(|value| value.to_string())
.or_else(|| value.as_str().map(str::to_string))
})
.context("Google Play upload response did not contain versionCode")?;
Ok(version)
}
fn update_play_track(
client: &Client,
token: &str,
package_name: &str,
edit_id: &str,
track: &str,
release_status: &str,
version_code: &str,
) -> Result<()> {
let url = format!(
"{PLAY_API}/androidpublisher/v3/applications/{package_name}/edits/{edit_id}/tracks/{track}"
);
let body = json!({
"releases": [{
"status": release_status,
"versionCodes": [version_code]
}]
});
let response = client
.put(url)
.bearer_auth(token)
.json(&body)
.send()
.context("failed to update Google Play track")?;
json_response(response, "Google Play track update")?;
Ok(())
}
fn validate_play_edit(
client: &Client,
token: &str,
package_name: &str,
edit_id: &str,
) -> Result<()> {
let url = format!(
"{PLAY_API}/androidpublisher/v3/applications/{package_name}/edits/{edit_id}:validate"
);
let response = client
.post(url)
.bearer_auth(token)
.send()
.context("failed to validate Google Play edit")?;
json_response(response, "Google Play edit validate")?;
Ok(())
}
fn commit_play_edit(client: &Client, token: &str, package_name: &str, edit_id: &str) -> Result<()> {
let url = format!(
"{PLAY_API}/androidpublisher/v3/applications/{package_name}/edits/{edit_id}:commit"
);
let response = client
.post(url)
.bearer_auth(token)
.send()
.context("failed to commit Google Play edit")?;
json_response(response, "Google Play edit commit")?;
Ok(())
}
fn google_play_access_token(cfg: &PlayStoreConfig, client: &Client) -> Result<String> {
if let Some(token) = env_value("PLAY_STORE_ACCESS_TOKEN") {
return Ok(token);
}
let secret_source = env_value("PLAY_STORE_SERVICE_ACCOUNT_JSON")
.or_else(|| env_value("GOOGLE_APPLICATION_CREDENTIALS"))
.or_else(|| cfg.service_account.clone())
.or_else(|| {
credentials::provider_secret(DistributionProvider::PlayStore, &[])
.ok()
.flatten()
});
let Some(source) = secret_source else {
bail!("Google Play credentials are missing; set PLAY_STORE_SERVICE_ACCOUNT_JSON, PLAY_STORE_ACCESS_TOKEN, GOOGLE_APPLICATION_CREDENTIALS, or import play-store credentials into the Fission vault")
};
if looks_like_bearer_token(&source) {
return Ok(source);
}
let service_account = load_google_service_account(&source)?;
service_account_access_token(&service_account, client)
}
fn service_account_access_token(account: &GoogleServiceAccount, client: &Client) -> Result<String> {
let token_uri = account.token_uri.as_deref().unwrap_or(GOOGLE_TOKEN_URI);
let iat = now_unix_seconds();
let claims = GoogleJwtClaims {
iss: &account.client_email,
scope: GOOGLE_PLAY_SCOPE,
aud: token_uri,
iat,
exp: iat + 3600,
};
let key = EncodingKey::from_rsa_pem(account.private_key.as_bytes())
.context("failed to parse Google service account private_key as RSA PEM")?;
let jwt = encode(&Header::new(Algorithm::RS256), &claims, &key)
.context("failed to sign Google service account JWT")?;
let response = client
.post(token_uri)
.form(&[
("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer"),
("assertion", jwt.as_str()),
])
.send()
.context("failed to exchange Google service account JWT")?;
let token: OAuthTokenResponse = response
.error_for_status()
.context("Google OAuth token exchange failed")?
.json()
.context("failed to parse Google OAuth token response")?;
let _ = (&token.token_type, token.expires_in);
Ok(token.access_token)
}
fn app_store_access_token(cfg: &AppStoreConfig) -> Result<String> {
if let Some(token) = env_value("APP_STORE_CONNECT_ACCESS_TOKEN") {
return Ok(token);
}
let issuer_id = env_value("APP_STORE_CONNECT_ISSUER_ID")
.or(cfg.issuer_id.clone())
.context("distribution.app_store.issuer_id or APP_STORE_CONNECT_ISSUER_ID is required")?;
let key_id = env_value("APP_STORE_CONNECT_KEY_ID")
.or(cfg.key_id.clone())
.context("distribution.app_store.key_id or APP_STORE_CONNECT_KEY_ID is required")?;
let key_source = env_value("APP_STORE_CONNECT_API_KEY")
.or_else(|| env_value("APP_STORE_CONNECT_API_KEY_PATH"))
.or(cfg.api_key_path.clone())
.or_else(|| credentials::provider_secret(DistributionProvider::AppStore, &[]).ok().flatten())
.context("APP_STORE_CONNECT_API_KEY, APP_STORE_CONNECT_API_KEY_PATH, distribution.app_store.api_key_path, or vault credentials are required")?;
if looks_like_bearer_token(&key_source) {
return Ok(key_source);
}
let key_text = if key_source.contains("-----BEGIN PRIVATE KEY-----") {
key_source
} else {
fs::read_to_string(&key_source).with_context(|| {
format!("failed to read App Store Connect API key from {key_source}")
})?
};
let now = now_unix_seconds();
let claims = AppStoreJwtClaims {
iss: &issuer_id,
aud: "appstoreconnect-v1",
iat: now,
exp: now + 20 * 60,
};
let mut header = Header::new(Algorithm::ES256);
header.kid = Some(key_id);
encode(
&header,
&claims,
&EncodingKey::from_ec_pem(key_text.as_bytes())
.context("failed to parse App Store Connect .p8 key as EC private key")?,
)
.context("failed to sign App Store Connect JWT")
}
fn app_store_app_id(cfg: &AppStoreConfig, client: &Client, token: &str) -> Result<String> {
if let Some(app_id) = cfg
.app_id
.as_deref()
.filter(|value| !value.trim().is_empty())
{
return Ok(app_id.to_string());
}
let bundle_id = cfg.bundle_id.as_deref().context(
"distribution.app_store.app_id or bundle_id is required for App Store Connect status",
)?;
let url = format!("{APP_STORE_API}/v1/apps?filter[bundleId]={bundle_id}&limit=1");
let response = client
.get(url)
.bearer_auth(token)
.send()
.context("failed to resolve App Store Connect app id from bundle id")?;
let value = json_response(response, "App Store app lookup")?;
value
.get("data")
.and_then(Value::as_array)
.and_then(|items| items.first())
.and_then(|item| item.get("id"))
.and_then(Value::as_str)
.map(str::to_string)
.with_context(|| {
format!("App Store Connect did not return an app for bundle id {bundle_id}")
})
}
fn microsoft_store_access_token(cfg: &MicrosoftStoreConfig, client: &Client) -> Result<String> {
if let Some(token) = env_value("MICROSOFT_STORE_TOKEN") {
return Ok(token);
}
let tenant_id = microsoft_store_tenant_id(cfg).context(
"distribution.microsoft_store.tenant_id, AZURE_TENANT_ID, or PARTNER_CENTER_TENANT_ID is required",
)?;
let client_id = microsoft_store_client_id(cfg).context(
"distribution.microsoft_store.client_id, AZURE_CLIENT_ID, or PARTNER_CENTER_CLIENT_ID is required",
)?;
let client_secret = microsoft_store_client_secret().context(
"MICROSOFT_STORE_CLIENT_SECRET, PARTNER_CENTER_CLIENT_SECRET, or vault credentials are required",
)?;
let url = format!("https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token");
let response = client
.post(url)
.form(&[
("grant_type", "client_credentials"),
("client_id", client_id.as_str()),
("client_secret", client_secret.as_str()),
("scope", MICROSOFT_STORE_SCOPE),
])
.send()
.context("failed to request Microsoft Store access token")?;
let token: OAuthTokenResponse = response
.error_for_status()
.context("Microsoft Store access token request failed")?
.json()
.context("failed to parse Microsoft Store access token response")?;
Ok(token.access_token)
}
fn run_msstore_reconfigure(cfg: &MicrosoftStoreConfig) -> Result<(String, String)> {
let tenant_id = microsoft_store_tenant_id(cfg).context(
"distribution.microsoft_store.tenant_id, AZURE_TENANT_ID, or PARTNER_CENTER_TENANT_ID is required for msstore reconfigure",
)?;
let seller_id = microsoft_store_seller_id(cfg).context(
"distribution.microsoft_store.seller_id, MICROSOFT_STORE_SELLER_ID, or PARTNER_CENTER_SELLER_ID is required for msstore reconfigure",
)?;
let client_id = microsoft_store_client_id(cfg).context(
"distribution.microsoft_store.client_id, AZURE_CLIENT_ID, or PARTNER_CENTER_CLIENT_ID is required for msstore reconfigure",
)?;
let client_secret = microsoft_store_client_secret().context(
"MICROSOFT_STORE_CLIENT_SECRET, PARTNER_CENTER_CLIENT_SECRET, or vault credentials are required for msstore reconfigure",
)?;
let args = vec![
"reconfigure".to_string(),
"--tenantId".to_string(),
tenant_id,
"--sellerId".to_string(),
seller_id,
"--clientId".to_string(),
client_id,
"--clientSecret".to_string(),
client_secret,
];
run_msstore(&args, "Microsoft Store Developer CLI reconfigure")
}
fn run_msstore(args: &[String], operation: &str) -> Result<(String, String)> {
let output = Command::new("msstore")
.args(args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.with_context(|| {
format!(
"failed to run msstore; install Microsoft Store Developer CLI before {operation}"
)
})?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
if !output.status.success() {
let detail = if stderr.trim().is_empty() {
stdout.trim()
} else {
stderr.trim()
};
bail!("{operation} failed with {}: {}", output.status, detail);
}
Ok((stdout, stderr))
}
fn load_google_service_account(source: &str) -> Result<GoogleServiceAccount> {
let text = if source.trim_start().starts_with('{') {
source.to_string()
} else {
fs::read_to_string(source)
.with_context(|| format!("failed to read Google service account JSON from {source}"))?
};
serde_json::from_str(&text).context("failed to parse Google service account JSON")
}
fn json_response(response: Response, operation: &str) -> Result<Value> {
let status = response.status();
let text = response
.text()
.with_context(|| format!("failed to read {operation} response"))?;
if !status.is_success() {
bail!("{operation} failed with {status}: {text}");
}
if text.trim().is_empty() {
Ok(Value::Null)
} else {
serde_json::from_str(&text)
.with_context(|| format!("failed to parse {operation} JSON response: {text}"))
}
}
fn microsoft_store_success(value: &Value, operation: &str) -> Result<()> {
if value
.get("isSuccess")
.and_then(Value::as_bool)
.unwrap_or(true)
{
Ok(())
} else {
bail!("{operation} returned an unsuccessful response: {value}")
}
}
fn http_client() -> Result<Client> {
Client::builder()
.timeout(Duration::from_secs(300))
.user_agent("cargo-fission-release/0.1")
.build()
.context("failed to build release HTTP client")
}
fn play_store_config(config: &PublishManifest) -> PlayStoreConfig {
config
.distribution
.as_ref()
.and_then(|distribution| distribution.play_store.clone())
.unwrap_or_default()
}
fn app_store_config(config: &PublishManifest) -> AppStoreConfig {
config
.distribution
.as_ref()
.and_then(|distribution| distribution.app_store.clone())
.unwrap_or_default()
}
fn microsoft_store_config(config: &PublishManifest) -> MicrosoftStoreConfig {
config
.distribution
.as_ref()
.and_then(|distribution| distribution.microsoft_store.clone())
.unwrap_or_default()
}
fn primary_artifact_with_extensions(manifest: &ArtifactManifest, exts: &[&str]) -> Result<PathBuf> {
manifest
.artifacts
.iter()
.map(|file| PathBuf::from(&file.path))
.find(|path| {
path.extension()
.and_then(|value| value.to_str())
.is_some_and(|ext| {
exts.iter()
.any(|candidate| ext.eq_ignore_ascii_case(candidate))
})
})
.with_context(|| {
format!(
"artifact manifest does not contain one of: {}",
exts.join(", ")
)
})
}
fn primary_artifact_extension(manifest: &ArtifactManifest) -> Option<&str> {
manifest
.artifacts
.iter()
.map(|file| Path::new(&file.path))
.find_map(|path| path.extension().and_then(|value| value.to_str()))
}
fn has_artifact_with_extension(manifest: &ArtifactManifest, exts: &[&str]) -> bool {
manifest.artifacts.iter().any(|file| {
Path::new(&file.path)
.extension()
.and_then(|value| value.to_str())
.is_some_and(|ext| {
exts.iter()
.any(|candidate| ext.eq_ignore_ascii_case(candidate))
})
})
}
fn microsoft_store_package_type(cfg: &MicrosoftStoreConfig, manifest: &ArtifactManifest) -> String {
cfg.package_type
.as_deref()
.or_else(|| primary_artifact_extension(manifest))
.unwrap_or("exe")
.to_ascii_lowercase()
}
fn is_microsoft_store_msix_type(package_type: &str) -> bool {
MICROSOFT_STORE_MSIX_TYPES
.iter()
.any(|candidate| package_type.eq_ignore_ascii_case(candidate))
}
fn microsoft_store_msstore_project(
options: &DistributeOptions,
cfg: &MicrosoftStoreConfig,
) -> PathBuf {
cfg.msstore_project
.as_deref()
.map(|path| {
let path = PathBuf::from(path);
if path.is_absolute() {
path
} else {
options.project_dir.join(path)
}
})
.unwrap_or_else(|| options.project_dir.clone())
}
fn msstore_publish_args(
project_path: &Path,
artifact: &Path,
product_id: &str,
flight_id: Option<&str>,
rollout: Option<u8>,
should_submit: bool,
) -> Vec<String> {
let mut args = vec![
"publish".to_string(),
project_path.display().to_string(),
"-i".to_string(),
artifact.display().to_string(),
"-id".to_string(),
product_id.to_string(),
];
if !should_submit {
args.push("-nc".to_string());
}
if let Some(flight_id) = flight_id.filter(|value| !value.trim().is_empty()) {
args.push("-f".to_string());
args.push(flight_id.to_string());
}
if let Some(rollout) = rollout {
args.push("-prp".to_string());
args.push(rollout.to_string());
}
args
}
fn microsoft_store_should_submit(options: &DistributeOptions, cfg: &MicrosoftStoreConfig) -> bool {
cfg.submit.unwrap_or(false) || options.track.as_deref() == Some("public") && options.yes
}
fn microsoft_store_flight_id(
track: Option<&str>,
cfg: &MicrosoftStoreConfig,
) -> Result<Option<String>> {
match track.map(str::trim).filter(|value| !value.is_empty()) {
Some("public") | None => Ok(None),
Some("private") => cfg.flight_id.clone().map(Some).context(
"distribution.microsoft_store.flight_id is required when --track private is used for MSIX publishing",
),
Some(flight_id) => Ok(Some(flight_id.to_string())),
}
}
fn microsoft_store_rollout_percentage(cfg: &MicrosoftStoreConfig) -> Result<Option<u8>> {
if let Some(rollout) = cfg.package_rollout_percentage {
if rollout > 100 {
bail!(
"distribution.microsoft_store.package_rollout_percentage must be between 0 and 100"
);
}
}
Ok(cfg.package_rollout_percentage)
}
fn microsoft_store_seller_id(cfg: &MicrosoftStoreConfig) -> Option<String> {
env_value("MICROSOFT_STORE_SELLER_ID")
.or_else(|| env_value("PARTNER_CENTER_SELLER_ID"))
.or_else(|| cfg.seller_id.clone())
}
fn microsoft_store_tenant_id(cfg: &MicrosoftStoreConfig) -> Option<String> {
env_value("AZURE_TENANT_ID")
.or_else(|| env_value("PARTNER_CENTER_TENANT_ID"))
.or_else(|| cfg.tenant_id.clone())
}
fn microsoft_store_client_id(cfg: &MicrosoftStoreConfig) -> Option<String> {
env_value("AZURE_CLIENT_ID")
.or_else(|| env_value("PARTNER_CENTER_CLIENT_ID"))
.or_else(|| cfg.client_id.clone())
}
fn microsoft_store_client_secret() -> Option<String> {
env_value("MICROSOFT_STORE_CLIENT_SECRET")
.or_else(|| env_value("PARTNER_CENTER_CLIENT_SECRET"))
.or_else(|| {
credentials::provider_secret(DistributionProvider::MicrosoftStore, &[])
.ok()
.flatten()
})
}
fn command_line(program: &str, args: &[String]) -> String {
std::iter::once(program.to_string())
.chain(args.iter().map(|arg| shell_word(arg)))
.collect::<Vec<_>>()
.join(" ")
}
fn shell_word(value: &str) -> String {
if value
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '/' | ':' | '\\'))
{
value.to_string()
} else {
format!("'{}'", value.replace('\'', "'\\''"))
}
}
fn artifact_format_check(
id: &str,
manifest: &ArtifactManifest,
accepted: &[&str],
remediation: &str,
) -> ReadinessCheck {
check(
id,
CheckSeverity::Error,
if accepted.iter().any(|format| manifest.format == *format) {
CheckStatus::Passed
} else {
CheckStatus::Failed
},
format!("artifact format is one of {}", accepted.join(", ")),
Some(format!("manifest format: {}", manifest.format)),
vec![remediation],
)
}
fn secret_check(
id: &str,
env_names: &[&str],
provider: DistributionProvider,
remediation: &str,
) -> ReadinessCheck {
let env_name = env_names.iter().find(|name| env::var_os(name).is_some());
let vault_present = credentials::provider_secret(provider, &[])
.ok()
.flatten()
.is_some();
check(
id,
CheckSeverity::Error,
if env_name.is_some() || vault_present {
CheckStatus::Passed
} else {
CheckStatus::Missing
},
"provider credentials are available",
env_name
.map(|name| format!("environment variable {name}"))
.or_else(|| vault_present.then(|| "Fission release vault".to_string())),
vec![remediation],
)
}
fn store_receipt(
options: &DistributeOptions,
provider: &str,
artifact_path: &Path,
status: &str,
deployment_id: Option<String>,
canonical_url: Option<String>,
manual_follow_up: Vec<String>,
) -> DistributionReceipt {
DistributionReceipt {
schema_version: 1,
created_at_unix_seconds: now_unix_seconds(),
provider: provider.to_string(),
site: options.site.clone(),
action: "publish".to_string(),
artifact_manifest: Some(artifact_path.display().to_string()),
deployment_id,
canonical_url,
preview_url: None,
custom_domain: None,
status: status.to_string(),
stdout: None,
stderr: None,
manual_follow_up,
}
}
fn package_name_from_project(manifest: &ArtifactManifest) -> Option<&str> {
(!manifest.project.app_id.trim().is_empty()).then_some(manifest.project.app_id.as_str())
}
fn looks_like_bearer_token(value: &str) -> bool {
let trimmed = value.trim();
!trimmed.starts_with('{') && !Path::new(trimmed).exists() && trimmed.matches('.').count() >= 2
}
fn env_value(name: &str) -> Option<String> {
env::var(name).ok().filter(|value| !value.trim().is_empty())
}
fn env_value_ref(name: &str) -> Option<&'static str> {
if env::var_os(name).is_some() {
Some("set")
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
fn options(track: Option<&str>, yes: bool) -> DistributeOptions {
DistributeOptions {
project_dir: PathBuf::from("/project"),
provider: DistributionProvider::MicrosoftStore,
action: DistributeAction::Publish,
artifact: None,
site: "production".to_string(),
deploy: None,
track: track.map(str::to_string),
dry_run: false,
yes,
json: false,
}
}
#[test]
fn msstore_publish_args_keep_submission_as_draft_by_default() {
let args = msstore_publish_args(
Path::new("/project"),
Path::new("/artifacts/app.msixupload"),
"9N123",
None,
None,
false,
);
assert_eq!(
args,
vec![
"publish",
"/project",
"-i",
"/artifacts/app.msixupload",
"-id",
"9N123",
"-nc"
]
);
}
#[test]
fn msstore_publish_args_include_flight_and_rollout_when_configured() {
let args = msstore_publish_args(
Path::new("/project"),
Path::new("/artifacts/app.msix"),
"9N123",
Some("beta"),
Some(25),
true,
);
assert_eq!(
args,
vec![
"publish",
"/project",
"-i",
"/artifacts/app.msix",
"-id",
"9N123",
"-f",
"beta",
"-prp",
"25"
]
);
}
#[test]
fn microsoft_store_private_track_uses_configured_flight_id() {
let cfg = MicrosoftStoreConfig {
flight_id: Some("insiders".to_string()),
..Default::default()
};
assert_eq!(
microsoft_store_flight_id(Some("private"), &cfg).unwrap(),
Some("insiders".to_string())
);
assert_eq!(
microsoft_store_flight_id(Some("preview"), &cfg).unwrap(),
Some("preview".to_string())
);
assert!(microsoft_store_flight_id(Some("public"), &cfg)
.unwrap()
.is_none());
}
#[test]
fn microsoft_store_submit_requires_explicit_commit_intent() {
let cfg = MicrosoftStoreConfig::default();
assert!(!microsoft_store_should_submit(&options(None, false), &cfg));
assert!(!microsoft_store_should_submit(
&options(Some("public"), false),
&cfg
));
assert!(microsoft_store_should_submit(
&options(Some("public"), true),
&cfg
));
let cfg = MicrosoftStoreConfig {
submit: Some(true),
..Default::default()
};
assert!(microsoft_store_should_submit(&options(None, false), &cfg));
}
#[test]
fn microsoft_store_rollout_rejects_out_of_range_percentages() {
let cfg = MicrosoftStoreConfig {
package_rollout_percentage: Some(101),
..Default::default()
};
assert!(microsoft_store_rollout_percentage(&cfg).is_err());
}
}