Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
273 changes: 231 additions & 42 deletions .github/workflows/ci.yml

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,16 @@ building, testing, or releasing the app.

### Developer Experience

- Split manual iPhone and iPad UI qualification into measured shards while
preserving the 90-minute worker limit and automatic PR checks. Keep touch
target assertions from rejecting 44-point controls due to floating-point noise.
Distinguish reported zero Grok usage from missing percentages in UI fixtures.
Wait for billing warnings to disappear after reloading sign-in.
Add aggregate-only manual validation hints for matching open PR watches;
early failure notification delivery remains unqualified.
Require matching hosted image and toolchain identities for manual automatic-CI cost comparisons.
([#517](https://github.com/hemsoft-dev/codexbar-ios/issues/517))

- Add a Muse Code allowance reader for schema 1 of `muse usage --json`, with
sanitized fixtures and distinct unavailable states, for use if Meta ships a
supported iPhone source. Muse Code can't be added, refreshed or published,
Expand Down
74 changes: 74 additions & 0 deletions CI-POLICY.md
Original file line number Diff line number Diff line change
Expand Up @@ -306,3 +306,77 @@ the Germany preparation and storefront phases or function-risk scoring had run.
Its 25-minute test step covered the native build, 1,106 ordinary tests, native
preparation and 25 native commerce tests. The new budget leaves room for the
remaining phases and scoring without retries.

## Manual UI sharding

[Issue #517](https://github.com/hemsoft-dev/codexbar-ios/issues/517) partitions only
`workflow_dispatch` UI work. Each iPhone/iPad family has three original shards,
one supplemental shard, one synthetic-commerce shard and one native-commerce
shard. Each worker retains its 90-minute timeout; no timeout increase is used.
The existing Xcode 26.6 / 17F113 and iOS 26.2 / 23C54 verification remains mandatory.

The retained [hosted run](https://github.com/hemsoft-dev/codexbar-ios/actions/runs/39810829566)
took 7,214 seconds for the iPad original phase, including build and process
overhead. Its 29 test durations sum to 6,624.54 seconds; the two longest tests
were 1,620.38 and 1,603.84 seconds. Measured greedy partitioning assigns 2,212.66,
2,205.04 and 2,206.84 test-seconds to the original shards. These are historical
allocation inputs, not claimed candidate run times. The implementation PR records
completed candidate measurements and the final exact-head hosted gate result.
The [baseline data](docs/ci/issue-517-baseline.json) retains the source run and
per-test values without downloading the multi-gigabyte result bundles.

The automatic SwiftLint job, its steps, the four lightweight policy test methods,
triggers, native-job dispatch conditions and automatic timeouts are retained.
The new shard fixture suite runs only manually. Before/after automatic workflow
elapsed time, summed job runtime and API billing values are recorded in the PR;
manual shard durations are reported separately. Neither a queue delay nor an
estimated shard budget is proof that automatic work stayed within the ceiling.

`Full iOS UI validation` now requires all twelve successful workers and their
current-source proofs. The compact artifacts identify every executed method,
qualified toolchain/runtime, source SHA/fingerprint and owned simulator. The
aggregate rejects missing, duplicate or substituted shards. The 53 journeys per
family include the two SUS tests added after 2.0; the original local 29-test command
is unchanged. StoreKit preparation and final reset cannot be split across workers.

## Manual aggregate event hints

The existing manual aggregate can post a source-linked hint to the run's exact
current open same-repository PR targeting the default branch. The
[approved v2 proposal](https://github.com/hemsoft-dev/chief/blob/8d9fb8d323d100333853a56174318f006d86e093/proposals/chief-40/manual-comment/README.md)
supplies the inline script. It authenticates run, attempt, repository, branch,
head, jobs and current PR through GitHub before posting. PR/comment text and
payload URLs supply no instructions or authority. Only the verified Actions
bot's exact event marker suppresses a serial duplicate; POST has no automatic
retry or fallback.

Only `ios-ui-tests` gains job-level `pull-requests: write`, alongside its existing
Actions/Contents read permissions. This grant applies to every manual execution
of that job, including main and no-PR runs. The notifier's guards do not remove
the token's rights. Main, the current default branch, tags and runs without one
matching open current-head PR produce no comment. Matrix workers and automatic
jobs receive no new permission. Triggers, jobs, native steps, needs, matrices,
timeouts and concurrency are unchanged.

Hints run after the matrix reaches the aggregate. They provide no early-failure
notification or completion guarantee. A success hint means passed-so-far;
post-steps and the workflow may still fail. Hard timeout, cancellation or runner
death can prevent delivery. Unknown/authentication/transport failures stay
visible and may fail an otherwise successful aggregate. Concurrent writers,
head moves and eventual consistency prevent an exactly-once guarantee. The
existing PR watch must already belong to the intended owner, who independently
verifies actual Actions state after waking.

Run `node --test scripts/tests/manual/test_manual_ci_comment.mjs` with Node 24
to exercise the actual inline script against mocked GitHub metadata, target
selection, failures and deduplication. The manual shard fixture suite also
verifies the exact approved script and proves that removing its single step
and permission grant restores the prior workflow bytes. These tests add no
automatic PR workload. The proposal's 39 offline checks and the local adaptation
checks do not prove live delivery. [Chief issue #40](https://github.com/hemsoft-dev/chief/issues/40)
stays open until a real bot comment and the exact owner-thread wake are verified
on a normal required qualification run. Existing dispatches cannot acquire the
new step, and no dispatch is added solely to test notification.

Rollback removes the final notifier step and its aggregate write grant, keeping
all quality gates, prior evidence and existing watches intact.
55 changes: 55 additions & 0 deletions CodexBarIOS/Services/ProCommerceFixture.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,60 @@
#if DEBUG
import Foundation
#if targetEnvironment(simulator)
import OSLog

/// Observations only: never supplies readiness or changes a StoreKit operation.
@MainActor
enum ProNativeReadinessTrace {
enum Stage: String {
case snapshotBegin, snapshotEnd, appTransactionBegin, appTransactionReturn, appTransactionError
case entitlementsBegin, entitlementsEnd, latestBegin, latestReturn
case snapshotProductsBegin, snapshotProductsReturn, statusBegin, statusReturn, snapshotError
case catalogBegin, catalogEnd, catalogError, storefrontBegin, storefrontReturn
case productsBegin, productsReturn, modelsBegin, modelsEnd, finalStorefrontBegin, finalStorefrontReturn
case viewRefreshBegin, viewRefreshEnd, viewReadiness
}

private static let logger = Logger(subsystem: "com.hemsoft.CodexBarIOS.native-readiness", category: "fixture")
private static let launch = UUID().uuidString
private static var sequence = 0
private static var previousReadiness: String?

static func runID(environment: [String: String]) -> UUID? {
guard environment["CODEXBAR_UI_TESTS"] == "1",
environment["CODEXBAR_UI_TEST_SCENARIO"] == "commerce-native-storekit",
let raw = environment["CODEXBAR_UI_TEST_RUN_ID"] else { return nil }
return UUID(uuidString: raw)
}

static func emit(_ stage: Stage, count: Int = 0, operation: UUID? = nil) {
guard let run = runID(environment: ProcessInfo.processInfo.environment) else { return }
let wallBefore = Date().timeIntervalSince1970
let uptime = ProcessInfo.processInfo.systemUptime
let wallAfter = Date().timeIntervalSince1970
sequence += 1
let record: [String: Any] = [
"schema": 1, "stage": stage.rawValue, "run": run.uuidString, "launch": launch,
"sequence": sequence, "pid": ProcessInfo.processInfo.processIdentifier, "count": count,
"operation": operation?.uuidString ?? "none",
"wallBefore": wallBefore, "uptime": uptime, "wallAfter": wallAfter,
]
guard let data = try? JSONSerialization.data(withJSONObject: record, options: [.sortedKeys]),
let message = String(data: data, encoding: .utf8) else { return }
logger.notice("CB517NATIVE \(message, privacy: .public)")
}

static func readiness(_ ready: Bool, resolved: Bool, refreshing: Bool, catalog: Int) {
guard runID(environment: ProcessInfo.processInfo.environment) != nil else { return }
// Non-observed storage cannot invalidate SwiftUI or schedule more refreshes.
let state = "\(ready)-\(resolved)-\(refreshing)-\(catalog)"
guard previousReadiness != state else { return }
previousReadiness = state
// Fixed bit fields: ready=1, resolved=2, refreshing=4; catalog enum occupies bits 3+.
emit(.viewReadiness, count: (ready ? 1 : 0) | (resolved ? 2 : 0) | (refreshing ? 4 : 0) | (catalog << 3))
}
}
#endif

/// Explicit simulator fixtures share the production reducer but never its stores or listeners.
@MainActor
Expand Down
32 changes: 31 additions & 1 deletion CodexBarIOS/Services/StoreKitProCatalog.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,52 @@ final class StoreKitProCatalog {
private var generation = 0

func load() async throws -> ProCatalog {
#if DEBUG && targetEnvironment(simulator)
let traceOperation = UUID()
#endif
generation += 1
let request = generation
guard let storefront = await Storefront.current else { throw ProCommerceError.catalogUnavailable }
#if DEBUG && targetEnvironment(simulator)
ProNativeReadinessTrace.emit(.storefrontBegin, operation: traceOperation)
#endif
let initialStorefront = await Storefront.current
#if DEBUG && targetEnvironment(simulator)
ProNativeReadinessTrace.emit(.storefrontReturn, operation: traceOperation)
#endif
guard let storefront = initialStorefront else { throw ProCommerceError.catalogUnavailable }
#if DEBUG && targetEnvironment(simulator)
ProNativeReadinessTrace.emit(.productsBegin, operation: traceOperation)
#endif
let fetched = try await Product.products(for: ProProductID.allCases.map(\.rawValue))
#if DEBUG && targetEnvironment(simulator)
ProNativeReadinessTrace.emit(.productsReturn, count: fetched.count, operation: traceOperation)
#endif

// Some runtimes report a failed lookup by returning no products instead of throwing.
guard !fetched.isEmpty else { throw ProCommerceError.catalogUnavailable }
var models: [ProProductID: ProCatalogProduct] = [:]
var retained: [ProProductID: Product] = [:]
#if DEBUG && targetEnvironment(simulator)
ProNativeReadinessTrace.emit(.modelsBegin, operation: traceOperation)
#endif
for product in fetched {
guard let identifier = ProProductID(rawValue: product.id) else { continue }
let model = await Self.model(product, identifier: identifier, storefront: storefront.id)
guard let model, model.hasExpectedShape else { continue }
models[identifier] = model
retained[identifier] = product
}
#if DEBUG && targetEnvironment(simulator)
ProNativeReadinessTrace.emit(.modelsEnd, operation: traceOperation)
#endif
#if DEBUG && targetEnvironment(simulator)
ProNativeReadinessTrace.emit(.finalStorefrontBegin, operation: traceOperation)
#endif
let currentStorefront = await Storefront.current
#if DEBUG && targetEnvironment(simulator)
ProNativeReadinessTrace.emit(.finalStorefrontReturn, operation: traceOperation)
#endif

try Task.checkCancellation()
guard request == generation, currentStorefront?.id == storefront.id else { throw ProCommerceError.termsChanged }
let catalog = ProCatalog(products: models, storefrontID: storefront.id)
Expand Down
79 changes: 77 additions & 2 deletions CodexBarIOS/Services/StoreKitProStoreSource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,82 @@ final class StoreKitProStoreSource: ProStoreSource {
private var restoredClockEvidence: ProFreshClockEvidence?
private let commerceCatalog = StoreKitProCatalog()

func catalog() async throws -> ProCatalog { try await commerceCatalog.load() }
func catalog() async throws -> ProCatalog {
#if DEBUG && targetEnvironment(simulator)
let traceOperation = UUID()
#endif
#if DEBUG && targetEnvironment(simulator)
ProNativeReadinessTrace.emit(.catalogBegin, operation: traceOperation)
#endif
do {
let catalog = try await commerceCatalog.load()
#if DEBUG && targetEnvironment(simulator)
ProNativeReadinessTrace.emit(.catalogEnd, count: catalog.products.count, operation: traceOperation)
#endif
return catalog
} catch {
#if DEBUG && targetEnvironment(simulator)
ProNativeReadinessTrace.emit(.catalogError, operation: traceOperation)
#endif
throw error
}
}

func storefrontUpdates() -> AsyncStream<String> { commerceCatalog.storefrontUpdates() }

func snapshot() async -> ProStoreSnapshot {
#if DEBUG && targetEnvironment(simulator)
let traceOperation = UUID()
#endif

#if DEBUG && targetEnvironment(simulator)
ProNativeReadinessTrace.emit(.snapshotBegin, operation: traceOperation)
#endif
defer {
#if DEBUG && targetEnvironment(simulator)
ProNativeReadinessTrace.emit(.snapshotEnd, operation: traceOperation)
#endif
}
var snapshot = ProStoreSnapshot()
await appendAppPurchase(to: &snapshot)
snapshot.freshClockEvidence = restoredClockEvidence
#if DEBUG && targetEnvironment(simulator)
ProNativeReadinessTrace.emit(.entitlementsBegin, operation: traceOperation)
#endif
for await result in Transaction.currentEntitlements { append(result, to: &snapshot) }
#if DEBUG && targetEnvironment(simulator)
ProNativeReadinessTrace.emit(.entitlementsEnd, operation: traceOperation)
#endif

// Latest signed records also expose revocations omitted by currentEntitlements, including after relaunch.
for product in ProProductID.allCases {
#if DEBUG && targetEnvironment(simulator)
ProNativeReadinessTrace.emit(.latestBegin, operation: traceOperation)
#endif
if let result = await Transaction.latest(for: product.rawValue) { append(result, to: &snapshot) }
#if DEBUG && targetEnvironment(simulator)
ProNativeReadinessTrace.emit(.latestReturn, operation: traceOperation)
#endif

}
do {
#if DEBUG && targetEnvironment(simulator)
ProNativeReadinessTrace.emit(.snapshotProductsBegin, operation: traceOperation)
#endif
let products = try await Product.products(for: ProProductID.allCases.map(\.rawValue))
#if DEBUG && targetEnvironment(simulator)
ProNativeReadinessTrace.emit(.snapshotProductsReturn, count: products.count, operation: traceOperation)
#endif

if let subscription = products.first(where: { $0.subscription != nil })?.subscription {
#if DEBUG && targetEnvironment(simulator)
ProNativeReadinessTrace.emit(.statusBegin, operation: traceOperation)
#endif
let statuses = try await subscription.status
#if DEBUG && targetEnvironment(simulator)
ProNativeReadinessTrace.emit(.statusReturn, count: statuses.count, operation: traceOperation)
#endif

snapshot.subscriptionStatusProducts = []
for status in statuses {
if let evidence = Self.subscription(status) {
Expand All @@ -39,6 +98,9 @@ final class StoreKitProStoreSource: ProStoreSource {
snapshot.subscriptionStatusAvailable = true
}
} catch {
#if DEBUG && targetEnvironment(simulator)
ProNativeReadinessTrace.emit(.snapshotError, operation: traceOperation)
#endif
// StoreKit can be unavailable offline. Only a previous verified decision may supply the allowance.
}
return snapshot
Expand Down Expand Up @@ -132,8 +194,18 @@ final class StoreKitProStoreSource: ProStoreSource {
}

private func appendAppPurchase(to snapshot: inout ProStoreSnapshot) async {
#if DEBUG && targetEnvironment(simulator)
let traceOperation = UUID()
#endif
do {
switch try await AppTransaction.shared {
#if DEBUG && targetEnvironment(simulator)
ProNativeReadinessTrace.emit(.appTransactionBegin, operation: traceOperation)
#endif
let result = try await AppTransaction.shared
#if DEBUG && targetEnvironment(simulator)
ProNativeReadinessTrace.emit(.appTransactionReturn, operation: traceOperation)
#endif
switch result {
case let .verified(transaction):
guard let environment = Self.environment(transaction.environment) else { return }
snapshot.appendVerifiedAppPurchase(
Expand All @@ -148,6 +220,9 @@ final class StoreKitProStoreSource: ProStoreSource {
snapshot.appVerificationEnvironment = Self.environment(transaction.environment)
}
} catch {
#if DEBUG && targetEnvironment(simulator)
ProNativeReadinessTrace.emit(.appTransactionError, operation: traceOperation)
#endif
// An unavailable AppTransaction does not replace a previously verified permanent grant.
}
}
Expand Down
4 changes: 2 additions & 2 deletions CodexBarIOS/Services/UITestFixtures.swift
Original file line number Diff line number Diff line change
Expand Up @@ -807,8 +807,8 @@ final class UITestFixtures {
let end = ISO8601DateFormatter().string(from: now.addingTimeInterval(5 * 86_400))
let noAllowance = scenario == "grok-no-allowance"
let percentField = switch scenario {
case "grok-zero", "grok-existing-zero", "grok-spending-zero": ""
case "grok-spending-reported-zero": "\"creditUsagePercent\":0,"
case "grok-spending-zero": ""
case "grok-zero", "grok-existing-zero", "grok-spending-reported-zero": "\"creditUsagePercent\":0,"
case "grok-no-allowance", "grok-percent-unavailable", "grok-existing-percent-unavailable",
"grok-spending-unavailable":
"\"creditUsagePercent\":null,"
Expand Down
30 changes: 28 additions & 2 deletions CodexBarIOS/Views/ProPresentationView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ struct ProPresentationView: View {
}
}
.accessibilityIdentifier("codexbar-pro-presentation")
.accessibilityValue(isReady ? "ready" : "loading")
.accessibilityValue(readinessValue)
.interactiveDismissDisabled(entitlement.command != nil)
.background {
ProSubscriptionSheetPresenter(isPresented: $showsManagement, groupID: managementGroupID)
Expand Down Expand Up @@ -253,6 +253,21 @@ struct ProPresentationView: View {

private var isBusy: Bool { entitlement.command != nil || isRefreshing || !entitlement.isResolved }

private var readinessValue: String {
let ready = isReady
#if DEBUG && targetEnvironment(simulator)
let catalog: Int
switch entitlement.catalog {
case .idle: catalog = 0
case .loading: catalog = 1
case .loaded: catalog = 2
case .failed: catalog = 3
}
ProNativeReadinessTrace.readiness(ready, resolved: entitlement.isResolved, refreshing: isRefreshing, catalog: catalog)
#endif
return ready ? "ready" : "loading"
}

private var isReady: Bool {
guard entitlement.isResolved, !isRefreshing else { return false }
switch entitlement.catalog {
Expand Down Expand Up @@ -353,9 +368,20 @@ struct ProPresentationView: View {
}

private func refreshCommerce() async {
#if DEBUG && targetEnvironment(simulator)
let traceOperation = UUID()
#endif
guard !isRefreshing, entitlement.command == nil else { return }
isRefreshing = true
defer { isRefreshing = false }
#if DEBUG && targetEnvironment(simulator)
ProNativeReadinessTrace.emit(.viewRefreshBegin, operation: traceOperation)
#endif
defer {
isRefreshing = false
#if DEBUG && targetEnvironment(simulator)
ProNativeReadinessTrace.emit(.viewRefreshEnd, operation: traceOperation)
#endif
}
await entitlement.refreshCommerce()
}

Expand Down
Loading
Loading