Files
el/ui/vessels/el-publish/src/main.el

244 lines
11 KiB
EmacsLisp

// 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)