improve(ci): shard iOS screenshot capture (#128460)

* perf(ci): shard iOS screenshot capture

* fix(ci): bind iOS screenshot shard evidence

* fix(ci): preserve screenshot capture attempts

* fix(ci): align screenshot shards with Xcode 26.6

* fix(ci): provision screenshot shard Fastlane

* fix(ci): normalize Fastlane provenance version

* fix(ci): select installed Fastlane version
This commit is contained in:
Vincent Koc
2026-08-24 16:59:06 -07:00
committed by GitHub
parent 4c8c22867e
commit e27433c22e
10 changed files with 1803 additions and 67 deletions
+206 -24
View File
@@ -69,6 +69,9 @@ concurrency:
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
IOS_SCREENSHOT_FASTLANE_VERSION: "2.236.1"
IOS_SCREENSHOT_NODE_VERSION: "24.16.0"
IOS_SCREENSHOT_XCODE_VERSION: "Xcode 26.6 Build version 17F113"
NODE_VERSION: "24.x"
jobs:
@@ -3833,29 +3836,10 @@ jobs:
runs-on: ${{ vars.OPENCLAW_CI_RUNNER_BACKEND == 'github' && 'macos-26' || (github.event_name == 'workflow_dispatch' || github.run_attempt > 1) && 'macos-26' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR","CONTRIBUTOR"]'), github.event.pull_request.author_association)) && 'blacksmith-12vcpu-macos-26' || 'macos-26') }}
timeout-minutes: 150
env:
BUNDLE_DEPLOYMENT: "true"
BUNDLE_GEMFILE: ${{ github.workspace }}/apps/ios/Gemfile
HISTORICAL_TARGET: ${{ needs.preflight.outputs.compatibility_target }}
steps:
- *platform_checkout_step
- name: Setup Ruby
if: env.HISTORICAL_TARGET != 'true'
uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1
with:
ruby-version: "3.4.10"
bundler: "2.6.9"
bundler-cache: false
working-directory: apps/ios
- name: Install locked Fastlane bundle
if: env.HISTORICAL_TARGET != 'true'
working-directory: apps/ios
run: |
bundle _2.6.9_ install --jobs 4 --retry 3
bundle _2.6.9_ check
bundle _2.6.9_ exec fastlane --version
- name: Select Xcode 26
run: |
set -euo pipefail
@@ -4008,20 +3992,214 @@ jobs:
if-no-files-found: warn
retention-days: 14
- name: Capture iOS release screenshots
# Full manual/release validation always captures. PRs and their exact-head
# release-gate substitutes use the same conservative screenshot-risk scope.
if: ${{ ((github.event_name == 'workflow_dispatch' && (!inputs.release_gate || needs.preflight.outputs.run_ios_screenshots == 'true')) || (github.event_name == 'pull_request' && needs.preflight.outputs.run_ios_screenshots == 'true')) && env.HISTORICAL_TARGET != 'true' }}
ios-screenshot-shard:
permissions:
contents: read
name: "ios-screenshots-${{ matrix.device_family }}"
needs: [preflight, ios-build]
# Full manual/release validation always captures. PRs and their exact-head
# release-gate substitutes use the same conservative screenshot-risk scope.
if: ${{ ((github.event_name == 'workflow_dispatch' && (!inputs.release_gate || needs.preflight.outputs.run_ios_screenshots == 'true')) || (github.event_name == 'pull_request' && needs.preflight.outputs.run_ios_screenshots == 'true')) && needs.preflight.outputs.compatibility_target != 'true' }}
runs-on: ${{ vars.OPENCLAW_CI_RUNNER_BACKEND == 'github' && 'macos-26' || (github.event_name == 'workflow_dispatch' || github.run_attempt > 1) && 'macos-26' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR","CONTRIBUTOR"]'), github.event.pull_request.author_association)) && 'blacksmith-12vcpu-macos-26' || 'macos-26') }}
timeout-minutes: 90
env:
BUNDLE_DEPLOYMENT: "true"
BUNDLE_GEMFILE: ${{ github.workspace }}/apps/ios/Gemfile
strategy:
fail-fast: false
max-parallel: 2
matrix:
device_family: [iphone, ipad-13]
steps:
- *platform_checkout_step
- name: Setup Ruby
uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1
with:
ruby-version: "3.4.10"
bundler: "2.6.9"
bundler-cache: false
working-directory: apps/ios
- name: Install locked Fastlane bundle
working-directory: apps/ios
run: |
bundle _2.6.9_ install --jobs 4 --retry 3
bundle _2.6.9_ check
bundle _2.6.9_ exec fastlane --version
- name: Select Xcode 26
run: |
set -euo pipefail
for xcode_app in /Applications/Xcode_26.6.app /Applications/Xcode-26.6.0.app; do
if [ -d "$xcode_app/Contents/Developer" ]; then
sudo xcode-select -s "$xcode_app/Contents/Developer"
break
fi
done
xcodebuild -version
xcode_version="$(xcodebuild -version | awk 'NR == 1 { print $2 }')"
if [[ "$xcode_version" != 26.6* ]]; then
echo "error: expected Xcode 26.6, got $xcode_version" >&2
exit 1
fi
swift --version
- name: Setup Node environment
uses: ./.ci-harness/.github/actions/setup-node-env
with:
cache-mode: ${{ needs.preflight.outputs.cache_mode }}
install-bun: "false"
node-version: ${{ env.IOS_SCREENSHOT_NODE_VERSION }}
- name: Install iOS screenshot tooling
run: |
swift_tools_dir="$RUNNER_TEMP/openclaw-swift-tools"
./scripts/install-xcodegen.sh "$swift_tools_dir"
./scripts/install-swift-tools.sh "$swift_tools_dir"
echo "$swift_tools_dir" >> "$GITHUB_PATH"
"$swift_tools_dir/xcodegen" --version
"$swift_tools_dir/swiftformat" --version
"$swift_tools_dir/swiftlint" version
- name: Capture iOS device screenshot shard
id: device_screenshots
env:
OPENCLAW_SNAPSHOT_DEVICE_FAMILY: ${{ matrix.device_family }}
OPENCLAW_SNAPSHOT_SKIP_WATCH: "1"
run: pnpm ios:screenshots
- name: Capture Apple Watch screenshot
id: watch_screenshot
if: matrix.device_family == 'iphone'
run: |
source scripts/lib/ios-fastlane.sh
(
cd apps/ios
run_ios_fastlane ios watch_screenshot
)
- name: Package iOS screenshot shard evidence
id: package_screenshot_evidence
env:
DEVICE_FAMILY: ${{ matrix.device_family }}
RUN_ATTEMPT: ${{ github.run_attempt }}
RUN_ID: ${{ github.run_id }}
TARGET_SHA: ${{ needs.preflight.outputs.checkout_revision }}
WORKFLOW_SHA: ${{ github.workflow_sha }}
run: |
set -euo pipefail
fastlane_version="$(
source scripts/lib/ios-fastlane.sh
run_ios_fastlane --version 2>&1 | awk 'match($0, /^fastlane [0-9]+\.[0-9]+\.[0-9]+$/) { print substr($0, RSTART + 9, RLENGTH - 9); exit }'
)"
xcode_version="$(xcodebuild -version | paste -sd ' ' -)"
test "$xcode_version" = "$IOS_SCREENSHOT_XCODE_VERSION"
collect_args=(
collect
--family "$DEVICE_FAMILY"
--screenshots apps/ios/fastlane/screenshots/en-US
--xcresults apps/ios/build/SnapshotTestResults
--output apps/ios/build/ScreenshotEvidenceShard
--target-sha "$TARGET_SHA"
--workflow-sha "$WORKFLOW_SHA"
--run-id "$RUN_ID"
--run-attempt "$RUN_ATTEMPT"
--xcode-version "$xcode_version"
--fastlane-version "$fastlane_version"
--node-version "$(node --version)"
)
node scripts/ios-screenshot-evidence.mjs "${collect_args[@]}"
if [[ "$DEVICE_FAMILY" == "iphone" ]]; then
collect_args[2]="watch"
node scripts/ios-screenshot-evidence.mjs "${collect_args[@]}"
fi
- name: Upload iOS screenshot shard evidence
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: ios-release-screenshot-shard-${{ matrix.device_family }}-${{ needs.preflight.outputs.checkout_revision }}
path: apps/ios/build/ScreenshotEvidenceShard/
if-no-files-found: error
retention-days: 14
- name: Upload failed iOS screenshot attempt evidence
if: ${{ always() && (steps.device_screenshots.outcome == 'failure' || steps.watch_screenshot.outcome == 'failure' || steps.package_screenshot_evidence.outcome == 'failure') }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: ios-release-screenshot-failure-${{ matrix.device_family }}-${{ needs.preflight.outputs.checkout_revision }}
path: |
apps/ios/fastlane/screenshots/en-US/*.png
apps/ios/build/SnapshotTestResults/capture-attempts.json
apps/ios/build/SnapshotTestResults/*.xcresult
if-no-files-found: warn
retention-days: 14
ios-screenshot-evidence:
permissions:
contents: read
name: "ios-screenshot-evidence"
needs: [preflight, ios-screenshot-shard]
if: ${{ ((github.event_name == 'workflow_dispatch' && (!inputs.release_gate || needs.preflight.outputs.run_ios_screenshots == 'true')) || (github.event_name == 'pull_request' && needs.preflight.outputs.run_ios_screenshots == 'true')) && needs.preflight.outputs.compatibility_target != 'true' }}
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- *linux_node_checkout_step
- name: Setup screenshot evidence Node
uses: ./.ci-harness/.github/actions/setup-node-env
with:
cache-mode: ${{ needs.preflight.outputs.cache_mode }}
install-bun: "false"
install-deps: "false"
node-version: ${{ env.IOS_SCREENSHOT_NODE_VERSION }}
- name: Download iOS screenshot shard evidence
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
pattern: ios-release-screenshot-shard-*-${{ needs.preflight.outputs.checkout_revision }}
path: apps/ios/build/ScreenshotEvidenceInputs
merge-multiple: false
- name: Reduce iOS screenshot evidence
id: reduce_screenshot_evidence
env:
RUN_ATTEMPT: ${{ github.run_attempt }}
RUN_ID: ${{ github.run_id }}
TARGET_SHA: ${{ needs.preflight.outputs.checkout_revision }}
WORKFLOW_SHA: ${{ github.workflow_sha }}
run: |
node scripts/ios-screenshot-evidence.mjs reduce \
--input apps/ios/build/ScreenshotEvidenceInputs \
--output . \
--target-sha "$TARGET_SHA" \
--workflow-sha "$WORKFLOW_SHA" \
--run-id "$RUN_ID" \
--run-attempt "$RUN_ATTEMPT" \
--xcode-version "$IOS_SCREENSHOT_XCODE_VERSION" \
--fastlane-version "$IOS_SCREENSHOT_FASTLANE_VERSION" \
--node-version "$(node --version)"
- name: Upload failed iOS screenshot reducer evidence
if: ${{ always() && steps.reduce_screenshot_evidence.outcome == 'failure' }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: ios-release-screenshot-reducer-failure-${{ needs.preflight.outputs.checkout_revision }}
path: |
apps/ios/build/ScreenshotEvidenceInputs/**/screenshots/*.png
apps/ios/build/ScreenshotEvidenceInputs/**/xcresults/*.xcresult
apps/ios/build/ScreenshotEvidenceInputs/**/manifest.json
if-no-files-found: warn
retention-days: 14
- name: Upload iOS release screenshot evidence
if: ${{ always() && ((github.event_name == 'workflow_dispatch' && (!inputs.release_gate || needs.preflight.outputs.run_ios_screenshots == 'true')) || (github.event_name == 'pull_request' && needs.preflight.outputs.run_ios_screenshots == 'true')) && env.HISTORICAL_TARGET != 'true' }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: ios-release-screenshots-${{ needs.preflight.outputs.checkout_revision }}
path: |
apps/ios/fastlane/screenshots/en-US/*.png
apps/ios/build/SnapshotTestResults/*.xcresult
apps/ios/build/ScreenshotEvidence/manifest.json
if-no-files-found: error
retention-days: 14
@@ -4296,6 +4474,8 @@ jobs:
- macos-node
- macos-swift
- ios-build
- ios-screenshot-shard
- ios-screenshot-evidence
- android
- docker-seed-e2e
if: ${{ always() && (github.event_name != 'pull_request' || !github.event.pull_request.draft) }}
@@ -4333,6 +4513,8 @@ jobs:
macos-node=${{ needs.macos-node.result }}
macos-swift=${{ needs.macos-swift.result }}
ios-build=${{ needs.ios-build.result }}
ios-screenshot-shard=${{ needs.ios-screenshot-shard.result }}
ios-screenshot-evidence=${{ needs.ios-screenshot-evidence.result }}
android=${{ needs.android.result }}
docker-seed-e2e=${{ needs.docker-seed-e2e.result }}
run: |
+77 -7
View File
@@ -15,6 +15,7 @@ APP_STORE_APP_IDENTIFIER = "ai.openclawfoundation.app"
DEFAULT_APP_STORE_CONNECT_KEYCHAIN_SERVICE = "openclaw-app-store-connect-key"
DEFAULT_SNAPSHOT_DEVICE_FAMILIES = [
{
key: "iphone",
label: "iPhone",
patterns: [
/\AiPhone .* Pro Max\z/,
@@ -23,6 +24,7 @@ DEFAULT_SNAPSHOT_DEVICE_FAMILIES = [
]
},
{
key: "ipad-13",
label: "13-inch iPad",
patterns: [
/\AiPad Pro 13-inch/,
@@ -170,8 +172,16 @@ end
def default_snapshot_devices
names = available_simulator_devices.map { |device| device["name"].to_s }.reject(&:empty?).uniq
requested_family = ENV["OPENCLAW_SNAPSHOT_DEVICE_FAMILY"].to_s.strip
families = DEFAULT_SNAPSHOT_DEVICE_FAMILIES
unless requested_family.empty?
family = families.find { |candidate| candidate.fetch(:key) == requested_family }
supported = families.map { |candidate| candidate.fetch(:key) }.join(", ")
UI.user_error!("Unsupported screenshot device family '#{requested_family}'. Expected one of: #{supported}.") if family.nil?
families = [family]
end
DEFAULT_SNAPSHOT_DEVICE_FAMILIES.map do |family|
families.map do |family|
match = family.fetch(:patterns).filter_map do |pattern|
names.find { |name| name.match?(pattern) }
end.first
@@ -1617,6 +1627,37 @@ def archive_snapshot_test_result!(result_bundle_path:, archive_directory:, devic
FileUtils.mv(result_bundle_path, archive_path)
end
def write_release_ios_screenshot_attempts!(attempts:, output_path:)
temporary_path = "#{output_path}.tmp"
File.write(
temporary_path,
"#{JSON.pretty_generate({ "schemaVersion" => 1, "attempts" => attempts })}\n"
)
FileUtils.mv(temporary_path, output_path)
ensure
FileUtils.rm_f(temporary_path) if defined?(temporary_path)
end
def record_release_ios_screenshot_attempt!(
attempts:,
output_path:,
device:,
screenshot_name:,
attempt:,
capture_outcome:
)
attempts << {
"deviceName" => device,
"screenshotName" => screenshot_name,
"attempt" => attempt,
"captureOutcome" => capture_outcome
}
write_release_ios_screenshot_attempts!(
attempts: attempts,
output_path: output_path
)
end
def capture_release_ios_screenshot!(
project:,
device:,
@@ -1624,6 +1665,8 @@ def capture_release_ios_screenshot!(
output_directory:,
result_bundle_path:,
result_bundle_archive_directory:,
capture_attempts:,
capture_attempts_path:,
derived_data_path:,
clear_previous_screenshots:
)
@@ -1635,7 +1678,8 @@ def capture_release_ios_screenshot!(
FileUtils.rm_f(expected_screenshot_path)
FileUtils.rm_rf(result_bundle_path)
begin
# Keep retry ownership outside Snapshot. Every action call creates a new
# OpenClaw owns retries of the whole capture action; Fastlane may still
# retry simulator launch internally. Each action call creates a new
# xcodebuild/TestManager session and reboots the selected simulator.
capture_ios_screenshots(
project: project,
@@ -1668,6 +1712,14 @@ def capture_release_ios_screenshot!(
screenshot_name: screenshot_name,
attempt: attempt
)
record_release_ios_screenshot_attempt!(
attempts: capture_attempts,
output_path: capture_attempts_path,
device: device,
screenshot_name: screenshot_name,
attempt: attempt,
capture_outcome: "failed"
)
raise if attempt == 2
UI.important(
@@ -1684,6 +1736,14 @@ def capture_release_ios_screenshot!(
screenshot_name: screenshot_name,
attempt: attempt
)
record_release_ios_screenshot_attempt!(
attempts: capture_attempts,
output_path: capture_attempts_path,
device: device,
screenshot_name: screenshot_name,
attempt: attempt,
capture_outcome: "succeeded"
)
return
end
end
@@ -2112,9 +2172,15 @@ platform :ios do
output_directory = File.join(ios_root, "fastlane", "screenshots")
result_bundle_path = File.join(output_directory, "test_output", "en-US", "OpenClawUITests.xcresult")
result_bundle_archive_directory = File.join(ios_root, "build", "SnapshotTestResults")
capture_attempts_path = File.join(result_bundle_archive_directory, "capture-attempts.json")
derived_data_path = File.join(ios_root, "build", "SnapshotDerivedData")
FileUtils.rm_rf(result_bundle_archive_directory)
FileUtils.mkdir_p(result_bundle_archive_directory)
capture_attempts = []
write_release_ios_screenshot_attempts!(
attempts: capture_attempts,
output_path: capture_attempts_path
)
FileUtils.rm_rf(derived_data_path)
devices = snapshot_devices
project = File.join(ios_root, "OpenClaw.xcodeproj")
@@ -2140,6 +2206,8 @@ platform :ios do
output_directory: output_directory,
result_bundle_path: result_bundle_path,
result_bundle_archive_directory: result_bundle_archive_directory,
capture_attempts: capture_attempts,
capture_attempts_path: capture_attempts_path,
derived_data_path: derived_data_path,
clear_previous_screenshots: capture_index.zero?
)
@@ -2155,11 +2223,13 @@ platform :ios do
# evidence in build/ so Deliver sees only locale directories during validation.
FileUtils.rm_rf(File.join(output_directory, "test_output"))
watch_screenshot(
release_version: release_version,
app_store_revision: app_store_revision,
build_number: build_number
)
unless ENV["OPENCLAW_SNAPSHOT_SKIP_WATCH"] == "1"
watch_screenshot(
release_version: release_version,
app_store_revision: app_store_revision,
build_number: build_number
)
end
end
desc "Generate deterministic Apple Watch screenshot for App Store metadata"
+8 -6
View File
@@ -52,7 +52,9 @@ dispatch.
| `checks-windows` | Windows-specific process/path tests plus shared runtime import specifier regressions | Windows-relevant changes |
| `macos-node` | Focused macOS TypeScript tests: launchd, Homebrew, runtime paths, packaging scripts, process-group wrapper | macOS-relevant changes |
| `macos-swift` | Swift lint and build for the macOS app, plus tests for the app and shared OpenClawKit package | macOS-relevant changes |
| `ios-build` | Swift lint, Debug and Release builds, focused simulator lifecycle tests, and the full release screenshot matrix when screenshot-pipeline owners changed | iOS/capture changes |
| `ios-build` | Swift lint, Debug and Release builds, and focused simulator lifecycle tests | iOS/capture changes |
| `ios-screenshot-shard` | Two device-family shards using the locked Ruby/Fastlane bundle: iPhone plus Watch in one job, and 13-inch iPad in the other; scenarios stay serial within each device | Screenshot-risk changes and manual CI |
| `ios-screenshot-evidence` | Hosted reducer that verifies exact artifact/family topology, digests, every OpenClaw-managed capture-attempt outcome (including failed invocations without an xcresult), and run provenance before publishing the canonical release screenshot artifact | After both screenshot shards |
| `android` | Android unit tests for both flavors plus one debug APK build | Android-relevant changes |
| `openclaw/ci-gate` | Final aggregate: requires preflight and security; accepts skips only for manifest-disabled downstream lanes | Every non-draft CI run |
| `openclaw-performance` | Separate workflow: daily/on-demand Kova runtime performance reports with mock-provider, deep-profile, and GPT 5.6 live lanes | Scheduled and manual dispatch |
@@ -72,7 +74,7 @@ Standalone Periphery workflows enforce zero dead-code findings for the iOS and m
1. `preflight` decides which lanes exist at all. The `docs-scope` and `changed-scope` logic are steps inside this job, not standalone jobs. Canonical `main` starts immediately in one of two parity slots; each slot admits one complete run and coalesces later pushes into its newest pending tip. On Node-relevant canonical `main` pushes and same-repository pull requests, preflight is the sole exact dependency-cache writer; downstream jobs wait for it, then restore the immutable archive or fall back to the ordinary pnpm-store cache on a miss.
2. `security-fast`, `check-*`, `check-additional-*`, `check-docs`, and `skills-python` fail quickly without waiting on the heavier artifact and platform matrix jobs.
3. `build-artifacts` and the locale checks overlap with the fast Linux lanes. Control UI and native app source PRs exclude generated locale snapshots/resources; their serialized refresh workflows repair and auto-merge isolated generated PRs in the background. Source CI still blocks stale source inventories and unsafe localization calls. Generated PRs, manual CI, and release prep enforce full translated/platform-generated parity. Canonical `release/YYYY.M.PATCH` branches may include release-prep locale repairs with the other generated release output.
4. Heavier platform and runtime lanes fan out after that: `checks-fast-core`, `checks-fast-contracts-plugins-*`, `checks-fast-contracts-channels-*`, `checks-node-*`, `checks-windows`, `macos-node`, `macos-swift`, `ios-build`, and `android`.
4. Heavier platform and runtime lanes fan out after that: `checks-fast-core`, `checks-fast-contracts-plugins-*`, `checks-fast-contracts-channels-*`, `checks-node-*`, `checks-windows`, `macos-node`, `macos-swift`, `ios-build`, the screenshot shards, and `android`.
5. `openclaw/ci-gate` waits for every selected lane. Preflight and security must succeed; downstream jobs may skip only when the manifest did not select them. A failed or canceled selected lane fails the aggregate.
The merge coordinator may reuse an authenticated successful `openclaw/ci-gate`
@@ -110,7 +112,7 @@ When the check fails, update the PR body instead of pushing another code commit.
Scope logic lives in `scripts/ci-changed-scope.mjs` and is covered by unit tests in `src/scripts/ci-changed-scope.test.ts`. Ordinary manual dispatch skips changed-scope detection and makes the preflight manifest act as if every scoped area changed. The exact-head `release_gate` exception evaluates the fetched pull request merge tree and retains its macOS, iOS-build, and screenshot-risk decisions.
Release screenshot routing is deliberately conservative because an app change can break deterministic App Store capture without breaking compilation. Pull requests and exact-head release gates run the full iPhone, iPad, and Watch matrix when the diff touches `apps/ios/**`, linked OpenClawKit or Swabble code, Apple Swift configuration, or the scripts used by screenshot capture. Ordinary manual CI and Full Release Validation always run that matrix. The screenshot decision is independent of macOS routing; a pure iOS app change does not select macOS jobs by itself.
Release screenshot routing is deliberately conservative because an app change can break deterministic App Store capture without breaking compilation. Pull requests and exact-head release gates run the full iPhone, iPad, and Watch matrix when the diff touches `apps/ios/**`, linked OpenClawKit or Swabble code, Apple Swift configuration, or the scripts used by screenshot capture. CI runs at most two device shards in parallel, keeps scenarios serial within each device, and captures Watch evidence in the iPhone shard. A hosted reducer verifies the exact evidence union before publishing the sole canonical artifact consumed by `openclaw/ci-gate`. Ordinary manual CI and Full Release Validation always run that matrix. The screenshot decision is independent of macOS routing; a pure iOS app change does not select macOS jobs by itself.
Separate iOS and macOS Periphery workflows enforce a zero-findings dead-code policy. Each runs only when a non-draft pull request touches its native scan scope, or when manually dispatched.
@@ -215,7 +217,7 @@ Runner choice follows contributor trust, not whether a pull request came from a
| `blacksmith-32vcpu-ubuntu-2404` | `build-artifacts`, `check-lint`, `check-dependencies`, and `check-additional-extension-package-boundary`; these lanes are CPU-sensitive enough that smaller runners extend the critical path |
| `blacksmith-8vcpu-windows-2025` | `checks-windows` |
| `blacksmith-6vcpu-macos-15` | `macos-node` on `openclaw/openclaw`; untrusted authors fall back to `macos-15` |
| `blacksmith-12vcpu-macos-26` | `macos-swift` and `ios-build` on `openclaw/openclaw`; untrusted authors fall back to `macos-26` |
| `blacksmith-12vcpu-macos-26` | `macos-swift`, `ios-build`, and the two `ios-screenshot-shard` rows on `openclaw/openclaw`; untrusted authors fall back to `macos-26` |
### Runner backend modes
@@ -229,7 +231,7 @@ The repository variable `OPENCLAW_CI_RUNNER_BACKEND` controls the runner backend
| `github` | GitHub-hosted | GitHub-hosted | Every configurable job remains hosted |
| `hybrid` | Selected critical-path plateaus use Blacksmith on attempt 1; remaining light lanes stay hosted | Blacksmith on attempt 1; GitHub-hosted on `github.run_attempt > 1` | Rerunning a failed or stuck Blacksmith job automatically moves it to hosted capacity |
Heavy lanes are `build-artifacts`, `check-sqlite-session-lifecycle`, `android`, `macos-node`, `macos-swift`, and `ios-build`. Hybrid also sends the compact Node matrix, up to 96 rows, twelve-row hosted-planner `checks-ui-e2e` matrix, the `checks-ui-e2e-real-gateway` lane that shares its serial Chromium workload, six-row QA Smoke matrix, the two-part Windows matrix, `checks-ui`, `check-lint`, `check-test-types`, the five `check-test-types-core-*` stripes, `check-dependencies`, `check-additional-extension-package-boundary`, `check-additional-runtime-topology-architecture`, and `report-plugin-sdk-api-diff` to Blacksmith on attempt 1. Compact-small rows use `blacksmith-4vcpu-ubuntu-2404`, compact-large rows use `blacksmith-8vcpu-ubuntu-2404`, and the planner's measured small queue-tail promotions retain their 8-vCPU labels. Every other configurable `ci.yml` lane stays hosted in hybrid, including preflight, the five core-lint stripes, the remaining lint/check rows, docs, and Python skills. Separate Opengrep workflows remain GitHub-hosted.
Heavy lanes are `build-artifacts`, `check-sqlite-session-lifecycle`, `android`, `macos-node`, `macos-swift`, `ios-build`, and `ios-screenshot-shard`. Hybrid also sends the compact Node matrix, up to 96 rows, twelve-row hosted-planner `checks-ui-e2e` matrix, the `checks-ui-e2e-real-gateway` lane that shares its serial Chromium workload, six-row QA Smoke matrix, the two-part Windows matrix, `checks-ui`, `check-lint`, `check-test-types`, the five `check-test-types-core-*` stripes, `check-dependencies`, `check-additional-extension-package-boundary`, `check-additional-runtime-topology-architecture`, and `report-plugin-sdk-api-diff` to Blacksmith on attempt 1. Compact-small rows use `blacksmith-4vcpu-ubuntu-2404`, compact-large rows use `blacksmith-8vcpu-ubuntu-2404`, and the planner's measured small queue-tail promotions retain their 8-vCPU labels. Every other configurable `ci.yml` lane stays hosted in hybrid, including preflight, the five core-lint stripes, the remaining lint/check rows, docs, and Python skills. Separate Opengrep workflows remain GitHub-hosted.
Hybrid is the normal degraded-capacity mode. If Blacksmith is down: rerun the failed or stuck heavy job; it lands on hosted automatically. During a full Blacksmith outage, `github` remains the repository-wide circuit breaker:
@@ -269,7 +271,7 @@ target below about 60% of the live bucket. With the current 10,000-registration
bucket, that means a 6,000-registration operating target, leaving headroom for
concurrent repositories, retries, and burst overlap.
The changed-target PR plan reduces the common Node test burst from 29 Blacksmith registrations to one. Broad-risk all-Blacksmith PRs keep the 32-registration metadata-complete compact fallback; canonical all-Blacksmith pushes use 24 nondist compact registrations. Hybrid currently uses 53 nondist fallback rows and 46 nondist push rows. The `github` and `hybrid` planner profiles remain capped at 96 compact rows; `github` rows are hosted, while hybrid rows consume Blacksmith registrations on attempt 1 and move to hosted capacity on retries. Even the 96-row cap plus roughly 25 other Blacksmith lanes is about 121 registrations per full run, or 484 for four admitted runs in a five-minute window, far below the 6,000-registration operating target.
The changed-target PR plan reduces the common Node test burst from 29 Blacksmith registrations to one. Broad-risk all-Blacksmith PRs keep the 32-registration metadata-complete compact fallback; canonical all-Blacksmith pushes use 24 nondist compact registrations. Hybrid currently uses 53 nondist fallback rows and 46 nondist push rows. The `github` and `hybrid` planner profiles remain capped at 96 compact rows; `github` rows are hosted, while hybrid rows consume Blacksmith registrations on attempt 1 and move to hosted capacity on retries. Screenshot-risk runs add at most two Blacksmith registrations. Even the 96-row cap plus roughly 27 other Blacksmith lanes is about 123 registrations per full run, or 492 for four admitted runs in a five-minute window, far below the 6,000-registration operating target.
Canonical-repo CI keeps Blacksmith as the default runner path for pushes and first-attempt same-repo pull-request runs when the backend is unset or `blacksmith`. Hybrid keeps the heavy set plus the named critical-path plateau lanes on Blacksmith for attempt 1; other light lanes and every rerun Blacksmith lane use GitHub-hosted capacity. Pull-request retries of both UI E2E jobs use GitHub-hosted Ubuntu in every mode; push retries remain on their normal backend unless hybrid fallback applies. All `workflow_dispatch` runs, including `release_gate`, and non-canonical repository runs use GitHub-hosted runners. The [`github` backend](#runner-backend-modes) provides a manual repository-wide fallback; canonical runs do not probe Blacksmith queue health or mutate the variable automatically.
+2 -2
View File
@@ -50,11 +50,11 @@ const MACOS_SCRIPT_SCOPE_RE =
const WORKSPACE_RSYNC_RECEIVER_SCOPE_RE =
/^src\/(?:shared\/worker-bundle-hash\.ts|worker\/workspace-rsync-receiver\.ts|gateway\/worker-environments\/workspace-(?:accepted-(?:remote-script|sync)|mutation-remote-script|rsync-path\.test|sync(?:-helpers)?)\.ts)$/;
const IOS_BUILD_RE =
/^(apps\/ios\/|apps\/shared\/|apps\/swabble\/|Swabble\/|scripts\/(?:check-swift-tools|format-swift|install-swift-tools|install-xcodegen|lint-swift)\.sh$|scripts\/(?:ios-(?:configure-signing|screenshots|team-id|write-version-xcconfig)\.sh|ios-write-swift-filelist\.m[jt]s|ios-version\.ts)$|scripts\/lib\/(?:ios-fastlane\.sh|ios-version\.ts|release-version\.mjs|version-script-args\.ts)$)/;
/^(apps\/ios\/|apps\/shared\/|apps\/swabble\/|Swabble\/|scripts\/(?:check-swift-tools|format-swift|install-swift-tools|install-xcodegen|lint-swift)\.sh$|scripts\/(?:ios-(?:configure-signing|screenshots|team-id|write-version-xcconfig)\.sh|ios-screenshot-evidence\.(?:mjs|d\.mts)|ios-write-swift-filelist\.m[jt]s|ios-version\.ts)$|scripts\/lib\/(?:ios-fastlane\.sh|ios-version\.ts|release-version\.mjs|version-script-args\.ts)$)/;
const IOS_SCREENSHOT_APP_SCOPE_RE =
/^(?:apps\/ios\/|apps\/shared\/OpenClawKit\/|apps\/swabble\/|Swabble\/)/;
const IOS_SCREENSHOT_SCRIPT_SCOPE_RE =
/^scripts\/(?:check-swift-tools|format-swift|install-swift-tools|install-xcodegen|lint-swift)\.sh$|^scripts\/(?:ios-(?:configure-signing|screenshots|team-id|write-version-xcconfig)\.sh|ios-write-swift-filelist\.m[jt]s|ios-version\.ts)$|^scripts\/lib\/(?:ios-fastlane\.sh|ios-version\.ts|release-version\.mjs|version-script-args\.ts)$/;
/^scripts\/(?:check-swift-tools|format-swift|install-swift-tools|install-xcodegen|lint-swift)\.sh$|^scripts\/(?:ios-(?:configure-signing|screenshots|team-id|write-version-xcconfig)\.sh|ios-screenshot-evidence\.(?:mjs|d\.mts)|ios-write-swift-filelist\.m[jt]s|ios-version\.ts)$|^scripts\/lib\/(?:ios-fastlane\.sh|ios-version\.ts|release-version\.mjs|version-script-args\.ts)$/;
const ANDROID_NATIVE_RE = /^(apps\/android\/|apps\/shared\/)/;
const NODE_SCOPE_RE =
/^(src\/|test\/|extensions\/|packages\/|scripts\/|ui\/|\.github\/|openclaw\.mjs$|package\.json$|pnpm-lock\.yaml$|pnpm-workspace\.yaml$|tsconfig.*\.json$|vitest.*\.ts$|tsdown\.config\.ts$|\.oxlintrc\.json$|\.oxfmtrc\.jsonc$)/;
+38
View File
@@ -0,0 +1,38 @@
export type IosScreenshotTooling = {
xcode: string;
fastlane: string;
node: string;
};
export type IosScreenshotProvenance = {
targetSha: string;
workflowSha: string;
runId: string;
runAttempt: string | number;
tooling: IosScreenshotTooling;
};
export type XcresultSummary = {
testResult: string;
failedTests: number;
};
export function collectIosScreenshotEvidence(options: {
family: string;
screenshotDirectory: string;
xcresultDirectory: string;
outputDirectory: string;
provenance: IosScreenshotProvenance;
readXcresultSummary?: (resultPath: string) => XcresultSummary;
}): Record<string, unknown>;
export function reduceIosScreenshotEvidence(options: {
inputDirectory: string;
outputRoot: string;
expectedProvenance: IosScreenshotProvenance;
}): Record<string, unknown>;
export function parseIosScreenshotEvidenceArgs(argv: string[]): {
command: string | undefined;
options: Record<string, string>;
};
+784
View File
@@ -0,0 +1,784 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { isDirectRunUrl } from "./lib/direct-run.mjs";
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const CAPTURE_ATTEMPTS_FILENAME = "capture-attempts.json";
const SCREENSHOT_NAMES = [
"01-control-connected",
"02-chat-connected",
"03-agent-connected",
"04-settings-connected",
];
const FAMILY_SPECS = {
iphone: {
devicePattern: /^iPhone /u,
screenshotNames: SCREENSHOT_NAMES,
captureAttempts: true,
},
"ipad-13": {
devicePattern: /^iPad (?:Air|Pro) 13-inch/u,
screenshotNames: SCREENSHOT_NAMES,
captureAttempts: true,
},
watch: {
devicePattern: /^Apple Watch/u,
screenshotNames: ["01-now-face"],
captureAttempts: false,
},
};
const EXPECTED_FAMILIES = Object.keys(FAMILY_SPECS).toSorted();
// Model only whole OpenClaw Fastlane invocations. Fastlane's internal launch
// retries remain workflow-log evidence.
const ATTEMPT_MODEL = Object.freeze({
owner: "openclaw",
unit: "capture_ios_screenshots invocation",
maxAttempts: 2,
fastlaneInternalRetries: "workflow-log",
});
function fail(message) {
throw new Error(message);
}
function requireString(value, label) {
if (typeof value !== "string" || value.trim() === "") {
fail(`${label} must be a nonempty string`);
}
return value.trim();
}
function requireSha(value, label) {
const sha = requireString(value, label);
if (!/^[0-9a-f]{40}$/u.test(sha)) {
fail(`${label} must be a full lowercase commit SHA`);
}
return sha;
}
function requirePositiveInteger(value, label) {
const number = Number(value);
if (!Number.isInteger(number) || number < 1) {
fail(`${label} must be a positive integer`);
}
return number;
}
function listEntries(directory) {
if (!fs.existsSync(directory)) {
fail(`missing evidence directory: ${directory}`);
}
return fs.readdirSync(directory, { withFileTypes: true });
}
function sha256File(filePath) {
return crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
}
function listFilesRecursive(directory, prefix = "") {
return fs
.readdirSync(directory, { withFileTypes: true })
.flatMap((entry) => {
const relativePath = path.posix.join(prefix, entry.name);
const absolutePath = path.join(directory, entry.name);
return entry.isDirectory()
? listFilesRecursive(absolutePath, relativePath)
: [{ absolutePath, relativePath }];
})
.toSorted((left, right) => left.relativePath.localeCompare(right.relativePath));
}
function sha256Directory(directory) {
const hash = crypto.createHash("sha256");
for (const file of listFilesRecursive(directory)) {
hash.update(file.relativePath);
hash.update("\0");
hash.update(fs.readFileSync(file.absolutePath));
hash.update("\0");
}
return hash.digest("hex");
}
function verifyPng(filePath) {
const stat = fs.statSync(filePath);
if (!stat.isFile() || stat.size <= PNG_SIGNATURE.length) {
fail(`invalid empty PNG evidence: ${filePath}`);
}
const header = Buffer.alloc(PNG_SIGNATURE.length);
const descriptor = fs.openSync(filePath, "r");
try {
fs.readSync(descriptor, header, 0, header.length, 0);
} finally {
fs.closeSync(descriptor);
}
if (!header.equals(PNG_SIGNATURE)) {
fail(`invalid PNG signature: ${filePath}`);
}
return { bytes: stat.size, sha256: sha256File(filePath) };
}
function defaultReadXcresultSummary(resultPath) {
const result = spawnSync(
"xcrun",
["xcresulttool", "get", "test-results", "summary", "--path", resultPath, "--compact"],
{ encoding: "utf8" },
);
if (result.status !== 0) {
fail(
`failed to inspect ${resultPath}: ${result.stderr.trim() || result.stdout.trim() || `exit ${result.status}`}`,
);
}
try {
const summary = JSON.parse(result.stdout);
return {
testResult: requireString(summary.result, `${resultPath} test result`),
failedTests: Number(summary.failedTests),
};
} catch (error) {
return fail(`invalid xcresult summary for ${resultPath}: ${String(error)}`);
}
}
function copyEntry(source, destination) {
fs.mkdirSync(path.dirname(destination), { recursive: true });
fs.cpSync(source, destination, { recursive: true });
}
function screenshotDeviceName(filename, screenshotName) {
const suffix = `-${screenshotName}.png`;
return filename.endsWith(suffix) ? filename.slice(0, -suffix.length) : undefined;
}
function collectScreenshots({ family, screenshotDirectory, familyDirectory, spec }) {
const pngNames = listEntries(screenshotDirectory)
.filter((entry) => entry.isFile() && entry.name.endsWith(".png"))
.map((entry) => entry.name);
const matches = spec.screenshotNames.map((screenshotName) => {
const candidates = pngNames.filter((filename) => {
const deviceName = screenshotDeviceName(filename, screenshotName);
return deviceName && spec.devicePattern.test(deviceName);
});
if (candidates.length !== 1) {
fail(
`${family} expected one ${screenshotName} PNG, found ${candidates.length}: ${candidates.join(", ") || "none"}`,
);
}
return { filename: candidates[0], screenshotName };
});
const deviceNames = new Set(
matches.map(({ filename, screenshotName }) => screenshotDeviceName(filename, screenshotName)),
);
if (deviceNames.size !== 1) {
fail(`${family} screenshots span multiple devices: ${[...deviceNames].join(", ")}`);
}
const deviceName = [...deviceNames][0];
const familyPngs = pngNames.filter((filename) => spec.devicePattern.test(filename));
const expectedPngs = matches.map(({ filename }) => filename).toSorted();
if (familyPngs.toSorted().join("\n") !== expectedPngs.join("\n")) {
fail(
`${family} PNG union mismatch; expected ${expectedPngs.join(", ")}, found ${familyPngs.toSorted().join(", ")}`,
);
}
const screenshots = matches.map(({ filename, screenshotName }) => {
const source = path.join(screenshotDirectory, filename);
const artifactPath = path.posix.join("screenshots", filename);
const metadata = verifyPng(source);
copyEntry(source, path.join(familyDirectory, artifactPath));
return Object.assign(
{
name: screenshotName,
deviceName,
artifactPath,
canonicalPath: path.posix.join("apps/ios/fastlane/screenshots/en-US", filename),
},
metadata,
);
});
return { deviceName, screenshots };
}
function readCaptureAttemptLedger(xcresultDirectory) {
const ledgerPath = path.join(xcresultDirectory, CAPTURE_ATTEMPTS_FILENAME);
if (!fs.existsSync(ledgerPath) || !fs.statSync(ledgerPath).isFile()) {
fail(`missing OpenClaw capture attempt ledger: ${ledgerPath}`);
}
let ledger;
try {
ledger = JSON.parse(fs.readFileSync(ledgerPath, "utf8"));
} catch (error) {
return fail(`invalid OpenClaw capture attempt ledger: ${String(error)}`);
}
const expectedKeys = ["attempts", "schemaVersion"];
const actualKeys =
ledger && typeof ledger === "object" && !Array.isArray(ledger)
? Object.keys(ledger).toSorted((left, right) => left.localeCompare(right))
: [];
if (actualKeys.join("\n") !== expectedKeys.join("\n")) {
fail("OpenClaw capture attempt ledger has an unexpected shape");
}
if (ledger.schemaVersion !== 1 || !Array.isArray(ledger.attempts)) {
fail("OpenClaw capture attempt ledger has an unsupported schema");
}
return ledger.attempts;
}
function collectCaptureAttempts({
deviceName,
familyDirectory,
screenshotNames,
xcresultDirectory,
readXcresultSummary,
}) {
const directoryEntries = listEntries(xcresultDirectory);
const unexpectedEntries = directoryEntries.filter(
(entry) =>
!(
(entry.isFile() && entry.name === CAPTURE_ATTEMPTS_FILENAME) ||
(entry.isDirectory() && entry.name.endsWith(".xcresult"))
),
);
if (unexpectedEntries.length > 0) {
fail(
`OpenClaw capture evidence contains unexpected entries: ${unexpectedEntries.map((entry) => entry.name).join(", ")}`,
);
}
const xcresultNames = directoryEntries
.filter((entry) => entry.isDirectory() && entry.name.endsWith(".xcresult"))
.map((entry) => entry.name);
const ledgerEntries = readCaptureAttemptLedger(xcresultDirectory);
const results = [];
for (const screenshotName of screenshotNames) {
const attempts = ledgerEntries
.filter((entry) => entry.deviceName === deviceName && entry.screenshotName === screenshotName)
.toSorted((left, right) => left.attempt - right.attempt);
const attemptNumbers = attempts.map(({ attempt }) => attempt).join(",");
if (attemptNumbers !== "1" && attemptNumbers !== "1,2") {
fail(
`${deviceName} ${screenshotName} expected OpenClaw attempt 1 and optional retry 2; found ${attemptNumbers || "none"}`,
);
}
const summaries = attempts.map((entry, index) => {
const expectedKeys = ["attempt", "captureOutcome", "deviceName", "screenshotName"];
const actualKeys =
entry && typeof entry === "object" && !Array.isArray(entry)
? Object.keys(entry).toSorted((left, right) => left.localeCompare(right))
: [];
if (actualKeys.join("\n") !== expectedKeys.join("\n")) {
fail(`${deviceName} ${screenshotName} has an invalid capture attempt record`);
}
const { attempt, captureOutcome } = entry;
const expectedOutcome = index === attempts.length - 1 ? "succeeded" : "failed";
if (captureOutcome !== expectedOutcome) {
fail(`${deviceName} ${screenshotName} has an unexpected capture outcome sequence`);
}
const name = `${deviceName}-${screenshotName}-attempt-${attempt}.xcresult`;
const source = path.join(xcresultDirectory, name);
if (!fs.existsSync(source)) {
if (captureOutcome === "succeeded") {
fail(`${name} is missing for the successful final capture attempt`);
}
return {
screenshotName,
attempt,
captureOutcome,
artifactPath: null,
canonicalPath: null,
testResult: null,
failedTests: null,
sha256: null,
};
}
const summary = readXcresultSummary(source);
if (!Number.isInteger(summary.failedTests) || summary.failedTests < 0) {
fail(`${name} has invalid failedTests`);
}
const artifactPath = path.posix.join("xcresults", name);
copyEntry(source, path.join(familyDirectory, artifactPath));
return {
screenshotName,
attempt,
artifactPath,
canonicalPath: path.posix.join("apps/ios/build/SnapshotTestResults", name),
captureOutcome,
testResult: summary.testResult,
failedTests: summary.failedTests,
sha256: sha256Directory(source),
};
});
const final = summaries.at(-1);
if (
final.captureOutcome !== "succeeded" ||
final.artifactPath === null ||
final.testResult !== "Passed" ||
final.failedTests !== 0
) {
fail(`${deviceName} ${screenshotName} does not have a passing final capture attempt`);
}
results.push(...summaries);
}
if (ledgerEntries.length !== results.length) {
fail(`${deviceName} capture attempt ledger contains unexpected evidence`);
}
const expectedNames = results
.filter(({ artifactPath }) => artifactPath !== null)
.map(({ artifactPath }) => path.posix.basename(artifactPath));
const familyEntries = xcresultNames.filter((name) => name.startsWith(`${deviceName}-`));
if (
familyEntries.length !== xcresultNames.length ||
familyEntries.toSorted((left, right) => left.localeCompare(right)).join("\n") !==
expectedNames.toSorted((left, right) => left.localeCompare(right)).join("\n")
) {
fail(`${deviceName} xcresult union contains unexpected evidence`);
}
return results;
}
export function collectIosScreenshotEvidence({
family,
screenshotDirectory,
xcresultDirectory,
outputDirectory,
provenance,
readXcresultSummary = defaultReadXcresultSummary,
}) {
const spec = FAMILY_SPECS[family];
if (!spec) {
fail(`unsupported screenshot family: ${family}`);
}
const normalizedProvenance = {
targetSha: requireSha(provenance.targetSha, "target SHA"),
workflowSha: requireSha(provenance.workflowSha, "workflow SHA"),
runId: requireString(provenance.runId, "workflow run id"),
runAttempt: requirePositiveInteger(provenance.runAttempt, "workflow run attempt"),
tooling: {
xcode: requireString(provenance.tooling?.xcode, "Xcode version"),
fastlane: requireString(provenance.tooling?.fastlane, "Fastlane version"),
node: requireString(provenance.tooling?.node, "Node version"),
},
};
const familyDirectory = path.join(outputDirectory, family);
fs.rmSync(familyDirectory, { recursive: true, force: true });
fs.mkdirSync(familyDirectory, { recursive: true });
const { deviceName, screenshots } = collectScreenshots({
family,
screenshotDirectory,
familyDirectory,
spec,
});
const captureAttempts = spec.captureAttempts
? collectCaptureAttempts({
deviceName,
familyDirectory,
screenshotNames: spec.screenshotNames,
xcresultDirectory,
readXcresultSummary,
})
: [];
const manifest = {
schemaVersion: 1,
attemptModel: { ...ATTEMPT_MODEL },
family,
deviceName,
...normalizedProvenance,
screenshots,
captureAttempts,
};
fs.writeFileSync(
path.join(familyDirectory, "manifest.json"),
`${JSON.stringify(manifest, null, 2)}\n`,
);
return manifest;
}
function loadExpectedManifests(inputDirectory, targetSha) {
const expectedContainers = {
[`ios-release-screenshot-shard-iphone-${targetSha}`]: ["iphone", "watch"],
[`ios-release-screenshot-shard-ipad-13-${targetSha}`]: ["ipad-13"],
};
const actualContainers = listEntries(inputDirectory);
if (actualContainers.some((entry) => !entry.isDirectory())) {
fail("screenshot evidence input contains a non-container entry");
}
const actualContainerNames = actualContainers.map((entry) => entry.name).toSorted();
const expectedContainerNames = Object.keys(expectedContainers).toSorted();
if (actualContainerNames.join("\n") !== expectedContainerNames.join("\n")) {
fail(
`screenshot artifact container topology mismatch; expected ${expectedContainerNames.join(", ")}, found ${actualContainerNames.join(", ") || "none"}`,
);
}
return Object.entries(expectedContainers).flatMap(([containerName, expectedFamilies]) => {
const containerDirectory = path.join(inputDirectory, containerName);
const containerEntries = listEntries(containerDirectory);
if (containerEntries.some((entry) => !entry.isDirectory())) {
fail(`${containerName} contains a non-family entry`);
}
const actualFamilies = containerEntries.map((entry) => entry.name).toSorted();
if (actualFamilies.join("\n") !== expectedFamilies.toSorted().join("\n")) {
fail(
`${containerName} family topology mismatch; expected ${expectedFamilies.join(", ")}, found ${actualFamilies.join(", ") || "none"}`,
);
}
return expectedFamilies.map((family) => {
const manifestPath = path.join(containerDirectory, family, "manifest.json");
if (!fs.existsSync(manifestPath)) {
fail(`${containerName} is missing ${family}/manifest.json`);
}
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
if (manifest.family !== family) {
fail(`${containerName}/${family} contains ${manifest.family ?? "unknown"} family evidence`);
}
return { containerName, manifestPath, manifest };
});
});
}
function verifyManifestEntry(manifestPath, entry, kind) {
const familyDirectory = path.dirname(manifestPath);
const artifactPath = requireString(entry.artifactPath, `${kind} artifact path`);
const canonicalPath = requireString(entry.canonicalPath, `${kind} canonical path`);
const expectedRoot =
kind === "screenshot"
? "apps/ios/fastlane/screenshots/en-US/"
: "apps/ios/build/SnapshotTestResults/";
const expectedArtifactRoot = kind === "screenshot" ? "screenshots/" : "xcresults/";
const filename = path.posix.basename(canonicalPath);
if (canonicalPath !== `${expectedRoot}${filename}`) {
fail(`unexpected ${kind} canonical path: ${canonicalPath}`);
}
if (artifactPath !== `${expectedArtifactRoot}${filename}`) {
fail(`unexpected ${kind} artifact path: ${artifactPath}`);
}
const source = path.join(familyDirectory, artifactPath);
const actualDigest = kind === "screenshot" ? verifyPng(source).sha256 : sha256Directory(source);
if (actualDigest !== entry.sha256) {
fail(`${kind} digest mismatch for ${canonicalPath}`);
}
return { canonicalPath, source };
}
function verifyFamilyArtifactUnion(manifestPath, manifest) {
const familyDirectory = path.dirname(manifestPath);
if (path.basename(familyDirectory) !== manifest.family) {
fail(`${manifest.family} manifest is stored under an unexpected directory`);
}
const xcresults = manifest.captureAttempts.filter((entry) => entry.artifactPath !== null);
const expectedTopLevel = ["manifest.json", "screenshots"];
if (xcresults.length > 0) {
expectedTopLevel.push("xcresults");
}
const actualTopLevel = listEntries(familyDirectory)
.map((entry) => entry.name)
.toSorted();
if (actualTopLevel.join("\n") !== expectedTopLevel.toSorted().join("\n")) {
fail(`${manifest.family} shard contains unexpected evidence`);
}
const expectedScreenshots = manifest.screenshots
.map((entry) => path.posix.basename(entry.artifactPath))
.toSorted();
const actualScreenshots = listEntries(path.join(familyDirectory, "screenshots"))
.map((entry) => entry.name)
.toSorted();
if (actualScreenshots.join("\n") !== expectedScreenshots.join("\n")) {
fail(`${manifest.family} screenshot artifact union mismatch`);
}
if (xcresults.length > 0) {
const expectedXcresults = xcresults
.map((entry) => path.posix.basename(entry.artifactPath))
.toSorted();
const actualXcresults = listEntries(path.join(familyDirectory, "xcresults"))
.map((entry) => entry.name)
.toSorted();
if (actualXcresults.join("\n") !== expectedXcresults.join("\n")) {
fail(`${manifest.family} xcresult artifact union mismatch`);
}
}
}
function verifyAttemptModel(manifest) {
const model = manifest.attemptModel;
const expectedKeys = Object.keys(ATTEMPT_MODEL).toSorted();
const actualKeys =
model && typeof model === "object" && !Array.isArray(model)
? Object.keys(model).toSorted()
: [];
if (
actualKeys.join("\n") !== expectedKeys.join("\n") ||
expectedKeys.some((key) => model[key] !== ATTEMPT_MODEL[key])
) {
fail(`${manifest.family} has an unexpected attempt model`);
}
}
function verifyManifestFamily(manifestPath, manifest) {
const spec = FAMILY_SPECS[manifest.family];
verifyAttemptModel(manifest);
const deviceName = requireString(manifest.deviceName, `${manifest.family} device name`);
if (!spec.devicePattern.test(deviceName)) {
fail(`${manifest.family} has unexpected device name: ${deviceName}`);
}
requireString(manifest.runId, `${manifest.family} workflow run id`);
requirePositiveInteger(manifest.runAttempt, `${manifest.family} workflow run attempt`);
requireString(manifest.tooling?.xcode, `${manifest.family} Xcode version`);
requireString(manifest.tooling?.fastlane, `${manifest.family} Fastlane version`);
requireString(manifest.tooling?.node, `${manifest.family} Node version`);
const screenshotNames = manifest.screenshots?.map((entry) => entry.name).toSorted();
if (
screenshotNames?.join("\n") !==
spec.screenshotNames.toSorted((left, right) => left.localeCompare(right)).join("\n")
) {
fail(`${manifest.family} screenshot name union mismatch`);
}
for (const screenshot of manifest.screenshots) {
const expectedFilename = `${deviceName}-${screenshot.name}.png`;
const expectedCanonicalPath = `apps/ios/fastlane/screenshots/en-US/${expectedFilename}`;
if (screenshot.canonicalPath !== expectedCanonicalPath) {
fail(`${manifest.family} has unexpected screenshot path: ${screenshot.canonicalPath}`);
}
if (screenshot.artifactPath !== `screenshots/${expectedFilename}`) {
fail(
`${manifest.family} has unexpected screenshot artifact path: ${screenshot.artifactPath}`,
);
}
}
if (!Array.isArray(manifest.captureAttempts)) {
fail(`${manifest.family} capture attempts must be an array`);
}
if (!spec.captureAttempts) {
if (manifest.captureAttempts?.length !== 0) {
fail(`${manifest.family} must not contain capture attempt evidence`);
}
verifyFamilyArtifactUnion(manifestPath, manifest);
return;
}
const knownScreenshotNames = new Set(spec.screenshotNames);
if (manifest.captureAttempts.some((entry) => !knownScreenshotNames.has(entry.screenshotName))) {
fail(`${manifest.family} capture attempt union contains an unexpected screenshot`);
}
for (const screenshotName of spec.screenshotNames) {
const attempts = manifest.captureAttempts
?.filter((entry) => entry.screenshotName === screenshotName)
.toSorted((left, right) => left.attempt - right.attempt);
const attemptNumbers = attempts?.map((entry) => entry.attempt).join(",");
if (attemptNumbers !== "1" && attemptNumbers !== "1,2") {
fail(`${manifest.family} ${screenshotName} capture attempt union mismatch`);
}
const final = attempts.at(-1);
if (
final.captureOutcome !== "succeeded" ||
final.artifactPath === null ||
final.testResult !== "Passed" ||
final.failedTests !== 0
) {
fail(`${manifest.family} ${screenshotName} final xcresult is not passing`);
}
if (
attempts.some(
(entry, index) =>
entry.captureOutcome !== (index === attempts.length - 1 ? "succeeded" : "failed"),
)
) {
fail(`${manifest.family} ${screenshotName} has an unexpected capture outcome sequence`);
}
for (const attempt of attempts) {
if (attempt.artifactPath === null) {
if (
attempt.captureOutcome !== "failed" ||
attempt.canonicalPath !== null ||
attempt.testResult !== null ||
attempt.failedTests !== null ||
attempt.sha256 !== null
) {
fail(`${manifest.family} ${screenshotName} has invalid missing xcresult evidence`);
}
continue;
}
requireString(attempt.testResult, `${manifest.family} ${screenshotName} test result`);
if (!Number.isInteger(attempt.failedTests) || attempt.failedTests < 0) {
fail(`${manifest.family} ${screenshotName} has invalid failedTests`);
}
const expectedFilename = `${deviceName}-${screenshotName}-attempt-${attempt.attempt}.xcresult`;
const expectedCanonicalPath = `apps/ios/build/SnapshotTestResults/${expectedFilename}`;
if (attempt.canonicalPath !== expectedCanonicalPath) {
fail(`${manifest.family} has unexpected xcresult path: ${attempt.canonicalPath}`);
}
if (attempt.artifactPath !== `xcresults/${expectedFilename}`) {
fail(`${manifest.family} has unexpected xcresult artifact path: ${attempt.artifactPath}`);
}
}
}
verifyFamilyArtifactUnion(manifestPath, manifest);
}
export function reduceIosScreenshotEvidence({ inputDirectory, outputRoot, expectedProvenance }) {
const expected = {
targetSha: requireSha(expectedProvenance.targetSha, "expected target SHA"),
workflowSha: requireSha(expectedProvenance.workflowSha, "expected workflow SHA"),
runId: requireString(expectedProvenance.runId, "expected workflow run id"),
runAttempt: requirePositiveInteger(
expectedProvenance.runAttempt,
"expected workflow run attempt",
),
tooling: {
xcode: requireString(expectedProvenance.tooling?.xcode, "expected Xcode version"),
fastlane: requireString(expectedProvenance.tooling?.fastlane, "expected Fastlane version"),
node: requireString(expectedProvenance.tooling?.node, "expected Node version"),
},
};
const manifests = loadExpectedManifests(inputDirectory, expected.targetSha);
const families = manifests
.map(({ manifest }) => manifest.family)
.toSorted((left, right) => left.localeCompare(right));
if (families.join("\n") !== EXPECTED_FAMILIES.join("\n")) {
fail(
`screenshot family union mismatch; expected ${EXPECTED_FAMILIES.join(", ")}, found ${families.join(", ") || "none"}`,
);
}
const canonicalEntries = [];
for (const { manifestPath, manifest } of manifests) {
if (manifest.schemaVersion !== 1) {
fail(`unsupported screenshot manifest schema in ${manifestPath}`);
}
if (manifest.targetSha !== expected.targetSha) {
fail(
`cross-SHA screenshot evidence in ${manifestPath}: expected ${expected.targetSha}, found ${manifest.targetSha}`,
);
}
if (manifest.workflowSha !== expected.workflowSha) {
fail(`${manifest.family} workflow SHA does not match the reducer context`);
}
if (manifest.runId !== expected.runId) {
fail(`${manifest.family} workflow run id does not match the reducer context`);
}
if (manifest.runAttempt !== expected.runAttempt) {
fail(`${manifest.family} workflow run attempt does not match the reducer context`);
}
for (const tool of ["xcode", "fastlane", "node"]) {
if (manifest.tooling?.[tool] !== expected.tooling[tool]) {
fail(`${manifest.family} ${tool} version does not match the reducer context`);
}
}
verifyManifestFamily(manifestPath, manifest);
for (const screenshot of manifest.screenshots) {
canonicalEntries.push({
...verifyManifestEntry(manifestPath, screenshot, "screenshot"),
family: manifest.family,
});
}
for (const attempt of manifest.captureAttempts) {
if (attempt.artifactPath === null) {
continue;
}
canonicalEntries.push({
...verifyManifestEntry(manifestPath, attempt, "xcresult"),
family: manifest.family,
});
}
}
const canonicalPaths = canonicalEntries.map(({ canonicalPath }) => canonicalPath);
if (new Set(canonicalPaths).size !== canonicalPaths.length) {
fail("duplicate canonical screenshot evidence paths");
}
const screenshotOutput = path.join(outputRoot, "apps/ios/fastlane/screenshots/en-US");
const xcresultOutput = path.join(outputRoot, "apps/ios/build/SnapshotTestResults");
const manifestOutput = path.join(outputRoot, "apps/ios/build/ScreenshotEvidence");
for (const directory of [screenshotOutput, xcresultOutput, manifestOutput]) {
fs.rmSync(directory, { recursive: true, force: true });
fs.mkdirSync(directory, { recursive: true });
}
for (const entry of canonicalEntries) {
copyEntry(entry.source, path.join(outputRoot, entry.canonicalPath));
}
const combinedManifest = {
schemaVersion: 1,
attemptModel: { ...ATTEMPT_MODEL },
...expected,
families: manifests
.map(({ manifest }) => manifest)
.toSorted((left, right) => left.family.localeCompare(right.family)),
};
fs.writeFileSync(
path.join(manifestOutput, "manifest.json"),
`${JSON.stringify(combinedManifest, null, 2)}\n`,
);
return combinedManifest;
}
export function parseIosScreenshotEvidenceArgs(argv) {
const [command, ...rest] = argv;
const options = {};
for (let index = 0; index < rest.length; index += 2) {
const key = rest[index];
const value = rest[index + 1];
if (!key?.startsWith("--") || value === undefined) {
fail(`invalid argument list near ${key ?? "end of input"}`);
}
options[key.slice(2)] = value;
}
return { command, options };
}
function main(argv) {
const { command, options } = parseIosScreenshotEvidenceArgs(argv);
if (command === "collect") {
const manifest = collectIosScreenshotEvidence({
family: options.family,
screenshotDirectory: options.screenshots,
xcresultDirectory: options.xcresults,
outputDirectory: options.output,
provenance: {
targetSha: options["target-sha"],
workflowSha: options["workflow-sha"],
runId: options["run-id"],
runAttempt: options["run-attempt"],
tooling: {
xcode: options["xcode-version"],
fastlane: options["fastlane-version"],
node: options["node-version"],
},
},
});
console.log(`collected ${manifest.family} screenshot evidence for ${manifest.targetSha}`);
return;
}
if (command === "reduce") {
const manifest = reduceIosScreenshotEvidence({
inputDirectory: options.input,
outputRoot: options.output,
expectedProvenance: {
targetSha: options["target-sha"],
workflowSha: options["workflow-sha"],
runId: options["run-id"],
runAttempt: options["run-attempt"],
tooling: {
xcode: options["xcode-version"],
fastlane: options["fastlane-version"],
node: options["node-version"],
},
},
});
console.log(`reduced iOS screenshot evidence for ${manifest.targetSha}`);
return;
}
fail(`expected command collect or reduce, got ${command ?? "none"}`);
}
if (isDirectRunUrl(process.argv[1], import.meta.url)) {
try {
main(process.argv.slice(2));
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
console.error("[ios-screenshot-evidence] FAILED (exit 1)");
process.exitCode = 1;
}
}
+8 -1
View File
@@ -11,6 +11,8 @@ describe("shouldRunIosScreenshots", () => {
"apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
"apps/swabble/Sources/SwabbleKit/WakeWordGate.swift",
"scripts/ios-screenshots.sh",
"scripts/ios-screenshot-evidence.mjs",
"scripts/ios-screenshot-evidence.d.mts",
"scripts/lib/ios-fastlane.sh",
"scripts/ios-write-swift-filelist.mjs",
"config/swiftformat",
@@ -31,7 +33,12 @@ describe("shouldRunIosScreenshots", () => {
});
it("keeps screenshot capture wrappers inside the iOS build lane", () => {
for (const changedPath of ["scripts/ios-screenshots.sh", "scripts/lib/ios-fastlane.sh"]) {
for (const changedPath of [
"scripts/ios-screenshots.sh",
"scripts/ios-screenshot-evidence.mjs",
"scripts/ios-screenshot-evidence.d.mts",
"scripts/lib/ios-fastlane.sh",
]) {
expect(detectChangedScope([changedPath]).runIosBuild, changedPath).toBe(true);
}
});
+13 -3
View File
@@ -3126,7 +3126,7 @@ NODE
expect(codeqlSelect.run).toContain('if [[ "$xcode_version" != 26.6* ]]; then');
for (const [workflowPath, selectorCount] of [
[".github/workflows/ci.yml", 1],
[".github/workflows/ci.yml", 2],
[".github/workflows/ios-periphery.yml", 1],
[".github/workflows/macos-periphery.yml", 1],
[".github/workflows/shared-openclawkit-periphery.yml", 2],
@@ -3320,6 +3320,7 @@ NODE
"control-ui-i18n": "ubuntu-24.04",
"docker-seed-e2e": "ubuntu-24.04",
"ios-build": "macos-26",
"ios-screenshot-shard": "macos-26",
"macos-node": "macos-15",
"macos-swift": "macos-26",
"native-i18n": "ubuntu-24.04",
@@ -3346,6 +3347,7 @@ NODE
"macos-node": "blacksmith-6vcpu-macos-15",
"macos-swift": "blacksmith-12vcpu-macos-26",
"ios-build": "blacksmith-12vcpu-macos-26",
"ios-screenshot-shard": "blacksmith-12vcpu-macos-26",
"check-test-types-hosted-core-shard": "blacksmith-8vcpu-ubuntu-2404",
"checks-ui": "blacksmith-8vcpu-ubuntu-2404",
"checks-windows": "blacksmith-8vcpu-windows-2025",
@@ -5805,10 +5807,16 @@ server.listen(0, "127.0.0.1", () => writeFileSync(readyPath, String(server.addre
const workflow = readCiWorkflow();
expect(source.match(/&platform_checkout_step/gu) ?? []).toHaveLength(1);
expect(source.match(/\*platform_checkout_step/gu) ?? []).toHaveLength(3);
expect(source.match(/\*platform_checkout_step/gu) ?? []).toHaveLength(4);
expect(source.match(/fetch_checkout_ref_once\(\)/gu) ?? []).toHaveLength(1);
for (const jobName of ["checks-windows", "macos-node", "macos-swift", "ios-build"]) {
for (const jobName of [
"checks-windows",
"macos-node",
"macos-swift",
"ios-build",
"ios-screenshot-shard",
]) {
const checkoutStep = workflow.jobs[jobName].steps.find(
(step: WorkflowStep) => step.name === "Checkout",
);
@@ -7786,6 +7794,8 @@ printf '%s\n' "\${CURL_SUCCESS_IP:-203.0.113.7}"
"macos-node",
"macos-swift",
"ios-build",
"ios-screenshot-shard",
"ios-screenshot-evidence",
"android",
"docker-seed-e2e",
];
+104 -24
View File
@@ -127,11 +127,13 @@ function swiftFunctionBody(source: string, name: string): string {
}
describe("iOS Fastlane release upload gates", () => {
it("pins the CI Ruby and Fastlane toolchain", () => {
it("pins the CI Ruby and Fastlane toolchain on the Fastlane-owning screenshot shards", () => {
const workflow = readFileSync(ciWorkflowPath, "utf8");
const iosJobStart = workflow.indexOf("\n ios-build:\n");
const iosJobEnd = workflow.indexOf("\n android:\n", iosJobStart);
const iosJobEnd = workflow.indexOf("\n ios-screenshot-shard:\n", iosJobStart);
const iosJob = workflow.slice(iosJobStart, iosJobEnd);
const shardJobEnd = workflow.indexOf("\n ios-screenshot-evidence:\n", iosJobEnd);
const shardJob = workflow.slice(iosJobEnd, shardJobEnd);
const gemfile = readFileSync(gemfilePath, "utf8");
const lockfile = readFileSync(gemfileLockPath, "utf8");
@@ -144,16 +146,22 @@ describe("iOS Fastlane release upload gates", () => {
expect(lockfile).toContain("CHECKSUMS");
expect(lockfile).toContain("RUBY VERSION\n ruby 3.4.10");
expect(lockfile).toContain("BUNDLED WITH\n 2.6.9");
expect(iosJob).toContain('BUNDLE_DEPLOYMENT: "true"');
expect(iosJob).toContain("BUNDLE_GEMFILE: ${{ github.workspace }}/apps/ios/Gemfile");
expect(iosJob).toContain("ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b");
expect(iosJob).toContain('ruby-version: "3.4.10"');
expect(iosJob).toContain('bundler: "2.6.9"');
expect(iosJob).toContain("bundler-cache: false");
expect(iosJob).toContain("working-directory: apps/ios");
expect(iosJob).toContain("bundle _2.6.9_ install --jobs 4 --retry 3");
expect(iosJob).toContain("bundle _2.6.9_ check");
expect(iosJob).toContain("bundle _2.6.9_ exec fastlane --version");
expect(iosJob).not.toContain("BUNDLE_DEPLOYMENT");
expect(iosJob).not.toContain("BUNDLE_GEMFILE");
expect(iosJob).not.toContain("ruby/setup-ruby@");
expect(iosJob).not.toContain("Install locked Fastlane bundle");
expect(shardJob).toContain('BUNDLE_DEPLOYMENT: "true"');
expect(shardJob).toContain("BUNDLE_GEMFILE: ${{ github.workspace }}/apps/ios/Gemfile");
expect(shardJob).toContain("ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b");
expect(shardJob).toContain('ruby-version: "3.4.10"');
expect(shardJob).toContain('bundler: "2.6.9"');
expect(shardJob).toContain("bundler-cache: false");
expect(shardJob).toContain("working-directory: apps/ios");
expect(shardJob).toContain("bundle _2.6.9_ install --jobs 4 --retry 3");
expect(shardJob).toContain("bundle _2.6.9_ check");
expect(shardJob).toContain("bundle _2.6.9_ exec fastlane --version");
expect(workflow.match(/ruby\/setup-ruby@/gu)).toHaveLength(1);
expect(workflow.match(/name: Install locked Fastlane bundle/gu)).toHaveLength(1);
});
it("documents every iOS Fastlane command through the pinned bundle", () => {
@@ -446,6 +454,8 @@ describe("iOS Fastlane release upload gates", () => {
const screenshots = laneBody(fastfile, "screenshots");
const capture = functionBody(fastfile, "capture_release_ios_screenshot!");
const archive = functionBody(fastfile, "archive_snapshot_test_result!");
const attemptRecorder = functionBody(fastfile, "record_release_ios_screenshot_attempt!");
const attemptWriter = functionBody(fastfile, "write_release_ios_screenshot_attempts!");
const verifier = functionBody(fastfile, "verify_snapshot_test_result!");
expect(screenshots).toContain("devices = snapshot_devices");
@@ -465,11 +475,22 @@ describe("iOS Fastlane release upload gates", () => {
expect(capture).toContain("stop_after_first_error: true");
expect(capture).toContain("retrying once in a fresh simulator session");
expect(capture).toContain("verify_snapshot_test_result!");
expect(capture).toContain('capture_outcome: "failed"');
expect(capture).toContain('capture_outcome: "succeeded"');
expect(capture.indexOf('capture_outcome: "failed"')).toBeLessThan(
capture.indexOf("raise if attempt == 2"),
);
expect(attemptRecorder).toContain('"captureOutcome" => capture_outcome');
expect(attemptRecorder).toContain("write_release_ios_screenshot_attempts!(");
expect(attemptWriter).toContain('"schemaVersion" => 1');
expect(archive).toContain('"#{device}-#{screenshot_name}-attempt-#{attempt}.xcresult"');
expect(screenshots).toContain("verify_release_ios_screenshot_manifest!(");
expect(screenshots).toContain(
'result_bundle_archive_directory = File.join(ios_root, "build", "SnapshotTestResults")',
);
expect(screenshots).toContain(
'capture_attempts_path = File.join(result_bundle_archive_directory, "capture-attempts.json")',
);
expect(screenshots.indexOf("capture_release_ios_screenshot!")).toBeLessThan(
screenshots.indexOf('FileUtils.rm_rf(File.join(output_directory, "test_output"))'),
);
@@ -515,9 +536,17 @@ describe("iOS Fastlane release upload gates", () => {
it("requires the exact nonempty PNG manifest before Watch capture", () => {
const fastfile = readFastfile();
const screenshots = laneBody(fastfile, "screenshots");
const snapshotDevices = functionBody(fastfile, "snapshot_devices");
const defaultSnapshotDevices = functionBody(fastfile, "default_snapshot_devices");
const verifier = functionBody(fastfile, "verify_release_ios_screenshot_manifest!");
expect(fastfile).toContain("REQUIRED_IOS_SCREENSHOT_NAMES");
expect(snapshotDevices).toContain('ENV["OPENCLAW_SNAPSHOT_DEVICES"]');
expect(snapshotDevices).toContain("return default_snapshot_devices if raw.empty?");
expect(defaultSnapshotDevices).toContain("available_simulator_devices");
expect(defaultSnapshotDevices).toContain('ENV["OPENCLAW_SNAPSHOT_DEVICE_FAMILY"]');
expect(defaultSnapshotDevices).toContain("families = DEFAULT_SNAPSHOT_DEVICE_FAMILIES");
expect(defaultSnapshotDevices).toContain("families = [family]");
expect(verifier).toContain("expected_names - actual_names");
expect(verifier).toContain("actual_names - expected_names");
expect(verifier).toContain("File.size?(path)");
@@ -528,25 +557,76 @@ describe("iOS Fastlane release upload gates", () => {
expect(screenshots.indexOf("verify_release_ios_screenshot_manifest!")).toBeLessThan(
screenshots.indexOf("watch_screenshot("),
);
expect(screenshots).toContain('ENV["OPENCLAW_SNAPSHOT_SKIP_WATCH"] == "1"');
});
it("runs the exact screenshot lane during native Apple, manual, and full release CI", () => {
it("shards exact screenshot evidence without changing runner authorization", () => {
const workflow = readFileSync(ciWorkflowPath, "utf8");
const iosJobStart = workflow.indexOf("\n ios-build:\n");
const iosJobEnd = workflow.indexOf("\n android:\n", iosJobStart);
const iosJobEnd = workflow.indexOf("\n ios-screenshot-shard:\n", iosJobStart);
const iosJob = workflow.slice(iosJobStart, iosJobEnd);
const shardJobStart = iosJobEnd;
const shardJobEnd = workflow.indexOf("\n ios-screenshot-evidence:\n", shardJobStart);
const shardJob = workflow.slice(shardJobStart, shardJobEnd);
const reducerJobStart = shardJobEnd;
const reducerJobEnd = workflow.indexOf("\n android:\n", reducerJobStart);
const reducerJob = workflow.slice(reducerJobStart, reducerJobEnd);
expect(workflow).toContain('IOS_SCREENSHOT_NODE_VERSION: "24.16.0"');
expect(workflow).toContain('IOS_SCREENSHOT_XCODE_VERSION: "Xcode 26.6 Build version 17F113"');
expect(iosJob).toContain("timeout-minutes: 150");
expect(iosJob).toContain("Capture iOS release screenshots");
expect(iosJob).toContain("github.event_name == 'workflow_dispatch'");
expect(iosJob).toContain("github.event_name == 'pull_request'");
expect(iosJob).toContain("inputs.release_gate");
expect(iosJob).toContain("needs.preflight.outputs.run_ios_screenshots == 'true'");
expect(iosJob).not.toContain("needs.preflight.outputs.run_macos == 'true'");
expect(iosJob).toContain("run: pnpm ios:screenshots");
expect(iosJob).toContain("Upload iOS release screenshot evidence");
expect(iosJob).toContain("apps/ios/build/SnapshotTestResults/*.xcresult");
expect(iosJob).toContain("if-no-files-found: error");
expect(iosJob).not.toContain("Capture iOS release screenshots");
expect(shardJob).toContain("needs: [preflight, ios-build]");
expect(shardJob).toContain("max-parallel: 2");
expect(shardJob).toContain("device_family: [iphone, ipad-13]");
expect(shardJob).toContain('OPENCLAW_SNAPSHOT_SKIP_WATCH: "1"');
expect(shardJob).toContain("if: matrix.device_family == 'iphone'");
expect(shardJob).toContain("run_ios_fastlane ios watch_screenshot");
expect(shardJob).toContain("run: pnpm ios:screenshots");
expect(shardJob).toContain("id: package_screenshot_evidence");
expect(shardJob).toContain("steps.package_screenshot_evidence.outcome == 'failure'");
expect(shardJob).toContain("apps/ios/build/SnapshotTestResults/capture-attempts.json");
expect(shardJob).not.toContain("IOS_SCREENSHOT_FASTLANE_VERSION");
expect(shardJob).toContain("IOS_SCREENSHOT_NODE_VERSION");
expect(shardJob).toContain("IOS_SCREENSHOT_XCODE_VERSION");
expect(shardJob).not.toContain('test "$fastlane_version" = "$IOS_SCREENSHOT_FASTLANE_VERSION"');
expect(shardJob).toContain("node-version: ${{ env.IOS_SCREENSHOT_NODE_VERSION }}");
expect(shardJob).not.toContain("SnapshotDerivedData");
expect(shardJob.match(/contents: read/g)).toHaveLength(1);
expect(reducerJob).toContain("needs: [preflight, ios-screenshot-shard]");
expect(reducerJob).toContain("merge-multiple: false");
expect(reducerJob).toContain("Setup screenshot evidence Node");
expect(reducerJob).toContain("node-version: ${{ env.IOS_SCREENSHOT_NODE_VERSION }}");
expect(reducerJob).toContain("id: reduce_screenshot_evidence");
expect(reducerJob).toContain("scripts/ios-screenshot-evidence.mjs reduce");
expect(reducerJob).toContain('--workflow-sha "$WORKFLOW_SHA"');
expect(reducerJob).toContain('--run-id "$RUN_ID"');
expect(reducerJob).toContain('--run-attempt "$RUN_ATTEMPT"');
expect(reducerJob).toContain('--xcode-version "$IOS_SCREENSHOT_XCODE_VERSION"');
expect(reducerJob).toContain('--fastlane-version "$IOS_SCREENSHOT_FASTLANE_VERSION"');
expect(reducerJob).toContain('--node-version "$(node --version)"');
expect(reducerJob).toContain("steps.reduce_screenshot_evidence.outcome == 'failure'");
expect(reducerJob).toContain("apps/ios/build/ScreenshotEvidenceInputs/**/xcresults/*.xcresult");
expect(reducerJob).toContain(
"name: ios-release-screenshots-${{ needs.preflight.outputs.checkout_revision }}",
);
expect(reducerJob).toContain("apps/ios/build/ScreenshotEvidence/manifest.json");
expect(reducerJob).not.toContain("SnapshotDerivedData");
});
it("records the installed Fastlane semver before the update notice", () => {
const workflow = readFileSync(ciWorkflowPath, "utf8");
const parser = workflow.match(/run_ios_fastlane --version 2>&1 \| awk '([^']+)'/u)?.[1];
expect(parser).toBeDefined();
const result = spawnSync("awk", [parser!], {
encoding: "utf8",
input:
"fastlane installation at path:\nfastlane 2.236.1\n# fastlane 2.238.0 is available. You are on 2.236.1.\n",
});
expect(result.status).toBe(0);
expect(result.stdout).toBe("2.236.1\n");
});
it("preserves caller-pinned Swift tools in archive build PATH", () => {
@@ -0,0 +1,563 @@
import fs from "node:fs";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
collectIosScreenshotEvidence,
reduceIosScreenshotEvidence,
} from "../../scripts/ios-screenshot-evidence.mjs";
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
const TARGET_SHA = "a".repeat(40);
const WORKFLOW_SHA = "b".repeat(40);
const PNG = Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
Buffer.from("fixture"),
]);
const SCREENSHOTS = [
"01-control-connected",
"02-chat-connected",
"03-agent-connected",
"04-settings-connected",
];
const ATTEMPT_MODEL = {
owner: "openclaw",
unit: "capture_ios_screenshots invocation",
maxAttempts: 2,
fastlaneInternalRetries: "workflow-log",
};
type Family = "iphone" | "ipad-13" | "watch";
function provenance(targetSha = TARGET_SHA) {
return {
targetSha,
workflowSha: WORKFLOW_SHA,
runId: "12345",
runAttempt: 2,
tooling: {
xcode: "Xcode 26.6 Build version 17F113",
fastlane: "2.236.1",
node: "v24.16.0",
},
};
}
function writeFamilySource(
root: string,
family: Family,
options: {
retry?: string;
retryTestResult?: "fail" | "pass";
retryWithoutXcresult?: boolean;
} = {},
) {
const screenshots = path.join(root, family, "screenshots");
const xcresults = path.join(root, family, "xcresults");
fs.mkdirSync(screenshots, { recursive: true });
fs.mkdirSync(xcresults, { recursive: true });
const device =
family === "iphone"
? "iPhone 17 Pro Max"
: family === "ipad-13"
? "iPad Pro 13-inch (M5)"
: "Apple Watch Ultra 3 (49mm)";
const names = family === "watch" ? ["01-now-face"] : SCREENSHOTS;
const captureAttempts = [];
for (const name of names) {
fs.writeFileSync(path.join(screenshots, `${device}-${name}.png`), PNG);
if (family !== "watch") {
const retried = options.retry === name;
captureAttempts.push({
deviceName: device,
screenshotName: name,
attempt: 1,
captureOutcome: retried ? "failed" : "succeeded",
});
if (!(retried && options.retryWithoutXcresult)) {
const attemptOne = path.join(xcresults, `${device}-${name}-attempt-1.xcresult`);
fs.mkdirSync(attemptOne, { recursive: true });
fs.writeFileSync(
path.join(attemptOne, "summary.txt"),
retried ? (options.retryTestResult ?? "pass") : "pass",
);
}
if (options.retry === name) {
captureAttempts.push({
deviceName: device,
screenshotName: name,
attempt: 2,
captureOutcome: "succeeded",
});
const attemptTwo = path.join(xcresults, `${device}-${name}-attempt-2.xcresult`);
fs.mkdirSync(attemptTwo, { recursive: true });
fs.writeFileSync(path.join(attemptTwo, "summary.txt"), "pass");
}
}
}
if (family !== "watch") {
fs.writeFileSync(
path.join(xcresults, "capture-attempts.json"),
JSON.stringify({ schemaVersion: 1, attempts: captureAttempts }),
);
}
return { device, screenshots, xcresults };
}
function containerName(family: Family, targetSha = TARGET_SHA) {
const shard = family === "watch" ? "iphone" : family;
return `ios-release-screenshot-shard-${shard}-${targetSha}`;
}
function familyDirectory(input: string, family: Family, targetSha = TARGET_SHA) {
return path.join(input, containerName(family, targetSha), family);
}
function manifestPath(input: string, family: Family, targetSha = TARGET_SHA) {
return path.join(familyDirectory(input, family, targetSha), "manifest.json");
}
function collectAll(
root: string,
targetSha = TARGET_SHA,
options: { retryWithoutXcresult?: boolean } = {},
) {
const output = path.join(root, "collected");
for (const family of ["iphone", "ipad-13", "watch"] as const) {
const source = writeFamilySource(root, family, {
retry: family === "iphone" ? "02-chat-connected" : undefined,
retryWithoutXcresult: family === "iphone" && options.retryWithoutXcresult,
});
collectIosScreenshotEvidence({
family,
screenshotDirectory: source.screenshots,
xcresultDirectory: source.xcresults,
outputDirectory: path.join(output, containerName(family, targetSha)),
provenance: provenance(targetSha),
readXcresultSummary: (resultPath) => {
const result = fs.readFileSync(path.join(resultPath, "summary.txt"), "utf8");
return result === "pass"
? { testResult: "Passed", failedTests: 0 }
: { testResult: "Failed", failedTests: 1 };
},
});
}
return output;
}
function reduceAll(input: string, outputRoot: string, expected = provenance()) {
return reduceIosScreenshotEvidence({
inputDirectory: input,
outputRoot,
expectedProvenance: expected,
});
}
function updateManifest(
input: string,
family: Family,
mutate: (manifest: Record<string, any>) => void,
) {
const filePath = manifestPath(input, family);
const manifest = JSON.parse(fs.readFileSync(filePath, "utf8"));
mutate(manifest);
fs.writeFileSync(filePath, JSON.stringify(manifest));
}
function updateAllManifests(input: string, mutate: (manifest: Record<string, any>) => void) {
for (const family of ["iphone", "ipad-13", "watch"] as const) {
updateManifest(input, family, mutate);
}
}
describe("iOS screenshot evidence", () => {
it("reduces the exact device union and models passed retry xcresults by capture outcome", () => {
const root = tempDirs.make("ios-screenshot-evidence-");
const input = collectAll(root);
const output = path.join(root, "reduced");
const manifest = reduceAll(input, output);
const iphoneManifest = JSON.parse(fs.readFileSync(manifestPath(input, "iphone"), "utf8"));
const retryAttempts = iphoneManifest.captureAttempts.filter(
(entry: { screenshotName: string }) => entry.screenshotName === "02-chat-connected",
);
expect(manifest.targetSha).toBe(TARGET_SHA);
expect(manifest.attemptModel).toEqual(ATTEMPT_MODEL);
expect(retryAttempts.map((entry: { captureOutcome: string }) => entry.captureOutcome)).toEqual([
"failed",
"succeeded",
]);
expect(retryAttempts.map((entry: { testResult: string }) => entry.testResult)).toEqual([
"Passed",
"Passed",
]);
expect(fs.readdirSync(path.join(output, "apps/ios/fastlane/screenshots/en-US"))).toHaveLength(
9,
);
expect(
fs.existsSync(
path.join(
output,
"apps/ios/build/SnapshotTestResults",
"iPhone 17 Pro Max-02-chat-connected-attempt-1.xcresult",
),
),
).toBe(true);
expect(
fs.existsSync(
path.join(
output,
"apps/ios/build/SnapshotTestResults",
"iPhone 17 Pro Max-02-chat-connected-attempt-2.xcresult",
),
),
).toBe(true);
});
it("accepts a failed first invocation without an xcresult before a passing retry", () => {
const root = tempDirs.make("ios-screenshot-missing-retry-xcresult-");
const input = collectAll(root, TARGET_SHA, { retryWithoutXcresult: true });
const output = path.join(root, "reduced");
reduceAll(input, output);
const iphoneManifest = JSON.parse(fs.readFileSync(manifestPath(input, "iphone"), "utf8"));
const retryAttempts = iphoneManifest.captureAttempts.filter(
(entry: { screenshotName: string }) => entry.screenshotName === "02-chat-connected",
);
expect(
retryAttempts.map(
(entry: { attempt: number; captureOutcome: string; artifactPath: string | null }) => ({
attempt: entry.attempt,
captureOutcome: entry.captureOutcome,
artifactPath: entry.artifactPath,
}),
),
).toEqual([
{ attempt: 1, captureOutcome: "failed", artifactPath: null },
{
attempt: 2,
captureOutcome: "succeeded",
artifactPath: "xcresults/iPhone 17 Pro Max-02-chat-connected-attempt-2.xcresult",
},
]);
expect(
fs.existsSync(
path.join(
output,
"apps/ios/build/SnapshotTestResults",
"iPhone 17 Pro Max-02-chat-connected-attempt-1.xcresult",
),
),
).toBe(false);
expect(
fs.existsSync(
path.join(
output,
"apps/ios/build/SnapshotTestResults",
"iPhone 17 Pro Max-02-chat-connected-attempt-2.xcresult",
),
),
).toBe(true);
});
it("requires the successful final invocation to have a passing xcresult", () => {
const root = tempDirs.make("ios-screenshot-missing-final-xcresult-");
const source = writeFamilySource(root, "iphone", { retry: "02-chat-connected" });
fs.rmSync(
path.join(source.xcresults, `${source.device}-02-chat-connected-attempt-2.xcresult`),
{ recursive: true },
);
expect(() =>
collectIosScreenshotEvidence({
family: "iphone",
screenshotDirectory: source.screenshots,
xcresultDirectory: source.xcresults,
outputDirectory: path.join(root, "collected"),
provenance: provenance(),
readXcresultSummary: () => ({ testResult: "Passed", failedTests: 0 }),
}),
).toThrow("is missing for the successful final capture attempt");
});
it.each([
{
label: "extra",
mutate: (input: string) =>
fs.cpSync(
path.join(input, containerName("ipad-13")),
path.join(input, `ios-release-screenshot-shard-extra-${TARGET_SHA}`),
{ recursive: true },
),
},
{
label: "renamed",
mutate: (input: string) =>
fs.renameSync(
path.join(input, containerName("iphone")),
path.join(input, `ios-release-screenshot-shard-renamed-${TARGET_SHA}`),
),
},
{
label: "missing",
mutate: (input: string) =>
fs.rmSync(path.join(input, containerName("ipad-13")), { recursive: true }),
},
{
label: "swapped",
mutate: (input: string) => {
const iphone = path.join(input, containerName("iphone"));
const ipad = path.join(input, containerName("ipad-13"));
const temporary = path.join(input, "temporary-container");
fs.renameSync(iphone, temporary);
fs.renameSync(ipad, iphone);
fs.renameSync(temporary, ipad);
},
},
])("rejects $label artifact container topology", ({ mutate }) => {
const root = tempDirs.make("ios-screenshot-topology-");
const input = collectAll(root);
mutate(input);
expect(() => reduceAll(input, path.join(root, "reduced"))).toThrow(/topology mismatch/u);
});
it("rejects cross-SHA shard evidence", () => {
const root = tempDirs.make("ios-screenshot-cross-sha-");
const input = collectAll(root);
updateManifest(input, "ipad-13", (manifest) => {
manifest.targetSha = "c".repeat(40);
});
expect(() => reduceAll(input, path.join(root, "reduced"))).toThrow(
"cross-SHA screenshot evidence",
);
});
it.each([
{
label: "workflow SHA",
mutate: (manifest: Record<string, any>) => {
manifest.workflowSha = "c".repeat(40);
},
error: "workflow SHA",
},
{
label: "run id",
mutate: (manifest: Record<string, any>) => {
manifest.runId = "99999";
},
error: "workflow run id",
},
{
label: "run attempt",
mutate: (manifest: Record<string, any>) => {
manifest.runAttempt = 3;
},
error: "workflow run attempt",
},
{
label: "Xcode version",
mutate: (manifest: Record<string, any>) => {
manifest.tooling.xcode = "Xcode 26.6 Build version forged";
},
error: "xcode version",
},
{
label: "Fastlane version",
mutate: (manifest: Record<string, any>) => {
manifest.tooling.fastlane = "2.236.0";
},
error: "fastlane version",
},
{
label: "Node version",
mutate: (manifest: Record<string, any>) => {
manifest.tooling.node = "v24.0.0";
},
error: "node version",
},
])("rejects self-consistent forged $label", ({ mutate, error }) => {
const root = tempDirs.make("ios-screenshot-forged-provenance-");
const input = collectAll(root);
updateAllManifests(input, mutate);
expect(() => reduceAll(input, path.join(root, "reduced"))).toThrow(error);
});
it.each([
{
label: "missing",
mutate: (manifest: Record<string, any>) => {
delete manifest.attemptModel;
},
},
{
label: "owner",
mutate: (manifest: Record<string, any>) => {
manifest.attemptModel.owner = "fastlane";
},
},
{
label: "unit",
mutate: (manifest: Record<string, any>) => {
manifest.attemptModel.unit = "launch retry";
},
},
{
label: "maximum",
mutate: (manifest: Record<string, any>) => {
manifest.attemptModel.maxAttempts = 3;
},
},
{
label: "Fastlane retry ownership",
mutate: (manifest: Record<string, any>) => {
manifest.attemptModel.fastlaneInternalRetries = "xcresult";
},
},
])("rejects $label attempt model changes", ({ mutate }) => {
const root = tempDirs.make("ios-screenshot-attempt-model-");
const input = collectAll(root);
updateManifest(input, "iphone", mutate);
expect(() => reduceAll(input, path.join(root, "reduced"))).toThrow("unexpected attempt model");
});
it("rejects changed PNG bytes after collection", () => {
const root = tempDirs.make("ios-screenshot-digest-");
const input = collectAll(root);
const screenshot = path.join(
familyDirectory(input, "watch"),
"screenshots",
"Apple Watch Ultra 3 (49mm)-01-now-face.png",
);
fs.appendFileSync(screenshot, "changed");
expect(() => reduceAll(input, path.join(root, "reduced"))).toThrow(
"screenshot digest mismatch",
);
});
it("rejects changed xcresult bytes after collection", () => {
const root = tempDirs.make("ios-screenshot-xcresult-digest-");
const input = collectAll(root);
fs.appendFileSync(
path.join(
familyDirectory(input, "iphone"),
"xcresults",
"iPhone 17 Pro Max-01-control-connected-attempt-1.xcresult",
"summary.txt",
),
"changed",
);
expect(() => reduceAll(input, path.join(root, "reduced"))).toThrow("xcresult digest mismatch");
});
it("rejects a non-passing final capture attempt", () => {
const root = tempDirs.make("ios-screenshot-final-failure-");
const input = collectAll(root);
updateManifest(input, "ipad-13", (manifest) => {
const final = manifest.captureAttempts.find(
(entry: { screenshotName: string }) => entry.screenshotName === "01-control-connected",
);
final.testResult = "Failed";
final.failedTests = 1;
});
expect(() => reduceAll(input, path.join(root, "reduced"))).toThrow(
"final xcresult is not passing",
);
});
it("rejects a successful capture predecessor before attempt two", () => {
const root = tempDirs.make("ios-screenshot-predecessor-");
const input = collectAll(root);
updateManifest(input, "iphone", (manifest) => {
const predecessor = manifest.captureAttempts.find(
(entry: { attempt: number; screenshotName: string }) =>
entry.screenshotName === "02-chat-connected" && entry.attempt === 1,
);
predecessor.captureOutcome = "succeeded";
});
expect(() => reduceAll(input, path.join(root, "reduced"))).toThrow(
"unexpected capture outcome sequence",
);
});
it("rejects an invalid PNG signature before collection", () => {
const root = tempDirs.make("ios-screenshot-signature-");
const source = writeFamilySource(root, "watch");
fs.writeFileSync(
path.join(source.screenshots, `${source.device}-01-now-face.png`),
"not a png",
);
expect(() =>
collectIosScreenshotEvidence({
family: "watch",
screenshotDirectory: source.screenshots,
xcresultDirectory: source.xcresults,
outputDirectory: path.join(root, "collected"),
provenance: provenance(),
}),
).toThrow("invalid PNG signature");
});
it("rejects unexpected screenshots in a device shard", () => {
const root = tempDirs.make("ios-screenshot-unexpected-");
const source = writeFamilySource(root, "iphone");
fs.writeFileSync(path.join(source.screenshots, `${source.device}-99-unexpected.png`), PNG);
expect(() =>
collectIosScreenshotEvidence({
family: "iphone",
screenshotDirectory: source.screenshots,
xcresultDirectory: source.xcresults,
outputDirectory: path.join(root, "collected"),
provenance: provenance(),
readXcresultSummary: () => ({ testResult: "Passed", failedTests: 0 }),
}),
).toThrow("PNG union mismatch");
});
it("rejects unexpected capture attempt entries in a shard manifest", () => {
const root = tempDirs.make("ios-screenshot-unexpected-xcresult-");
const input = collectAll(root);
updateManifest(input, "iphone", (manifest) => {
manifest.captureAttempts.push({
...manifest.captureAttempts[0],
screenshotName: "99-unexpected",
});
});
expect(() => reduceAll(input, path.join(root, "reduced"))).toThrow(
"capture attempt union contains an unexpected screenshot",
);
});
it("rejects unexpected files in a collected shard", () => {
const root = tempDirs.make("ios-screenshot-unexpected-file-");
const input = collectAll(root);
fs.writeFileSync(path.join(familyDirectory(input, "watch"), "unexpected.txt"), "unexpected");
expect(() => reduceAll(input, path.join(root, "reduced"))).toThrow(
"shard contains unexpected evidence",
);
});
it("rejects artifact paths outside the declared family directory", () => {
const root = tempDirs.make("ios-screenshot-artifact-path-");
const input = collectAll(root);
updateManifest(input, "watch", (manifest) => {
manifest.screenshots[0].artifactPath = "../iphone/manifest.json";
});
expect(() => reduceAll(input, path.join(root, "reduced"))).toThrow(
"unexpected screenshot artifact path",
);
});
});