Archived
f4abfe6fdc
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/.
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)
|