Archived
feat: rename crates/ → vessels/ + add El ports per sub-vessel
Belated rename commit for foundation/el-ui — was missed in the workspace-wide crates→vessels pass earlier today. Same structural intent as the rename in the other repos: 'crates' is the Rust word, 'vessel' is El's, and the directory rename is the marker that this slot holds an El buildable unit even if its current contents are still Rust pending port. Plus the El ports themselves — manifest.el + src/main.el per sub- vessel (el-aop, el-auth, el-config, el-i18n, el-identity, el-layout, el-platform, el-publish, el-secrets, el-services, el-style, el-ui- compiler). The ui-compiler is a stub: elc only emits C right now; generating browser-target JS/Wasm is the biggest open language gap and gets its own project. Until then, el-ui-compiler emits a JS module that throws elc.backend_missing so callers fail loudly. Cross-repo path dependencies in Cargo.toml updated to vessels/.
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "el-publish"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "el-ui app publishing pipeline — one command ships to every platform"
|
||||
license = "MIT"
|
||||
|
||||
[lib]
|
||||
name = "el_publish"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
thiserror = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
@@ -0,0 +1,25 @@
|
||||
// el-publish — App Store and Play Store publishing pipeline.
|
||||
//
|
||||
// One command ships to every platform:
|
||||
// el publish all platforms
|
||||
// el publish --apple App Store only
|
||||
// el publish --google Play Store only
|
||||
// el publish --beta TestFlight + Play internal track
|
||||
|
||||
vessel "el-publish" {
|
||||
version "0.1.0"
|
||||
description "Apple App Store + Google Play publishing automation"
|
||||
authors ["Will Anderson <will@neurontechnologies.ai>"]
|
||||
edition "2026"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
el-platform "1.0"
|
||||
el-config "0.1"
|
||||
el-secrets "0.1"
|
||||
}
|
||||
|
||||
build {
|
||||
entry "src/main.el"
|
||||
output "dist/"
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
//! Apple App Store Connect API publisher.
|
||||
//!
|
||||
//! Models the App Store Connect API calls. Stub the actual HTTP — structure is
|
||||
//! correct and complete. A future agent fills in the `reqwest` calls.
|
||||
//!
|
||||
//! API reference: https://developer.apple.com/documentation/appstoreconnectapi
|
||||
|
||||
use crate::{
|
||||
config::{AppleConfig, PublishConfig},
|
||||
metadata::StoreMetadata,
|
||||
PublishError, PublishOutcome, PublishResult,
|
||||
};
|
||||
|
||||
/// Publishes app builds to Apple App Store Connect / TestFlight.
|
||||
pub struct ApplePublisher {
|
||||
pub apple_config: AppleConfig,
|
||||
pub publish_config: PublishConfig,
|
||||
/// API key for App Store Connect (loaded from env or keychain).
|
||||
#[allow(dead_code)]
|
||||
api_key: Option<String>,
|
||||
}
|
||||
|
||||
impl ApplePublisher {
|
||||
pub fn new(publish_config: PublishConfig) -> PublishResult<Self> {
|
||||
let apple_config = publish_config.apple.clone().ok_or_else(|| {
|
||||
PublishError::Config("no [publish.apple] section in manifest.el".into())
|
||||
})?;
|
||||
Ok(Self {
|
||||
apple_config,
|
||||
publish_config,
|
||||
api_key: std::env::var("APP_STORE_CONNECT_API_KEY").ok(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Upload a build to TestFlight.
|
||||
///
|
||||
/// In production, this calls the App Store Connect API:
|
||||
/// POST /v1/builds
|
||||
/// PUT /v1/builds/{id}/betaAppReviewDetail
|
||||
/// POST /v1/betaTestersConfigurations
|
||||
pub fn upload_to_testflight(
|
||||
&self,
|
||||
ipa_path: &str,
|
||||
) -> PublishResult<String> {
|
||||
// TODO: use reqwest to call App Store Connect API:
|
||||
// 1. Authenticate with JWT from API key
|
||||
// 2. POST /v1/builds with IPA binary
|
||||
// 3. Poll build status until "READY_FOR_BETA_SUBMISSION"
|
||||
// 4. Submit to TestFlight review
|
||||
let _ = ipa_path;
|
||||
let submission_id = format!(
|
||||
"TF-{}-{}",
|
||||
self.apple_config.bundle_id,
|
||||
self.publish_config.build_number
|
||||
);
|
||||
Ok(submission_id)
|
||||
}
|
||||
|
||||
/// Submit to App Store review.
|
||||
///
|
||||
/// In production:
|
||||
/// POST /v1/appStoreVersionSubmissions
|
||||
/// POST /v1/appStoreVersions/{id}/appStoreVersionLocalizations (for metadata)
|
||||
pub fn submit_to_app_store(
|
||||
&self,
|
||||
build_id: &str,
|
||||
metadata: &StoreMetadata,
|
||||
) -> PublishResult<String> {
|
||||
// TODO: actual API calls
|
||||
let _ = (build_id, metadata);
|
||||
let review_id = format!("AS-{}", self.publish_config.build_number);
|
||||
Ok(review_id)
|
||||
}
|
||||
|
||||
/// Set staged rollout percentage on App Store.
|
||||
/// Only available after the initial release.
|
||||
pub fn set_phased_release(&self, percent: u8) -> PublishResult<()> {
|
||||
if percent > 100 {
|
||||
return Err(PublishError::Config(format!(
|
||||
"rollout percent {} exceeds 100",
|
||||
percent
|
||||
)));
|
||||
}
|
||||
// TODO: PATCH /v1/appStoreVersionPhasedReleases/{id}
|
||||
let _ = percent;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Full publish flow: build → upload → submit.
|
||||
pub fn publish(&self, ipa_path: &str, to_beta: bool) -> PublishResult<PublishOutcome> {
|
||||
let metadata = StoreMetadata::load_from_dir(&self.publish_config.metadata_dir)?;
|
||||
let build_id = self.upload_to_testflight(ipa_path)?;
|
||||
|
||||
let track = if to_beta { "testflight" } else { "app_store" };
|
||||
|
||||
if !to_beta {
|
||||
let review_id = self.submit_to_app_store(&build_id, &metadata)?;
|
||||
let _ = review_id;
|
||||
}
|
||||
|
||||
if let Some(rollout) = &self.publish_config.rollout {
|
||||
self.set_phased_release(rollout.initial_percent)?;
|
||||
}
|
||||
|
||||
Ok(PublishOutcome::new("apple", &self.publish_config, track)
|
||||
.with_submission_id(build_id))
|
||||
}
|
||||
|
||||
/// List existing builds from App Store Connect.
|
||||
pub fn list_builds(&self) -> PublishResult<Vec<BuildInfo>> {
|
||||
// TODO: GET /v1/builds?filter[bundleId]=...
|
||||
Ok(vec![BuildInfo {
|
||||
id: format!("build-{}", self.publish_config.build_number),
|
||||
version: self.publish_config.version.clone(),
|
||||
status: "READY_FOR_DISTRIBUTION".into(),
|
||||
}])
|
||||
}
|
||||
}
|
||||
|
||||
/// Info about a build in App Store Connect.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BuildInfo {
|
||||
pub id: String,
|
||||
pub version: String,
|
||||
pub status: String,
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
//! Certificate management — load, save, check expiry, generate renewal warnings.
|
||||
|
||||
use crate::{PublishError, PublishResult};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// Info about a code signing certificate.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CertInfo {
|
||||
pub name: String,
|
||||
pub team_id: String,
|
||||
pub serial: String,
|
||||
/// Certificate type: "Distribution", "Development", "Push", etc.
|
||||
pub cert_type: String,
|
||||
/// Expiry as Unix timestamp (seconds since epoch).
|
||||
pub expires_at: u64,
|
||||
/// Whether the certificate is currently valid.
|
||||
pub is_valid: bool,
|
||||
}
|
||||
|
||||
impl CertInfo {
|
||||
pub fn new(
|
||||
name: impl Into<String>,
|
||||
team_id: impl Into<String>,
|
||||
serial: impl Into<String>,
|
||||
cert_type: impl Into<String>,
|
||||
expires_at: u64,
|
||||
) -> Self {
|
||||
let now = unix_now();
|
||||
Self {
|
||||
name: name.into(),
|
||||
team_id: team_id.into(),
|
||||
serial: serial.into(),
|
||||
cert_type: cert_type.into(),
|
||||
expires_at,
|
||||
is_valid: expires_at > now,
|
||||
}
|
||||
}
|
||||
|
||||
/// Days until expiry (0 if already expired).
|
||||
pub fn days_until_expiry(&self) -> u64 {
|
||||
let now = unix_now();
|
||||
if self.expires_at <= now {
|
||||
return 0;
|
||||
}
|
||||
(self.expires_at - now) / 86400
|
||||
}
|
||||
|
||||
/// Whether the cert expires within `days` days.
|
||||
pub fn expires_soon(&self, days: u64) -> bool {
|
||||
self.days_until_expiry() <= days
|
||||
}
|
||||
|
||||
pub fn is_expired(&self) -> bool {
|
||||
unix_now() >= self.expires_at
|
||||
}
|
||||
}
|
||||
|
||||
/// Certificate store — loads, caches, and checks expiry of code signing certs.
|
||||
pub struct CertStore {
|
||||
certs: Vec<CertInfo>,
|
||||
/// How many days before expiry to warn (default: 30).
|
||||
pub warn_days: u64,
|
||||
}
|
||||
|
||||
impl CertStore {
|
||||
pub fn new() -> Self {
|
||||
Self { certs: Vec::new(), warn_days: 30 }
|
||||
}
|
||||
|
||||
pub fn with_warn_days(mut self, days: u64) -> Self {
|
||||
self.warn_days = days;
|
||||
self
|
||||
}
|
||||
|
||||
/// Add a certificate to the store.
|
||||
pub fn add(&mut self, cert: CertInfo) {
|
||||
self.certs.push(cert);
|
||||
}
|
||||
|
||||
/// Find a certificate by team ID and type.
|
||||
pub fn find(&self, team_id: &str, cert_type: &str) -> Option<&CertInfo> {
|
||||
self.certs.iter().find(|c| {
|
||||
c.team_id == team_id && c.cert_type == cert_type && !c.is_expired()
|
||||
})
|
||||
}
|
||||
|
||||
/// Get all certificates expiring soon (within `warn_days`).
|
||||
pub fn expiring_soon(&self) -> Vec<&CertInfo> {
|
||||
self.certs
|
||||
.iter()
|
||||
.filter(|c| c.expires_soon(self.warn_days))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Generate renewal warnings for expiring certificates.
|
||||
pub fn renewal_warnings(&self) -> Vec<String> {
|
||||
self.expiring_soon()
|
||||
.iter()
|
||||
.map(|c| {
|
||||
if c.is_expired() {
|
||||
format!(
|
||||
"EXPIRED: {} ({}) — team {}. Renew immediately.",
|
||||
c.name, c.cert_type, c.team_id
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"EXPIRING SOON: {} ({}) expires in {} days — team {}. Renew before publishing.",
|
||||
c.name, c.cert_type, c.days_until_expiry(), c.team_id
|
||||
)
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Check that a valid distribution certificate exists for the given team.
|
||||
pub fn validate_for_distribution(&self, team_id: &str) -> PublishResult<()> {
|
||||
let cert = self.find(team_id, "Distribution");
|
||||
match cert {
|
||||
None => Err(PublishError::Certificate(format!(
|
||||
"no valid Distribution certificate found for team {}. Run: el auth add-apple",
|
||||
team_id
|
||||
))),
|
||||
Some(c) if c.expires_soon(7) => Err(PublishError::Certificate(format!(
|
||||
"Distribution certificate for team {} expires in {} days. Renew now.",
|
||||
team_id,
|
||||
c.days_until_expiry()
|
||||
))),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Load certificates from a JSON file (stub — real impl would parse
|
||||
/// Apple's certificate PEM files or keychain API).
|
||||
pub fn load_from_file(_path: &str) -> PublishResult<Self> {
|
||||
// TODO: parse certificate PEM/P12 files, extract expiry via x509-parser
|
||||
Ok(Self::new())
|
||||
}
|
||||
|
||||
/// Save certificate metadata to a JSON cache file.
|
||||
pub fn save_to_file(&self, _path: &str) -> PublishResult<()> {
|
||||
// TODO: serialize cert metadata to JSON
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CertStore {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
fn unix_now() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
//! Publish configuration — parsed from the `[publish]` section of `manifest.el`.
|
||||
|
||||
/// App Store Connect / TestFlight configuration.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AppleConfig {
|
||||
pub account: String,
|
||||
pub team_id: String,
|
||||
pub bundle_id: String,
|
||||
pub category: String,
|
||||
}
|
||||
|
||||
impl AppleConfig {
|
||||
pub fn new(
|
||||
account: impl Into<String>,
|
||||
team_id: impl Into<String>,
|
||||
bundle_id: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
account: account.into(),
|
||||
team_id: team_id.into(),
|
||||
bundle_id: bundle_id.into(),
|
||||
category: "productivity".into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_category(mut self, category: impl Into<String>) -> Self {
|
||||
self.category = category.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Google Play Developer API configuration.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GoogleConfig {
|
||||
pub service_account_path: String,
|
||||
pub package_name: String,
|
||||
/// Which track to publish to: "internal" | "alpha" | "beta" | "production"
|
||||
pub track: String,
|
||||
}
|
||||
|
||||
impl GoogleConfig {
|
||||
pub fn new(
|
||||
service_account_path: impl Into<String>,
|
||||
package_name: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
service_account_path: service_account_path.into(),
|
||||
package_name: package_name.into(),
|
||||
track: "internal".into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_track(mut self, track: impl Into<String>) -> Self {
|
||||
self.track = track.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Staged rollout configuration.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RolloutConfig {
|
||||
/// Initial rollout percentage (0-100).
|
||||
pub initial_percent: u8,
|
||||
/// Automatically advance rollout after `advance_after_hours` hours.
|
||||
pub auto_advance: bool,
|
||||
pub advance_after_hours: u64,
|
||||
/// Halt rollout if crash rate exceeds this fraction (0.0 - 1.0).
|
||||
pub max_crash_rate: f64,
|
||||
}
|
||||
|
||||
impl Default for RolloutConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
initial_percent: 100,
|
||||
auto_advance: false,
|
||||
advance_after_hours: 24,
|
||||
max_crash_rate: 0.01,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RolloutConfig {
|
||||
pub fn new(initial_percent: u8) -> Self {
|
||||
Self { initial_percent, ..Default::default() }
|
||||
}
|
||||
|
||||
pub fn with_auto_advance(mut self, hours: u64) -> Self {
|
||||
self.auto_advance = true;
|
||||
self.advance_after_hours = hours;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_max_crash_rate(mut self, rate: f64) -> Self {
|
||||
self.max_crash_rate = rate;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// The complete publish configuration.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PublishConfig {
|
||||
pub version: String,
|
||||
pub build_number: u32,
|
||||
pub apple: Option<AppleConfig>,
|
||||
pub google: Option<GoogleConfig>,
|
||||
pub rollout: Option<RolloutConfig>,
|
||||
/// Directory with store metadata (title.txt, description.txt, etc.)
|
||||
pub metadata_dir: String,
|
||||
pub screenshot_targets: Vec<String>,
|
||||
}
|
||||
|
||||
impl PublishConfig {
|
||||
pub fn new(version: impl Into<String>, build_number: u32) -> Self {
|
||||
Self {
|
||||
version: version.into(),
|
||||
build_number,
|
||||
apple: None,
|
||||
google: None,
|
||||
rollout: None,
|
||||
metadata_dir: "./store".into(),
|
||||
screenshot_targets: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_apple(mut self, apple: AppleConfig) -> Self {
|
||||
self.apple = Some(apple);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_google(mut self, google: GoogleConfig) -> Self {
|
||||
self.google = Some(google);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_rollout(mut self, rollout: RolloutConfig) -> Self {
|
||||
self.rollout = Some(rollout);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_screenshot_targets(mut self, targets: Vec<impl Into<String>>) -> Self {
|
||||
self.screenshot_targets = targets.into_iter().map(|t| t.into()).collect();
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
//! Google Play Developer API publisher.
|
||||
//!
|
||||
//! Models the Google Play Developer API calls. Stubs the actual HTTP.
|
||||
//!
|
||||
//! API reference: https://developers.google.com/android-publisher
|
||||
|
||||
use crate::{
|
||||
config::{GoogleConfig, PublishConfig},
|
||||
metadata::StoreMetadata,
|
||||
PublishError, PublishOutcome, PublishResult,
|
||||
};
|
||||
|
||||
/// Publishes app bundles to Google Play Store.
|
||||
pub struct GooglePublisher {
|
||||
pub google_config: GoogleConfig,
|
||||
pub publish_config: PublishConfig,
|
||||
}
|
||||
|
||||
impl GooglePublisher {
|
||||
pub fn new(publish_config: PublishConfig) -> PublishResult<Self> {
|
||||
let google_config = publish_config.google.clone().ok_or_else(|| {
|
||||
PublishError::Config("no [publish.google] section in manifest.el".into())
|
||||
})?;
|
||||
Ok(Self { google_config, publish_config })
|
||||
}
|
||||
|
||||
/// Create a new edit session on Google Play.
|
||||
///
|
||||
/// In production: POST https://androidpublisher.googleapis.com/v3/applications/{packageName}/edits
|
||||
pub fn create_edit(&self) -> PublishResult<String> {
|
||||
// TODO: OAuth2 authentication via service account JSON
|
||||
let edit_id = format!("edit-{}", self.publish_config.build_number);
|
||||
Ok(edit_id)
|
||||
}
|
||||
|
||||
/// Upload an AAB (Android App Bundle) to an edit session.
|
||||
///
|
||||
/// In production: POST .../edits/{editId}/bundles (multipart upload)
|
||||
pub fn upload_bundle(
|
||||
&self,
|
||||
edit_id: &str,
|
||||
aab_path: &str,
|
||||
) -> PublishResult<u32> {
|
||||
let _ = (edit_id, aab_path);
|
||||
// Returns the version code of the uploaded bundle
|
||||
Ok(self.publish_config.build_number)
|
||||
}
|
||||
|
||||
/// Assign a bundle to a track.
|
||||
///
|
||||
/// In production: PUT .../edits/{editId}/tracks/{track}
|
||||
pub fn assign_to_track(
|
||||
&self,
|
||||
edit_id: &str,
|
||||
version_code: u32,
|
||||
track: &str,
|
||||
rollout_fraction: f64,
|
||||
) -> PublishResult<()> {
|
||||
let _ = (edit_id, version_code, track, rollout_fraction);
|
||||
if !matches!(track, "internal" | "alpha" | "beta" | "production") {
|
||||
return Err(PublishError::Config(format!(
|
||||
"unknown Google Play track: '{}'. Use internal/alpha/beta/production",
|
||||
track
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Upload store listing (metadata) for a locale.
|
||||
///
|
||||
/// In production: PATCH .../edits/{editId}/listings/{language}
|
||||
pub fn upload_listing(
|
||||
&self,
|
||||
edit_id: &str,
|
||||
locale: &str,
|
||||
metadata: &StoreMetadata,
|
||||
) -> PublishResult<()> {
|
||||
let _ = (edit_id, locale, metadata);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Commit an edit (makes the changes live).
|
||||
///
|
||||
/// In production: POST .../edits/{editId}:commit
|
||||
pub fn commit_edit(&self, edit_id: &str) -> PublishResult<String> {
|
||||
// Returns the resulting version code
|
||||
Ok(format!("{}-committed", edit_id))
|
||||
}
|
||||
|
||||
/// Update rollout percentage for a track (for staged rollouts).
|
||||
pub fn update_rollout(&self, track: &str, percent: u8) -> PublishResult<()> {
|
||||
if percent > 100 {
|
||||
return Err(PublishError::Config(format!(
|
||||
"rollout percent {} exceeds 100",
|
||||
percent
|
||||
)));
|
||||
}
|
||||
let _ = (track, percent);
|
||||
// TODO: PATCH .../tracks/{track} with rollout fraction
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Full publish flow: create edit → upload bundle → assign to track → commit.
|
||||
pub fn publish(&self, aab_path: &str) -> PublishResult<PublishOutcome> {
|
||||
let metadata = StoreMetadata::load_from_dir(&self.publish_config.metadata_dir)?;
|
||||
let edit_id = self.create_edit()?;
|
||||
let version_code = self.upload_bundle(&edit_id, aab_path)?;
|
||||
|
||||
let track = &self.google_config.track;
|
||||
let rollout_fraction = self
|
||||
.publish_config
|
||||
.rollout
|
||||
.as_ref()
|
||||
.map(|r| r.initial_percent as f64 / 100.0)
|
||||
.unwrap_or(1.0);
|
||||
|
||||
self.assign_to_track(&edit_id, version_code, track, rollout_fraction)?;
|
||||
self.upload_listing(&edit_id, "en-US", &metadata)?;
|
||||
let commit_id = self.commit_edit(&edit_id)?;
|
||||
|
||||
Ok(PublishOutcome::new("google", &self.publish_config, track)
|
||||
.with_submission_id(commit_id))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
//! el-publish — App Store and Play Store publishing pipeline for el-ui.
|
||||
//!
|
||||
//! One command ships to every platform:
|
||||
//! ```bash
|
||||
//! el publish # all platforms
|
||||
//! el publish --apple # App Store only
|
||||
//! el publish --google # Play Store only
|
||||
//! el publish --beta # TestFlight + Play internal track
|
||||
//! ```
|
||||
//!
|
||||
//! Configuration in `manifest.el`:
|
||||
//! ```toml
|
||||
//! [publish]
|
||||
//! version = "1.0.0"
|
||||
//! build_number = 42
|
||||
//!
|
||||
//! [publish.apple]
|
||||
//! account = "will@neurontechnologies.ai"
|
||||
//! bundle_id = "ai.neurontechnologies.myapp"
|
||||
//!
|
||||
//! [publish.google]
|
||||
//! package = "ai.neurontechnologies.myapp"
|
||||
//! track = "internal"
|
||||
//! ```
|
||||
|
||||
pub mod apple;
|
||||
pub mod cert;
|
||||
pub mod config;
|
||||
pub mod google;
|
||||
pub mod metadata;
|
||||
pub mod rollout;
|
||||
pub mod screenshot;
|
||||
|
||||
pub use apple::ApplePublisher;
|
||||
pub use cert::{CertInfo, CertStore};
|
||||
pub use config::{AppleConfig, GoogleConfig, PublishConfig, RolloutConfig};
|
||||
pub use google::GooglePublisher;
|
||||
pub use metadata::StoreMetadata;
|
||||
pub use rollout::RolloutMonitor;
|
||||
pub use screenshot::{ScreenshotCapture, ScreenshotTarget};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum PublishError {
|
||||
#[error("config error: {0}")]
|
||||
Config(String),
|
||||
#[error("build error: {0}")]
|
||||
Build(String),
|
||||
#[error("upload error: {0}")]
|
||||
Upload(String),
|
||||
#[error("certificate error: {0}")]
|
||||
Certificate(String),
|
||||
#[error("metadata error: {0}")]
|
||||
Metadata(String),
|
||||
#[error("api error: {status} {body}")]
|
||||
Api { status: u16, body: String },
|
||||
#[error("io error: {0}")]
|
||||
Io(String),
|
||||
}
|
||||
|
||||
pub type PublishResult<T> = Result<T, PublishError>;
|
||||
|
||||
/// The outcome of a publish operation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PublishOutcome {
|
||||
pub platform: String,
|
||||
pub version: String,
|
||||
pub build_number: u32,
|
||||
pub track: String,
|
||||
pub rollout_percent: u8,
|
||||
pub submission_id: Option<String>,
|
||||
}
|
||||
|
||||
impl PublishOutcome {
|
||||
pub fn new(
|
||||
platform: impl Into<String>,
|
||||
config: &PublishConfig,
|
||||
track: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
platform: platform.into(),
|
||||
version: config.version.clone(),
|
||||
build_number: config.build_number,
|
||||
track: track.into(),
|
||||
rollout_percent: config.rollout.as_ref().map(|r| r.initial_percent).unwrap_or(100),
|
||||
submission_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_submission_id(mut self, id: impl Into<String>) -> Self {
|
||||
self.submission_id = Some(id.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
// el-publish — App Store + Play Store publishing pipeline.
|
||||
//
|
||||
// Two providers (Apple, Google), one publish flow:
|
||||
// 1. Validate config + certs
|
||||
// 2. Build artifact (delegated to el-ui-compiler / xcodebuild / gradle)
|
||||
// 3. Capture metadata + screenshots
|
||||
// 4. Upload to App Store Connect / Play Developer API
|
||||
// 5. Monitor rollout
|
||||
|
||||
// ── Errors ───────────────────────────────────────────────────────────────────
|
||||
|
||||
let PUB_ERR_CONFIG: String = "publish.config"
|
||||
let PUB_ERR_BUILD: String = "publish.build"
|
||||
let PUB_ERR_UPLOAD: String = "publish.upload"
|
||||
let PUB_ERR_CERT: String = "publish.certificate"
|
||||
let PUB_ERR_METADATA: String = "publish.metadata"
|
||||
let PUB_ERR_API: String = "publish.api"
|
||||
let PUB_ERR_IO: String = "publish.io"
|
||||
|
||||
// ── Tracks ───────────────────────────────────────────────────────────────────
|
||||
|
||||
let TRACK_PRODUCTION: String = "production"
|
||||
let TRACK_BETA: String = "beta" // TestFlight on Apple; "beta" on Google
|
||||
let TRACK_INTERNAL: String = "internal"
|
||||
let TRACK_ALPHA: String = "alpha"
|
||||
|
||||
// ── Apple config ─────────────────────────────────────────────────────────────
|
||||
|
||||
type AppleConfig {
|
||||
account: String // Apple ID
|
||||
bundle_id: String // ai.neurontechnologies.myapp
|
||||
team_id: String
|
||||
api_key_id: String // App Store Connect API key
|
||||
api_issuer: String
|
||||
}
|
||||
|
||||
// ── Google config ────────────────────────────────────────────────────────────
|
||||
|
||||
type GoogleConfig {
|
||||
package: String // ai.neurontechnologies.myapp
|
||||
track: String // production | beta | internal | alpha
|
||||
service_account_json_ref: String // secret ref to service-account JSON
|
||||
}
|
||||
|
||||
// ── Rollout (staged) ─────────────────────────────────────────────────────────
|
||||
|
||||
type RolloutConfig {
|
||||
initial_percent: Int // 0..100
|
||||
target_percent: Int
|
||||
bake_hours: Int // hours between rollout stages
|
||||
}
|
||||
|
||||
fn rollout_default() -> RolloutConfig {
|
||||
{ "initial_percent": 5, "target_percent": 100, "bake_hours": 24 }
|
||||
}
|
||||
|
||||
// ── Top-level publish config ────────────────────────────────────────────────
|
||||
|
||||
type PublishConfig {
|
||||
version: String
|
||||
build_number: Int
|
||||
apple_json: String // empty if not configured
|
||||
google_json: String
|
||||
rollout_json: String // RolloutConfig as JSON
|
||||
}
|
||||
|
||||
fn publish_config_new(version: String, build_number: Int) -> PublishConfig {
|
||||
{ "version": version, "build_number": build_number,
|
||||
"apple_json": "", "google_json": "", "rollout_json": json_encode(rollout_default()) }
|
||||
}
|
||||
|
||||
// ── Outcome ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type PublishOutcome {
|
||||
platform: String
|
||||
version: String
|
||||
build_number: Int
|
||||
track: String
|
||||
rollout_percent: Int
|
||||
submission_id: String
|
||||
}
|
||||
|
||||
fn outcome_new(platform: String, cfg: PublishConfig, track: String) -> PublishOutcome {
|
||||
let pct: Int = 100
|
||||
if !str_eq(cfg.rollout_json, "") {
|
||||
let pct = str_to_int(json_get(cfg.rollout_json, "initial_percent"))
|
||||
}
|
||||
{ "platform": platform, "version": cfg.version, "build_number": cfg.build_number,
|
||||
"track": track, "rollout_percent": pct, "submission_id": "" }
|
||||
}
|
||||
|
||||
// ── Cert store ──────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Tracks .p12 / .mobileprovision (Apple) and signing keystores (Google).
|
||||
|
||||
type CertInfo {
|
||||
kind: String // "apple_p12" | "apple_provisioning" | "google_keystore"
|
||||
path: String
|
||||
expires_at: String // ISO 8601
|
||||
fingerprint: String
|
||||
}
|
||||
|
||||
fn cert_is_valid(c: CertInfo) -> Bool {
|
||||
!time_after(time_now_iso(), c.expires_at)
|
||||
}
|
||||
|
||||
fn cert_store_load(dir: String) -> String {
|
||||
// Returns JSON array of CertInfo loaded from `dir`.
|
||||
let files: String = fs_list(dir)
|
||||
let out: String = "[]"
|
||||
let n: Int = json_array_len(files)
|
||||
let i: Int = 0
|
||||
while i < n {
|
||||
let path: String = json_array_get(files, i)
|
||||
let info: CertInfo = cert_inspect(path)
|
||||
let out = json_array_push(out, json_encode(info))
|
||||
let i = i + 1
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn cert_inspect(path: String) -> CertInfo {
|
||||
let kind: String = "apple_p12"
|
||||
if str_ends_with(path, ".mobileprovision") { let kind = "apple_provisioning" }
|
||||
if str_ends_with(path, ".jks") { let kind = "google_keystore" }
|
||||
if str_ends_with(path, ".keystore") { let kind = "google_keystore" }
|
||||
let exp: String = exec_capture("openssl", "x509 -enddate -noout -in " + path)
|
||||
{ "kind": kind, "path": path, "expires_at": exp, "fingerprint": "" }
|
||||
}
|
||||
|
||||
// ── Metadata ────────────────────────────────────────────────────────────────
|
||||
|
||||
type StoreMetadata {
|
||||
title: String
|
||||
subtitle: String
|
||||
description: String
|
||||
keywords: String // comma-separated
|
||||
privacy_url: String
|
||||
support_url: String
|
||||
locale: String // e.g. "en-US"
|
||||
}
|
||||
|
||||
fn metadata_load(path: String, locale: String) -> StoreMetadata {
|
||||
let raw: String = fs_read(path + "/" + locale + ".toml")
|
||||
let json: String = toml_to_json(raw)
|
||||
{ "title": json_get(json, "title"),
|
||||
"subtitle": json_get(json, "subtitle"),
|
||||
"description": json_get(json, "description"),
|
||||
"keywords": json_get(json, "keywords"),
|
||||
"privacy_url": json_get(json, "privacy_url"),
|
||||
"support_url": json_get(json, "support_url"),
|
||||
"locale": locale }
|
||||
}
|
||||
|
||||
// ── Screenshots ─────────────────────────────────────────────────────────────
|
||||
|
||||
type ScreenshotTarget {
|
||||
platform: String // "apple" | "google"
|
||||
device_class: String // "iphone-6.7" | "ipad-12.9" | "phone" | "tablet" | "tv"
|
||||
locale: String
|
||||
expected_resolution: String // "1290x2796"
|
||||
}
|
||||
|
||||
fn target_iphone_6_7(locale: String) -> ScreenshotTarget {
|
||||
{ "platform": "apple", "device_class": "iphone-6.7",
|
||||
"locale": locale, "expected_resolution": "1290x2796" }
|
||||
}
|
||||
|
||||
fn target_phone_google(locale: String) -> ScreenshotTarget {
|
||||
{ "platform": "google", "device_class": "phone",
|
||||
"locale": locale, "expected_resolution": "1080x1920" }
|
||||
}
|
||||
|
||||
fn screenshot_capture(target: ScreenshotTarget, simulator_id: String, output_dir: String) -> String {
|
||||
// Drives simulator/emulator to capture at expected resolution.
|
||||
let path: String = output_dir + "/" + target.platform + "_" + target.device_class
|
||||
+ "_" + target.locale + ".png"
|
||||
exec_capture("xcrun", "simctl io " + simulator_id + " screenshot " + path)
|
||||
path
|
||||
}
|
||||
|
||||
// ── Apple publisher ─────────────────────────────────────────────────────────
|
||||
|
||||
fn apple_publish(cfg: PublishConfig, apple: AppleConfig, ipa_path: String, track: String) -> PublishOutcome {
|
||||
let api_key: String = secret_lookup("apple.api_key")
|
||||
let resp: String = exec_capture("xcrun", "altool --upload-app -f " + ipa_path
|
||||
+ " --type ios --apiKey " + apple.api_key_id
|
||||
+ " --apiIssuer " + apple.api_issuer)
|
||||
let outcome: PublishOutcome = outcome_new("apple", cfg, track)
|
||||
let submission: String = json_get(resp, "submission_id")
|
||||
{ "platform": outcome.platform, "version": outcome.version,
|
||||
"build_number": outcome.build_number, "track": outcome.track,
|
||||
"rollout_percent": outcome.rollout_percent, "submission_id": submission }
|
||||
}
|
||||
|
||||
// ── Google publisher ────────────────────────────────────────────────────────
|
||||
|
||||
fn google_publish(cfg: PublishConfig, google: GoogleConfig, aab_path: String) -> PublishOutcome {
|
||||
let sa_json: String = secret_lookup(google.service_account_json_ref)
|
||||
let token: String = google_oauth_token(sa_json)
|
||||
let edit: String = google_play_edit_create(google.package, token)
|
||||
let upload: String = google_play_upload_aab(google.package, edit, aab_path, token)
|
||||
let assigned: String = google_play_track_assign(google.package, edit, google.track,
|
||||
cfg.version, cfg.build_number, token)
|
||||
google_play_edit_commit(google.package, edit, token)
|
||||
let outcome: PublishOutcome = outcome_new("google", cfg, google.track)
|
||||
{ "platform": outcome.platform, "version": outcome.version,
|
||||
"build_number": outcome.build_number, "track": outcome.track,
|
||||
"rollout_percent": outcome.rollout_percent, "submission_id": json_get(upload, "id") }
|
||||
}
|
||||
|
||||
// ── Rollout monitor ─────────────────────────────────────────────────────────
|
||||
|
||||
fn rollout_monitor(outcome: PublishOutcome, cfg: RolloutConfig) -> Bool {
|
||||
// Polls store status, advances rollout percentage in stages.
|
||||
let current: Int = outcome.rollout_percent
|
||||
while current < cfg.target_percent {
|
||||
sleep_seconds(cfg.bake_hours * 3600)
|
||||
let crash_rate: Int = fetch_crash_rate(outcome.platform, outcome.version)
|
||||
if crash_rate > 100 { // 1% crash threshold (per 10000)
|
||||
println("[el-publish] rollout halted: crash rate too high")
|
||||
return false
|
||||
}
|
||||
let next: Int = current + 25
|
||||
if next > cfg.target_percent { let next = cfg.target_percent }
|
||||
rollout_advance(outcome, next)
|
||||
let current = next
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn rollout_advance(outcome: PublishOutcome, percent: Int) -> Bool {
|
||||
println("[el-publish] " + outcome.platform + " rollout -> " + int_to_str(percent) + "%")
|
||||
true
|
||||
}
|
||||
|
||||
// ── Entry — smoke test ──────────────────────────────────────────────────────
|
||||
|
||||
let cfg: PublishConfig = publish_config_new("1.0.0", 42)
|
||||
let apple: AppleConfig = { "account": "will@neurontechnologies.ai",
|
||||
"bundle_id": "ai.neurontechnologies.myapp", "team_id": "ABC123",
|
||||
"api_key_id": "KEY", "api_issuer": "ISS" }
|
||||
println("[el-publish] " + cfg.version + " (" + int_to_str(cfg.build_number) + ") for " + apple.bundle_id)
|
||||
@@ -0,0 +1,122 @@
|
||||
//! Store metadata — reads title.txt, description.txt, whats-new.txt, etc.
|
||||
//!
|
||||
//! Directory structure (mirrors Fastlane's metadata format):
|
||||
//! ```text
|
||||
//! store/
|
||||
//! en-US/
|
||||
//! title.txt
|
||||
//! description.txt
|
||||
//! whats-new.txt
|
||||
//! keywords.txt
|
||||
//! promotional-text.txt
|
||||
//! de-DE/
|
||||
//! title.txt
|
||||
//! ...
|
||||
//! ```
|
||||
|
||||
use crate::{PublishError, PublishResult};
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
/// Metadata for a single locale.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct LocaleMetadata {
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub whats_new: String,
|
||||
pub keywords: Vec<String>,
|
||||
pub promotional_text: String,
|
||||
}
|
||||
|
||||
impl LocaleMetadata {
|
||||
pub fn new(title: impl Into<String>, description: impl Into<String>) -> Self {
|
||||
Self {
|
||||
title: title.into(),
|
||||
description: description.into(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_whats_new(mut self, whats_new: impl Into<String>) -> Self {
|
||||
self.whats_new = whats_new.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Complete store metadata across all locales.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct StoreMetadata {
|
||||
pub locales: HashMap<String, LocaleMetadata>,
|
||||
}
|
||||
|
||||
impl StoreMetadata {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn add_locale(mut self, locale: impl Into<String>, metadata: LocaleMetadata) -> Self {
|
||||
self.locales.insert(locale.into(), metadata);
|
||||
self
|
||||
}
|
||||
|
||||
/// Get metadata for a specific locale, falling back to `en-US`.
|
||||
pub fn for_locale(&self, locale: &str) -> Option<&LocaleMetadata> {
|
||||
self.locales.get(locale).or_else(|| self.locales.get("en-US"))
|
||||
}
|
||||
|
||||
/// Load metadata from the directory structure.
|
||||
///
|
||||
/// Falls back gracefully: if the metadata directory doesn't exist,
|
||||
/// returns empty metadata (don't fail the publish for missing store copy).
|
||||
pub fn load_from_dir(dir: &str) -> PublishResult<Self> {
|
||||
let path = Path::new(dir);
|
||||
if !path.exists() {
|
||||
// Warn but don't fail — metadata is optional for internal/alpha builds.
|
||||
return Ok(Self::new());
|
||||
}
|
||||
|
||||
let mut metadata = Self::new();
|
||||
|
||||
let read_dir = std::fs::read_dir(path).map_err(|e| {
|
||||
PublishError::Metadata(format!("failed to read metadata dir {}: {}", dir, e))
|
||||
})?;
|
||||
|
||||
for entry in read_dir.flatten() {
|
||||
let locale_path = entry.path();
|
||||
if !locale_path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let locale = locale_path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
if locale.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let locale_meta = LocaleMetadata {
|
||||
title: read_file(&locale_path, "title.txt"),
|
||||
description: read_file(&locale_path, "description.txt"),
|
||||
whats_new: read_file(&locale_path, "whats-new.txt"),
|
||||
keywords: read_file(&locale_path, "keywords.txt")
|
||||
.split(',')
|
||||
.map(|k| k.trim().to_string())
|
||||
.filter(|k| !k.is_empty())
|
||||
.collect(),
|
||||
promotional_text: read_file(&locale_path, "promotional-text.txt"),
|
||||
};
|
||||
|
||||
metadata.locales.insert(locale, locale_meta);
|
||||
}
|
||||
|
||||
Ok(metadata)
|
||||
}
|
||||
}
|
||||
|
||||
fn read_file(dir: &Path, filename: &str) -> String {
|
||||
std::fs::read_to_string(dir.join(filename))
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
//! Rollout monitor — checks crash rate, advances or halts rollout.
|
||||
|
||||
use crate::{config::RolloutConfig, PublishError, PublishResult};
|
||||
|
||||
/// Current rollout state.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RolloutState {
|
||||
pub track: String,
|
||||
pub current_percent: u8,
|
||||
pub crash_rate: f64,
|
||||
pub is_halted: bool,
|
||||
pub hours_elapsed: u64,
|
||||
}
|
||||
|
||||
impl RolloutState {
|
||||
pub fn new(track: impl Into<String>, initial_percent: u8) -> Self {
|
||||
Self {
|
||||
track: track.into(),
|
||||
current_percent: initial_percent,
|
||||
crash_rate: 0.0,
|
||||
is_halted: false,
|
||||
hours_elapsed: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Monitors a staged rollout and decides whether to advance or halt.
|
||||
pub struct RolloutMonitor {
|
||||
pub config: RolloutConfig,
|
||||
}
|
||||
|
||||
impl RolloutMonitor {
|
||||
pub fn new(config: RolloutConfig) -> Self {
|
||||
Self { config }
|
||||
}
|
||||
|
||||
/// Evaluate whether to advance, halt, or maintain the current rollout.
|
||||
pub fn evaluate(&self, state: &RolloutState) -> RolloutDecision {
|
||||
if state.is_halted {
|
||||
return RolloutDecision::Halted {
|
||||
reason: "rollout was previously halted".into(),
|
||||
};
|
||||
}
|
||||
|
||||
// Check crash rate
|
||||
if state.crash_rate > self.config.max_crash_rate {
|
||||
return RolloutDecision::Halt {
|
||||
reason: format!(
|
||||
"crash rate {:.2}% exceeds threshold {:.2}%",
|
||||
state.crash_rate * 100.0,
|
||||
self.config.max_crash_rate * 100.0
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// Check if we should advance
|
||||
if self.config.auto_advance
|
||||
&& state.hours_elapsed >= self.config.advance_after_hours
|
||||
&& state.current_percent < 100
|
||||
{
|
||||
let next_percent = advance_percent(state.current_percent);
|
||||
return RolloutDecision::Advance { to_percent: next_percent };
|
||||
}
|
||||
|
||||
RolloutDecision::Maintain
|
||||
}
|
||||
|
||||
/// Apply a rollout decision — returns the new rollout state.
|
||||
pub fn apply(
|
||||
&self,
|
||||
mut state: RolloutState,
|
||||
decision: &RolloutDecision,
|
||||
) -> PublishResult<RolloutState> {
|
||||
match decision {
|
||||
RolloutDecision::Advance { to_percent } => {
|
||||
state.current_percent = *to_percent;
|
||||
state.hours_elapsed = 0;
|
||||
}
|
||||
RolloutDecision::Halt { reason } => {
|
||||
state.is_halted = true;
|
||||
let _ = reason;
|
||||
}
|
||||
RolloutDecision::Halted { .. } => {
|
||||
return Err(PublishError::Config(
|
||||
"cannot apply decision to halted rollout".into(),
|
||||
));
|
||||
}
|
||||
RolloutDecision::Maintain => {}
|
||||
}
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
/// Run a complete rollout cycle: fetch metrics, evaluate, apply.
|
||||
///
|
||||
/// In production: fetch crash rate from Firebase Crashlytics or App Store
|
||||
/// Connect Analytics API.
|
||||
pub fn tick(&self, mut state: RolloutState) -> PublishResult<(RolloutState, RolloutDecision)> {
|
||||
// TODO: fetch real crash rate from analytics API
|
||||
// let crash_rate = analytics_client.crash_rate(&state.track, &config.build).await?;
|
||||
// state.crash_rate = crash_rate;
|
||||
state.hours_elapsed += 1;
|
||||
|
||||
let decision = self.evaluate(&state);
|
||||
let new_state = self.apply(state, &decision)?;
|
||||
Ok((new_state, decision))
|
||||
}
|
||||
}
|
||||
|
||||
/// The outcome of a rollout evaluation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum RolloutDecision {
|
||||
/// Advance to a higher rollout percentage.
|
||||
Advance { to_percent: u8 },
|
||||
/// Halt the rollout due to high crash rate.
|
||||
Halt { reason: String },
|
||||
/// Rollout was already halted.
|
||||
Halted { reason: String },
|
||||
/// No change needed.
|
||||
Maintain,
|
||||
}
|
||||
|
||||
/// Determine the next rollout percentage.
|
||||
/// Uses the standard staged-rollout progression: 10 → 25 → 50 → 100.
|
||||
fn advance_percent(current: u8) -> u8 {
|
||||
match current {
|
||||
0..=9 => 10,
|
||||
10..=24 => 25,
|
||||
25..=49 => 50,
|
||||
_ => 100,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
//! Screenshot pipeline — captures app screenshots for each store target size.
|
||||
|
||||
use crate::PublishResult;
|
||||
|
||||
/// A screenshot target device/size specification.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ScreenshotTarget {
|
||||
/// Display name (e.g. "iPhone 6.7\"")
|
||||
pub name: String,
|
||||
/// Target identifier used in manifest.el (e.g. "iphone-6.7")
|
||||
pub id: String,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub platform: ScreenshotPlatform,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ScreenshotPlatform {
|
||||
Ios,
|
||||
Android,
|
||||
}
|
||||
|
||||
impl ScreenshotTarget {
|
||||
/// All standard screenshot targets, matching Apple and Google requirements.
|
||||
pub fn all_standard() -> Vec<Self> {
|
||||
vec![
|
||||
Self {
|
||||
name: "iPhone 6.7\"".into(),
|
||||
id: "iphone-6.7".into(),
|
||||
width: 1290,
|
||||
height: 2796,
|
||||
platform: ScreenshotPlatform::Ios,
|
||||
},
|
||||
Self {
|
||||
name: "iPhone 6.1\"".into(),
|
||||
id: "iphone-6.1".into(),
|
||||
width: 1179,
|
||||
height: 2556,
|
||||
platform: ScreenshotPlatform::Ios,
|
||||
},
|
||||
Self {
|
||||
name: "iPad 13\"".into(),
|
||||
id: "ipad-13".into(),
|
||||
width: 2064,
|
||||
height: 2752,
|
||||
platform: ScreenshotPlatform::Ios,
|
||||
},
|
||||
Self {
|
||||
name: "Android Phone".into(),
|
||||
id: "android-phone".into(),
|
||||
width: 1080,
|
||||
height: 1920,
|
||||
platform: ScreenshotPlatform::Android,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// Find a target by its ID.
|
||||
pub fn find(id: &str) -> Option<Self> {
|
||||
Self::all_standard().into_iter().find(|t| t.id == id)
|
||||
}
|
||||
}
|
||||
|
||||
/// A captured screenshot.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Screenshot {
|
||||
pub target: ScreenshotTarget,
|
||||
pub locale: String,
|
||||
pub scenario: String,
|
||||
/// Path to the PNG file.
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
/// Trait for screenshot capture backends.
|
||||
///
|
||||
/// Implementations might use:
|
||||
/// - `xcrun simctl` for iOS Simulator screenshots
|
||||
/// - Android Emulator ADB screenshots
|
||||
/// - Puppeteer/Playwright for web screenshots
|
||||
pub trait ScreenshotCapture: Send + Sync {
|
||||
/// Capture a screenshot for the given scenario and target.
|
||||
fn capture(
|
||||
&self,
|
||||
scenario: &str,
|
||||
target: &ScreenshotTarget,
|
||||
locale: &str,
|
||||
output_dir: &str,
|
||||
) -> PublishResult<Screenshot>;
|
||||
|
||||
/// Capture all scenarios for all targets and locales.
|
||||
fn capture_all(
|
||||
&self,
|
||||
scenarios: &[String],
|
||||
targets: &[ScreenshotTarget],
|
||||
locales: &[String],
|
||||
output_dir: &str,
|
||||
) -> PublishResult<Vec<Screenshot>> {
|
||||
let mut screenshots = Vec::new();
|
||||
for scenario in scenarios {
|
||||
for target in targets {
|
||||
for locale in locales {
|
||||
let shot = self.capture(scenario, target, locale, output_dir)?;
|
||||
screenshots.push(shot);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(screenshots)
|
||||
}
|
||||
}
|
||||
|
||||
/// Stub screenshot capture (produces placeholder paths without actually
|
||||
/// launching a simulator). A future agent fills in the real capture logic.
|
||||
pub struct StubScreenshotCapture;
|
||||
|
||||
impl ScreenshotCapture for StubScreenshotCapture {
|
||||
fn capture(
|
||||
&self,
|
||||
scenario: &str,
|
||||
target: &ScreenshotTarget,
|
||||
locale: &str,
|
||||
output_dir: &str,
|
||||
) -> PublishResult<Screenshot> {
|
||||
let filename = format!(
|
||||
"{}/{}/{}-{}.png",
|
||||
output_dir, locale, target.id, scenario
|
||||
);
|
||||
// TODO: actually launch simulator/emulator, navigate to scenario, capture PNG
|
||||
Ok(Screenshot {
|
||||
target: target.clone(),
|
||||
locale: locale.to_string(),
|
||||
scenario: scenario.to_string(),
|
||||
path: filename,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
//! Tests for el-publish.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::{
|
||||
cert::{CertInfo, CertStore},
|
||||
config::{AppleConfig, GoogleConfig, PublishConfig, RolloutConfig},
|
||||
metadata::{LocaleMetadata, StoreMetadata},
|
||||
rollout::{RolloutDecision, RolloutMonitor, RolloutState},
|
||||
screenshot::{ScreenshotCapture, ScreenshotTarget, StubScreenshotCapture},
|
||||
PublishError,
|
||||
};
|
||||
|
||||
fn unix_now() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn test_config() -> PublishConfig {
|
||||
PublishConfig::new("1.2.0", 42)
|
||||
.with_apple(AppleConfig::new(
|
||||
"will@example.com",
|
||||
"TEAM123",
|
||||
"ai.example.myapp",
|
||||
))
|
||||
.with_google(GoogleConfig::new("./secrets/google.json", "ai.example.myapp"))
|
||||
}
|
||||
|
||||
// ── Test 1: PublishConfig parses version and build number ─────────────────
|
||||
#[test]
|
||||
fn test_publish_config_basic() {
|
||||
let cfg = test_config();
|
||||
assert_eq!(cfg.version, "1.2.0");
|
||||
assert_eq!(cfg.build_number, 42);
|
||||
assert!(cfg.apple.is_some());
|
||||
assert!(cfg.google.is_some());
|
||||
}
|
||||
|
||||
// ── Test 2: AppleConfig has correct fields ────────────────────────────────
|
||||
#[test]
|
||||
fn test_apple_config() {
|
||||
let cfg = AppleConfig::new("will@example.com", "TEAM123", "ai.example.myapp")
|
||||
.with_category("utilities");
|
||||
assert_eq!(cfg.account, "will@example.com");
|
||||
assert_eq!(cfg.team_id, "TEAM123");
|
||||
assert_eq!(cfg.category, "utilities");
|
||||
}
|
||||
|
||||
// ── Test 3: GoogleConfig track defaults to internal ───────────────────────
|
||||
#[test]
|
||||
fn test_google_config_default_track() {
|
||||
let cfg = GoogleConfig::new("./google.json", "ai.example.myapp");
|
||||
assert_eq!(cfg.track, "internal");
|
||||
}
|
||||
|
||||
// ── Test 4: GoogleConfig with_track ──────────────────────────────────────
|
||||
#[test]
|
||||
fn test_google_config_track() {
|
||||
let cfg = GoogleConfig::new("./google.json", "ai.example.myapp")
|
||||
.with_track("production");
|
||||
assert_eq!(cfg.track, "production");
|
||||
}
|
||||
|
||||
// ── Test 5: RolloutConfig default values ─────────────────────────────────
|
||||
#[test]
|
||||
fn test_rollout_config_default() {
|
||||
let cfg = RolloutConfig::default();
|
||||
assert_eq!(cfg.initial_percent, 100);
|
||||
assert!(!cfg.auto_advance);
|
||||
assert_eq!(cfg.max_crash_rate, 0.01);
|
||||
}
|
||||
|
||||
// ── Test 6: RolloutMonitor evaluates normal state as Maintain ─────────────
|
||||
#[test]
|
||||
fn test_rollout_maintain() {
|
||||
let cfg = RolloutConfig::new(10).with_auto_advance(24);
|
||||
let monitor = RolloutMonitor::new(cfg);
|
||||
let state = RolloutState {
|
||||
track: "production".into(),
|
||||
current_percent: 10,
|
||||
crash_rate: 0.001, // well below threshold
|
||||
is_halted: false,
|
||||
hours_elapsed: 5, // less than 24h advance threshold
|
||||
};
|
||||
let decision = monitor.evaluate(&state);
|
||||
assert!(matches!(decision, RolloutDecision::Maintain));
|
||||
}
|
||||
|
||||
// ── Test 7: RolloutMonitor halts on high crash rate ───────────────────────
|
||||
#[test]
|
||||
fn test_rollout_halt_on_crash_rate() {
|
||||
let cfg = RolloutConfig::new(10);
|
||||
let monitor = RolloutMonitor::new(cfg);
|
||||
let state = RolloutState {
|
||||
track: "production".into(),
|
||||
current_percent: 10,
|
||||
crash_rate: 0.05, // 5% > 1% threshold
|
||||
is_halted: false,
|
||||
hours_elapsed: 2,
|
||||
};
|
||||
let decision = monitor.evaluate(&state);
|
||||
assert!(matches!(decision, RolloutDecision::Halt { .. }));
|
||||
}
|
||||
|
||||
// ── Test 8: RolloutMonitor advances after threshold hours ─────────────────
|
||||
#[test]
|
||||
fn test_rollout_advance() {
|
||||
let cfg = RolloutConfig::new(10).with_auto_advance(24);
|
||||
let monitor = RolloutMonitor::new(cfg);
|
||||
let state = RolloutState {
|
||||
track: "production".into(),
|
||||
current_percent: 10,
|
||||
crash_rate: 0.001,
|
||||
is_halted: false,
|
||||
hours_elapsed: 25, // exceeds 24h threshold
|
||||
};
|
||||
let decision = monitor.evaluate(&state);
|
||||
assert!(matches!(decision, RolloutDecision::Advance { to_percent: 25 }));
|
||||
}
|
||||
|
||||
// ── Test 9: RolloutMonitor advance progression 10→25→50→100 ──────────────
|
||||
#[test]
|
||||
fn test_rollout_advance_progression() {
|
||||
let cfg = RolloutConfig::new(10).with_auto_advance(1);
|
||||
let monitor = RolloutMonitor::new(cfg);
|
||||
|
||||
let mut state = RolloutState {
|
||||
track: "production".into(),
|
||||
current_percent: 10,
|
||||
crash_rate: 0.001,
|
||||
is_halted: false,
|
||||
hours_elapsed: 5,
|
||||
};
|
||||
|
||||
let expected = [25u8, 50, 100];
|
||||
for expected_next in expected {
|
||||
let decision = monitor.evaluate(&state);
|
||||
if let RolloutDecision::Advance { to_percent } = decision {
|
||||
state.current_percent = to_percent;
|
||||
state.hours_elapsed = 5;
|
||||
assert_eq!(to_percent, expected_next);
|
||||
} else {
|
||||
panic!("expected Advance decision, got {:?}", decision);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test 10: CertInfo::days_until_expiry ─────────────────────────────────
|
||||
#[test]
|
||||
fn test_cert_days_until_expiry() {
|
||||
let future = unix_now() + 30 * 86400; // 30 days from now
|
||||
let cert = CertInfo::new("My Cert", "TEAM123", "SN001", "Distribution", future);
|
||||
let days = cert.days_until_expiry();
|
||||
assert!(days >= 29 && days <= 30, "should be ~30 days: {}", days);
|
||||
}
|
||||
|
||||
// ── Test 11: CertInfo::is_expired for past cert ───────────────────────────
|
||||
#[test]
|
||||
fn test_cert_is_expired() {
|
||||
let past = unix_now() - 86400; // yesterday
|
||||
let cert = CertInfo::new("Old Cert", "TEAM123", "SN002", "Distribution", past);
|
||||
assert!(cert.is_expired());
|
||||
assert_eq!(cert.days_until_expiry(), 0);
|
||||
}
|
||||
|
||||
// ── Test 12: CertStore::renewal_warnings finds expiring certs ─────────────
|
||||
#[test]
|
||||
fn test_cert_store_renewal_warnings() {
|
||||
let mut store = CertStore::new().with_warn_days(45);
|
||||
let soon = unix_now() + 10 * 86400; // 10 days away
|
||||
store.add(CertInfo::new("My Cert", "T1", "SN1", "Distribution", soon));
|
||||
let warnings = store.renewal_warnings();
|
||||
assert!(!warnings.is_empty(), "should have warnings for cert expiring in 10 days");
|
||||
assert!(warnings[0].contains("EXPIRING SOON") || warnings[0].contains("EXPIRED"));
|
||||
}
|
||||
|
||||
// ── Test 13: CertStore::validate_for_distribution fails without cert ───────
|
||||
#[test]
|
||||
fn test_cert_store_validate_no_cert() {
|
||||
let store = CertStore::new();
|
||||
let result = store.validate_for_distribution("TEAM123");
|
||||
assert!(result.is_err());
|
||||
match result {
|
||||
Err(PublishError::Certificate(_)) => {}
|
||||
_ => panic!("expected Certificate error"),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test 14: StoreMetadata::for_locale fallback to en-US ──────────────────
|
||||
#[test]
|
||||
fn test_store_metadata_locale_fallback() {
|
||||
let meta = StoreMetadata::new()
|
||||
.add_locale(
|
||||
"en-US",
|
||||
LocaleMetadata::new("My App", "The best app ever"),
|
||||
);
|
||||
// de-DE not set, should fall back to en-US
|
||||
let locale_meta = meta.for_locale("de-DE").unwrap();
|
||||
assert_eq!(locale_meta.title, "My App");
|
||||
}
|
||||
|
||||
// ── Test 15: StoreMetadata::load_from_dir with missing dir is ok ──────────
|
||||
#[test]
|
||||
fn test_store_metadata_missing_dir() {
|
||||
// Missing directory should not error — returns empty metadata
|
||||
let result = StoreMetadata::load_from_dir("/nonexistent/path/that/doesnt/exist");
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
// ── Test 16: ScreenshotTarget::all_standard has expected targets ──────────
|
||||
#[test]
|
||||
fn test_screenshot_targets() {
|
||||
let targets = ScreenshotTarget::all_standard();
|
||||
assert!(targets.len() >= 4, "should have at least 4 standard targets");
|
||||
let ids: Vec<&str> = targets.iter().map(|t| t.id.as_str()).collect();
|
||||
assert!(ids.contains(&"iphone-6.7"));
|
||||
assert!(ids.contains(&"android-phone"));
|
||||
}
|
||||
|
||||
// ── Test 17: ScreenshotTarget::find by ID ────────────────────────────────
|
||||
#[test]
|
||||
fn test_screenshot_target_find() {
|
||||
let target = ScreenshotTarget::find("ipad-13").unwrap();
|
||||
assert_eq!(target.name, "iPad 13\"");
|
||||
assert_eq!(target.width, 2064);
|
||||
}
|
||||
|
||||
// ── Test 18: StubScreenshotCapture produces paths ─────────────────────────
|
||||
#[test]
|
||||
fn test_stub_screenshot_capture() {
|
||||
let capture = StubScreenshotCapture;
|
||||
let target = ScreenshotTarget::find("iphone-6.7").unwrap();
|
||||
let shot = capture.capture("home_screen", &target, "en-US", "/tmp/screenshots").unwrap();
|
||||
assert!(shot.path.contains("iphone-6.7"));
|
||||
assert!(shot.path.contains("home_screen"));
|
||||
assert_eq!(shot.locale, "en-US");
|
||||
}
|
||||
|
||||
// ── Test 19: PublishConfig with rollout ───────────────────────────────────
|
||||
#[test]
|
||||
fn test_publish_config_with_rollout() {
|
||||
let rollout = RolloutConfig::new(10)
|
||||
.with_auto_advance(24)
|
||||
.with_max_crash_rate(0.005);
|
||||
let cfg = PublishConfig::new("1.0.0", 1).with_rollout(rollout);
|
||||
let r = cfg.rollout.unwrap();
|
||||
assert_eq!(r.initial_percent, 10);
|
||||
assert!(r.auto_advance);
|
||||
assert_eq!(r.max_crash_rate, 0.005);
|
||||
}
|
||||
|
||||
// ── Test 20: PublishConfig screenshot_targets ─────────────────────────────
|
||||
#[test]
|
||||
fn test_publish_config_screenshot_targets() {
|
||||
let cfg = PublishConfig::new("1.0.0", 1)
|
||||
.with_screenshot_targets(vec!["iphone-6.7", "android-phone"]);
|
||||
assert_eq!(cfg.screenshot_targets.len(), 2);
|
||||
assert!(cfg.screenshot_targets.contains(&"iphone-6.7".to_string()));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user