244 lines
11 KiB
EmacsLisp
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)
|