From 3dbc7ac62f97646504222447d8add2144e971ebd Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Mon, 8 Jun 2026 14:44:40 +0100 Subject: [PATCH 1/9] ci(snapshots): Add selective iOS snapshot upload workflow Add a GitHub Actions workflow that builds, generates, and uploads iOS snapshot images to Sentry. The workflow maps changed paths to the relevant snapshot tests so pull requests only regenerate the snapshots affected by their changes, while pushes to main and unmatched changes fall back to the full test set. Build products are produced once via build-for-testing and shared across the snapshot generation and image-name jobs to avoid redundant builds. The aggregated images and image-name manifest are then uploaded with sentry-cli. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../workflows/ios_sentry_upload_snapshots.yml | 318 ++++++++++++++++++ 1 file changed, 318 insertions(+) create mode 100644 .github/workflows/ios_sentry_upload_snapshots.yml diff --git a/.github/workflows/ios_sentry_upload_snapshots.yml b/.github/workflows/ios_sentry_upload_snapshots.yml new file mode 100644 index 0000000..bd801ce --- /dev/null +++ b/.github/workflows/ios_sentry_upload_snapshots.yml @@ -0,0 +1,318 @@ +name: Upload iOS snapshots to Sentry + +on: + push: + branches: [main] + pull_request: + branches: [main] + paths: + - Examples/MultiModuleDemo/** + - Sources/** + - Package.swift + - .github/workflows/ios_sentry_upload_snapshots.yml + +env: + DERIVED_DATA_PATH: ${{ github.workspace }}/DerivedData-snapshot-upload + SNAPSHOT_UPLOAD_BASE_DIR: ${{ github.workspace }}/snapshot-images + BUILD_PRODUCTS_ARCHIVE: ${{ github.workspace }}/snapshot-build-products.tar.gz + ALL_IMAGE_NAMES_FILE: ${{ github.workspace }}/snapshot-images/all-image-names.txt + XCTESTRUN_FILENAME: MultiModuleDemoTests.xctestrun + SNAPSHOT_SCHEME: MultiModuleDemo + SNAPSHOT_PROJECT: Examples/MultiModuleDemo/MultiModuleDemo.xcodeproj + SNAPSHOT_DESTINATION: platform=iOS Simulator,name=iPhone 17 Pro Max,arch=arm64 + SENTRY_APP_ID: ${{ secrets.SENTRY_APP_ID }} + +jobs: + select_snapshot_tests: + runs-on: ubuntu-latest + outputs: + selected_tests: ${{ steps.select.outputs.selected_tests }} + selected_tests_json: ${{ steps.select.outputs.selected_tests_json }} + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Select snapshot tests + id: select + shell: bash + run: | + set -euo pipefail + + declare -a tests=() + + add_test() { + local test="$1" + for existing in "${tests[@]}"; do + if [ "${existing}" = "${test}" ]; then + return + fi + done + tests+=("${test}") + } + + if [ "${{ github.event_name }}" = "pull_request" ]; then + BASE_SHA="${{ github.event.pull_request.base.sha }}" + HEAD_SHA="${{ github.event.pull_request.head.sha }}" + changed_files="$(git diff --name-only "${BASE_SHA}" "${HEAD_SHA}")" + else + changed_files="" + fi + + if [ "${{ github.event_name }}" != "pull_request" ] || [ -z "${changed_files}" ]; then + add_test "MultiModuleDemoTests/MultiModuleDemoSnapshotSingleModuleAllowTests" + add_test "MultiModuleDemoTests/MultiModuleDemoSnapshotMultipleModuleAllowTests" + else + while IFS= read -r file; do + case "${file}" in + Examples/MultiModuleDemo/ModuleA/*) + add_test "MultiModuleDemoTests/MultiModuleDemoSnapshotSingleModuleAllowTests" + ;; + Examples/MultiModuleDemo/ModuleB/*|Examples/MultiModuleDemo/ModuleC/*) + add_test "MultiModuleDemoTests/MultiModuleDemoSnapshotMultipleModuleAllowTests" + ;; + Examples/MultiModuleDemo/*|Sources/*|Package.swift) + add_test "MultiModuleDemoTests/MultiModuleDemoSnapshotSingleModuleAllowTests" + add_test "MultiModuleDemoTests/MultiModuleDemoSnapshotMultipleModuleAllowTests" + ;; + esac + done <<< "${changed_files}" + fi + + if [ "${#tests[@]}" -eq 0 ]; then + add_test "MultiModuleDemoTests/MultiModuleDemoSnapshotSingleModuleAllowTests" + add_test "MultiModuleDemoTests/MultiModuleDemoSnapshotMultipleModuleAllowTests" + fi + + selected_tests="$(IFS=,; echo "${tests[*]}")" + selected_tests_json="$(printf '%s\n' "${tests[@]}" | jq -R . | jq -cs .)" + + echo "selected_tests=${selected_tests}" >> "${GITHUB_OUTPUT}" + echo "selected_tests_json=${selected_tests_json}" >> "${GITHUB_OUTPUT}" + printf 'Selected snapshot tests:\n%s\n' "${tests[@]}" + + build_for_testing: + runs-on: macos-26 + needs: select_snapshot_tests + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Select Xcode + run: sudo xcode-select -s /Applications/Xcode_26.4.app + + - name: Prepare DerivedData directory + run: mkdir -p "${DERIVED_DATA_PATH}" + + - name: Cache Swift Package Manager + uses: actions/cache@v4 + with: + path: | + ~/Library/Caches/org.swift.swiftpm + ${{ env.DERIVED_DATA_PATH }}/SourcePackages + key: ${{ runner.os }}-spm-${{ hashFiles('**/Package.resolved') }} + restore-keys: ${{ runner.os }}-spm- + + - name: Build snapshot tests + run: | + xcodebuild build-for-testing \ + -project "${SNAPSHOT_PROJECT}" \ + -scheme "${SNAPSHOT_SCHEME}" \ + -sdk iphonesimulator \ + -destination "${SNAPSHOT_DESTINATION}" \ + -resultBundlePath SnapshotBuildForTesting.xcresult \ + -derivedDataPath "${DERIVED_DATA_PATH}" \ + -skipPackagePluginValidation \ + ONLY_ACTIVE_ARCH=YES \ + CODE_SIGNING_ALLOWED=NO + + - name: Normalize xctestrun path + run: | + XCTESTRUN_SOURCE="$(find "${DERIVED_DATA_PATH}/Build/Products" -name '*.xctestrun' -print -quit)" + if [ -z "${XCTESTRUN_SOURCE}" ]; then + echo "No .xctestrun file found under ${DERIVED_DATA_PATH}/Build/Products" >&2 + exit 1 + fi + + cp "${XCTESTRUN_SOURCE}" "${DERIVED_DATA_PATH}/Build/Products/${XCTESTRUN_FILENAME}" + + - name: Archive test products + run: tar -C "${DERIVED_DATA_PATH}/Build" -czf "${BUILD_PRODUCTS_ARCHIVE}" Products + + - name: Upload build products artifact + uses: actions/upload-artifact@v7 + with: + name: snapshot-build-products + path: ${{ env.BUILD_PRODUCTS_ARCHIVE }} + if-no-files-found: error + + generate_snapshots: + runs-on: macos-26 + needs: [select_snapshot_tests, build_for_testing] + + strategy: + fail-fast: false + matrix: + include: + - simulator_name: iPhone 17 Pro Max + slug: iphone-17-pro-max + + env: + TEST_RUNNER_SNAPSHOTS_EXPORT_DIR: ${{ github.workspace }}/snapshot-images/${{ matrix.slug }} + SELECTED_SNAPSHOT_TESTS: ${{ needs.select_snapshot_tests.outputs.selected_tests }} + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Select Xcode + run: sudo xcode-select -s /Applications/Xcode_26.4.app + + - name: Download build products artifact + uses: actions/download-artifact@v5 + with: + name: snapshot-build-products + path: ${{ github.workspace }} + + - name: Extract test products + run: | + mkdir -p "${DERIVED_DATA_PATH}/Build" + tar -C "${DERIVED_DATA_PATH}/Build" -xzf "${BUILD_PRODUCTS_ARCHIVE}" + + - name: Boot simulator + run: xcrun simctl boot "${{ matrix.simulator_name }}" || true + + - name: Prepare snapshot export directory + run: mkdir -p "${TEST_RUNNER_SNAPSHOTS_EXPORT_DIR}" + + - name: Generate snapshot images + run: | + set -euo pipefail + only_testing_args=() + IFS=',' read -ra tests <<< "${SELECTED_SNAPSHOT_TESTS}" + for test in "${tests[@]}"; do + only_testing_args+=("-only-testing:${test}") + done + + xcodebuild test-without-building \ + -xctestrun "${DERIVED_DATA_PATH}/Build/Products/${XCTESTRUN_FILENAME}" \ + -destination "platform=iOS Simulator,name=${{ matrix.simulator_name }},arch=arm64" \ + "${only_testing_args[@]}" \ + -resultBundlePath "SnapshotResults-${{ matrix.slug }}.xcresult" + + - name: Upload snapshots artifact + uses: actions/upload-artifact@v7 + with: + name: snapshots-${{ matrix.slug }} + path: ${{ env.TEST_RUNNER_SNAPSHOTS_EXPORT_DIR }} + if-no-files-found: error + + generate_image_names: + runs-on: macos-26 + needs: build_for_testing + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Select Xcode + run: sudo xcode-select -s /Applications/Xcode_26.4.app + + - name: Download build products artifact + uses: actions/download-artifact@v5 + with: + name: snapshot-build-products + path: ${{ github.workspace }} + + - name: Extract test products + run: | + mkdir -p "${DERIVED_DATA_PATH}/Build" + tar -C "${DERIVED_DATA_PATH}/Build" -xzf "${BUILD_PRODUCTS_ARCHIVE}" + + - name: Boot simulator + run: xcrun simctl boot "iPhone 17 Pro Max" || true + + - name: Generate all image names + run: | + set -euo pipefail + mkdir -p "$(dirname "${ALL_IMAGE_NAMES_FILE}")" + name_files=() + tests=( + "MultiModuleDemoTests/MultiModuleDemoSnapshotSingleModuleAllowTests" + "MultiModuleDemoTests/MultiModuleDemoSnapshotMultipleModuleAllowTests" + ) + + for index in "${!tests[@]}"; do + name_file="${RUNNER_TEMP}/snapshot-names-${index}.txt" + name_files+=("${name_file}") + + TEST_RUNNER_SNAPSHOTS_ALL_IMAGE_NAMES_FILE="${name_file}" \ + xcodebuild test-without-building \ + -xctestrun "${DERIVED_DATA_PATH}/Build/Products/${XCTESTRUN_FILENAME}" \ + -destination "${SNAPSHOT_DESTINATION}" \ + -only-testing:"${tests[$index]}" \ + -resultBundlePath "SnapshotImageNames-${index}.xcresult" + done + + cat "${name_files[@]}" | sort -u > "${ALL_IMAGE_NAMES_FILE}" + cat "${ALL_IMAGE_NAMES_FILE}" + + - name: Upload image names artifact + uses: actions/upload-artifact@v7 + with: + name: snapshot-image-names + path: ${{ env.ALL_IMAGE_NAMES_FILE }} + if-no-files-found: error + + upload_snapshots: + runs-on: macos-26 + needs: [generate_snapshots, generate_image_names] + + steps: + - name: Download generated snapshots + uses: actions/download-artifact@v5 + with: + path: ${{ env.SNAPSHOT_UPLOAD_BASE_DIR }} + pattern: snapshots-* + + - name: Download image names + uses: actions/download-artifact@v5 + with: + name: snapshot-image-names + path: ${{ github.workspace }}/snapshot-image-names + + - name: List aggregated snapshot files + run: | + echo "Generated snapshot files:" + find "${SNAPSHOT_UPLOAD_BASE_DIR}" -type f | sort + echo + echo "All image names:" + cat "${{ github.workspace }}/snapshot-image-names/all-image-names.txt" + echo + echo "Total PNG images: $(find "${SNAPSHOT_UPLOAD_BASE_DIR}" -type f -name '*.png' | wc -l | tr -d ' ')" + + - name: Install sentry-cli + run: curl -sL https://sentry.io/get-cli/ | bash + + - name: Upload snapshots to Sentry + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + run: | + if [ -z "${SENTRY_AUTH_TOKEN}" ] || [ -z "${SENTRY_APP_ID}" ]; then + echo "SENTRY_AUTH_TOKEN and SENTRY_APP_ID secrets are required" >&2 + exit 1 + fi + + sentry-cli build snapshots "${SNAPSHOT_UPLOAD_BASE_DIR}" \ + --auth-token "${SENTRY_AUTH_TOKEN}" \ + --app-id "${SENTRY_APP_ID}" \ + --all-image-file-names-file "${{ github.workspace }}/snapshot-image-names/all-image-names.txt" From 792838f2cecd4f32641dbbbccbacff9f54ff63d6 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Mon, 8 Jun 2026 15:01:12 +0100 Subject: [PATCH 2/9] ci(snapshots): Configure Sentry upload workflow permissions Restrict the workflow token to read-only repository access and pass the Sentry project secret required by snapshot uploads. Co-Authored-By: Codex --- .github/workflows/ios_sentry_upload_snapshots.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ios_sentry_upload_snapshots.yml b/.github/workflows/ios_sentry_upload_snapshots.yml index bd801ce..377bf42 100644 --- a/.github/workflows/ios_sentry_upload_snapshots.yml +++ b/.github/workflows/ios_sentry_upload_snapshots.yml @@ -11,6 +11,9 @@ on: - Package.swift - .github/workflows/ios_sentry_upload_snapshots.yml +permissions: + contents: read + env: DERIVED_DATA_PATH: ${{ github.workspace }}/DerivedData-snapshot-upload SNAPSHOT_UPLOAD_BASE_DIR: ${{ github.workspace }}/snapshot-images @@ -21,6 +24,7 @@ env: SNAPSHOT_PROJECT: Examples/MultiModuleDemo/MultiModuleDemo.xcodeproj SNAPSHOT_DESTINATION: platform=iOS Simulator,name=iPhone 17 Pro Max,arch=arm64 SENTRY_APP_ID: ${{ secrets.SENTRY_APP_ID }} + SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} jobs: select_snapshot_tests: @@ -307,12 +311,13 @@ jobs: env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} run: | - if [ -z "${SENTRY_AUTH_TOKEN}" ] || [ -z "${SENTRY_APP_ID}" ]; then - echo "SENTRY_AUTH_TOKEN and SENTRY_APP_ID secrets are required" >&2 + if [ -z "${SENTRY_AUTH_TOKEN}" ] || [ -z "${SENTRY_APP_ID}" ] || [ -z "${SENTRY_PROJECT}" ]; then + echo "SENTRY_AUTH_TOKEN, SENTRY_APP_ID, and SENTRY_PROJECT secrets are required" >&2 exit 1 fi sentry-cli build snapshots "${SNAPSHOT_UPLOAD_BASE_DIR}" \ --auth-token "${SENTRY_AUTH_TOKEN}" \ + --project "${SENTRY_PROJECT}" \ --app-id "${SENTRY_APP_ID}" \ --all-image-file-names-file "${{ github.workspace }}/snapshot-image-names/all-image-names.txt" From a4142221a4c5d7f4d5dc6041e80d72bf383684ce Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Mon, 8 Jun 2026 15:13:41 +0100 Subject: [PATCH 3/9] ci(snapshots): Preserve git metadata during upload Check out the repository in the snapshot upload job so sentry-cli can detect git metadata automatically when uploading snapshots. Co-Authored-By: Codex --- .github/workflows/ios_sentry_upload_snapshots.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/ios_sentry_upload_snapshots.yml b/.github/workflows/ios_sentry_upload_snapshots.yml index 377bf42..7ff9b00 100644 --- a/.github/workflows/ios_sentry_upload_snapshots.yml +++ b/.github/workflows/ios_sentry_upload_snapshots.yml @@ -282,6 +282,11 @@ jobs: needs: [generate_snapshots, generate_image_names] steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + - name: Download generated snapshots uses: actions/download-artifact@v5 with: From 1dddfd27846682dda01a96cb2675972e9985901f Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Mon, 8 Jun 2026 15:50:19 +0100 Subject: [PATCH 4/9] ci(snapshots): Run selected snapshot tests in parallel Use the PR merge-base diff when selecting snapshot test classes. Fan out snapshot generation by selected test class so each class runs in its own GitHub Actions matrix job. This avoids shared dynamic preview state between test classes while keeping the test-without-building runs parallel in CI. Co-Authored-By: OpenAI Codex --- .../workflows/ios_sentry_upload_snapshots.yml | 29 +++++++------------ 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ios_sentry_upload_snapshots.yml b/.github/workflows/ios_sentry_upload_snapshots.yml index 7ff9b00..e4e147e 100644 --- a/.github/workflows/ios_sentry_upload_snapshots.yml +++ b/.github/workflows/ios_sentry_upload_snapshots.yml @@ -60,7 +60,7 @@ jobs: if [ "${{ github.event_name }}" = "pull_request" ]; then BASE_SHA="${{ github.event.pull_request.base.sha }}" HEAD_SHA="${{ github.event.pull_request.head.sha }}" - changed_files="$(git diff --name-only "${BASE_SHA}" "${HEAD_SHA}")" + changed_files="$(git diff --name-only "${BASE_SHA}...${HEAD_SHA}")" else changed_files="" fi @@ -91,7 +91,7 @@ jobs: fi selected_tests="$(IFS=,; echo "${tests[*]}")" - selected_tests_json="$(printf '%s\n' "${tests[@]}" | jq -R . | jq -cs .)" + selected_tests_json="$(printf '%s\n' "${tests[@]}" | jq -R . | jq -cs 'map({name: ., slug: (split("/")[-1] | gsub("[^A-Za-z0-9_-]"; "-"))})')" echo "selected_tests=${selected_tests}" >> "${GITHUB_OUTPUT}" echo "selected_tests_json=${selected_tests_json}" >> "${GITHUB_OUTPUT}" @@ -162,13 +162,13 @@ jobs: strategy: fail-fast: false matrix: - include: - - simulator_name: iPhone 17 Pro Max + snapshot_test: ${{ fromJSON(needs.select_snapshot_tests.outputs.selected_tests_json) }} + simulator: + - name: iPhone 17 Pro Max slug: iphone-17-pro-max env: - TEST_RUNNER_SNAPSHOTS_EXPORT_DIR: ${{ github.workspace }}/snapshot-images/${{ matrix.slug }} - SELECTED_SNAPSHOT_TESTS: ${{ needs.select_snapshot_tests.outputs.selected_tests }} + TEST_RUNNER_SNAPSHOTS_EXPORT_DIR: ${{ github.workspace }}/snapshot-images/${{ matrix.simulator.slug }}/${{ matrix.snapshot_test.slug }} steps: - name: Checkout @@ -191,30 +191,23 @@ jobs: tar -C "${DERIVED_DATA_PATH}/Build" -xzf "${BUILD_PRODUCTS_ARCHIVE}" - name: Boot simulator - run: xcrun simctl boot "${{ matrix.simulator_name }}" || true + run: xcrun simctl boot "${{ matrix.simulator.name }}" || true - name: Prepare snapshot export directory run: mkdir -p "${TEST_RUNNER_SNAPSHOTS_EXPORT_DIR}" - name: Generate snapshot images run: | - set -euo pipefail - only_testing_args=() - IFS=',' read -ra tests <<< "${SELECTED_SNAPSHOT_TESTS}" - for test in "${tests[@]}"; do - only_testing_args+=("-only-testing:${test}") - done - xcodebuild test-without-building \ -xctestrun "${DERIVED_DATA_PATH}/Build/Products/${XCTESTRUN_FILENAME}" \ - -destination "platform=iOS Simulator,name=${{ matrix.simulator_name }},arch=arm64" \ - "${only_testing_args[@]}" \ - -resultBundlePath "SnapshotResults-${{ matrix.slug }}.xcresult" + -destination "platform=iOS Simulator,name=${{ matrix.simulator.name }},arch=arm64" \ + -only-testing:"${{ matrix.snapshot_test.name }}" \ + -resultBundlePath "SnapshotResults-${{ matrix.simulator.slug }}-${{ matrix.snapshot_test.slug }}.xcresult" - name: Upload snapshots artifact uses: actions/upload-artifact@v7 with: - name: snapshots-${{ matrix.slug }} + name: snapshots-${{ matrix.simulator.slug }}-${{ matrix.snapshot_test.slug }} path: ${{ env.TEST_RUNNER_SNAPSHOTS_EXPORT_DIR }} if-no-files-found: error From 473a3ab77ff12e083355d9ed48e2124d12ffc4f2 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Mon, 8 Jun 2026 15:59:21 +0100 Subject: [PATCH 5/9] ci(snapshots): Flatten generated snapshot artifacts Merge snapshot matrix artifacts into the upload root so uploaded image paths match the generated all-image-name file. Co-Authored-By: OpenAI Codex --- .github/workflows/ios_sentry_upload_snapshots.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ios_sentry_upload_snapshots.yml b/.github/workflows/ios_sentry_upload_snapshots.yml index e4e147e..817a0c5 100644 --- a/.github/workflows/ios_sentry_upload_snapshots.yml +++ b/.github/workflows/ios_sentry_upload_snapshots.yml @@ -285,6 +285,7 @@ jobs: with: path: ${{ env.SNAPSHOT_UPLOAD_BASE_DIR }} pattern: snapshots-* + merge-multiple: true - name: Download image names uses: actions/download-artifact@v5 From 1d487d19994c2fd04e8a93a2d7908f7577e50505 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Mon, 8 Jun 2026 16:17:04 +0100 Subject: [PATCH 6/9] ci(snapshots): Parallelize image name generation Run each full-coverage image-name test class in its own matrix job so it can execute alongside selected snapshot generation. Aggregate the per-class image-name artifacts before uploading to Sentry so the upload still receives one complete image-name file. Co-Authored-By: OpenAI Codex --- .../workflows/ios_sentry_upload_snapshots.yml | 65 +++++++++++-------- 1 file changed, 39 insertions(+), 26 deletions(-) diff --git a/.github/workflows/ios_sentry_upload_snapshots.yml b/.github/workflows/ios_sentry_upload_snapshots.yml index 817a0c5..76f8680 100644 --- a/.github/workflows/ios_sentry_upload_snapshots.yml +++ b/.github/workflows/ios_sentry_upload_snapshots.yml @@ -215,6 +215,18 @@ jobs: runs-on: macos-26 needs: build_for_testing + strategy: + fail-fast: false + matrix: + snapshot_test: + - name: MultiModuleDemoTests/MultiModuleDemoSnapshotSingleModuleAllowTests + slug: MultiModuleDemoSnapshotSingleModuleAllowTests + - name: MultiModuleDemoTests/MultiModuleDemoSnapshotMultipleModuleAllowTests + slug: MultiModuleDemoSnapshotMultipleModuleAllowTests + + env: + IMAGE_NAMES_FILE: ${{ github.workspace }}/snapshot-image-names/all-image-names-${{ matrix.snapshot_test.slug }}.txt + steps: - name: Checkout uses: actions/checkout@v6 @@ -240,34 +252,22 @@ jobs: - name: Generate all image names run: | - set -euo pipefail - mkdir -p "$(dirname "${ALL_IMAGE_NAMES_FILE}")" - name_files=() - tests=( - "MultiModuleDemoTests/MultiModuleDemoSnapshotSingleModuleAllowTests" - "MultiModuleDemoTests/MultiModuleDemoSnapshotMultipleModuleAllowTests" - ) - - for index in "${!tests[@]}"; do - name_file="${RUNNER_TEMP}/snapshot-names-${index}.txt" - name_files+=("${name_file}") - - TEST_RUNNER_SNAPSHOTS_ALL_IMAGE_NAMES_FILE="${name_file}" \ - xcodebuild test-without-building \ - -xctestrun "${DERIVED_DATA_PATH}/Build/Products/${XCTESTRUN_FILENAME}" \ - -destination "${SNAPSHOT_DESTINATION}" \ - -only-testing:"${tests[$index]}" \ - -resultBundlePath "SnapshotImageNames-${index}.xcresult" - done - - cat "${name_files[@]}" | sort -u > "${ALL_IMAGE_NAMES_FILE}" - cat "${ALL_IMAGE_NAMES_FILE}" + mkdir -p "$(dirname "${IMAGE_NAMES_FILE}")" + + TEST_RUNNER_SNAPSHOTS_ALL_IMAGE_NAMES_FILE="${IMAGE_NAMES_FILE}" \ + xcodebuild test-without-building \ + -xctestrun "${DERIVED_DATA_PATH}/Build/Products/${XCTESTRUN_FILENAME}" \ + -destination "${SNAPSHOT_DESTINATION}" \ + -only-testing:"${{ matrix.snapshot_test.name }}" \ + -resultBundlePath "SnapshotImageNames-${{ matrix.snapshot_test.slug }}.xcresult" + + cat "${IMAGE_NAMES_FILE}" - name: Upload image names artifact uses: actions/upload-artifact@v7 with: - name: snapshot-image-names - path: ${{ env.ALL_IMAGE_NAMES_FILE }} + name: snapshot-image-names-${{ matrix.snapshot_test.slug }} + path: ${{ env.IMAGE_NAMES_FILE }} if-no-files-found: error upload_snapshots: @@ -290,8 +290,21 @@ jobs: - name: Download image names uses: actions/download-artifact@v5 with: - name: snapshot-image-names path: ${{ github.workspace }}/snapshot-image-names + pattern: snapshot-image-names-* + merge-multiple: true + + - name: Aggregate image names + run: | + set -euo pipefail + mkdir -p "$(dirname "${ALL_IMAGE_NAMES_FILE}")" + mapfile -t image_name_files < <(find "${{ github.workspace }}/snapshot-image-names" -type f -name '*.txt' | sort) + if [ "${#image_name_files[@]}" -eq 0 ]; then + echo "No image name files found" >&2 + exit 1 + fi + + cat "${image_name_files[@]}" | sort -u > "${ALL_IMAGE_NAMES_FILE}" - name: List aggregated snapshot files run: | @@ -299,7 +312,7 @@ jobs: find "${SNAPSHOT_UPLOAD_BASE_DIR}" -type f | sort echo echo "All image names:" - cat "${{ github.workspace }}/snapshot-image-names/all-image-names.txt" + cat "${ALL_IMAGE_NAMES_FILE}" echo echo "Total PNG images: $(find "${SNAPSHOT_UPLOAD_BASE_DIR}" -type f -name '*.png' | wc -l | tr -d ' ')" From ab1b6e11cb1f7fa7610a2a776395b8396b018477 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Mon, 8 Jun 2026 16:47:09 +0100 Subject: [PATCH 7/9] ci(snapshots): Use aggregated image name manifest Pass the merged all-image-name manifest produced by the upload job to sentry-cli. This keeps the parallel image-name jobs while ensuring Sentry receives the manifest path that the aggregation step actually writes. Co-Authored-By: OpenAI Codex --- .github/workflows/ios_sentry_upload_snapshots.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ios_sentry_upload_snapshots.yml b/.github/workflows/ios_sentry_upload_snapshots.yml index 76f8680..819f2ed 100644 --- a/.github/workflows/ios_sentry_upload_snapshots.yml +++ b/.github/workflows/ios_sentry_upload_snapshots.yml @@ -332,4 +332,4 @@ jobs: --auth-token "${SENTRY_AUTH_TOKEN}" \ --project "${SENTRY_PROJECT}" \ --app-id "${SENTRY_APP_ID}" \ - --all-image-file-names-file "${{ github.workspace }}/snapshot-image-names/all-image-names.txt" + --all-image-file-names-file "${ALL_IMAGE_NAMES_FILE}" From 1f0771a1759bd7f6f514eeaf345042ba77495a70 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Mon, 8 Jun 2026 16:52:56 +0100 Subject: [PATCH 8/9] ci(snapshots): Support macOS bash in manifest aggregation Replace mapfile with a Bash 3.2-compatible read loop when collecting per-shard image-name manifests. macOS runners use Bash 3.2 by default, so mapfile is not available in the upload job. Co-Authored-By: OpenAI Codex --- .github/workflows/ios_sentry_upload_snapshots.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ios_sentry_upload_snapshots.yml b/.github/workflows/ios_sentry_upload_snapshots.yml index 819f2ed..788f4a5 100644 --- a/.github/workflows/ios_sentry_upload_snapshots.yml +++ b/.github/workflows/ios_sentry_upload_snapshots.yml @@ -298,7 +298,11 @@ jobs: run: | set -euo pipefail mkdir -p "$(dirname "${ALL_IMAGE_NAMES_FILE}")" - mapfile -t image_name_files < <(find "${{ github.workspace }}/snapshot-image-names" -type f -name '*.txt' | sort) + image_name_files=() + while IFS= read -r file; do + image_name_files+=("${file}") + done < <(find "${{ github.workspace }}/snapshot-image-names" -type f -name '*.txt' | sort) + if [ "${#image_name_files[@]}" -eq 0 ]; then echo "No image name files found" >&2 exit 1 From fdacedef2a27432cb7b6605cd16778fe21072962 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Mon, 8 Jun 2026 18:05:55 +0100 Subject: [PATCH 9/9] docs(snapshots): Document selective snapshot workflow jobs Add explanatory comments to the snapshot upload workflow describing each job's role in the selective testing flow: how pull requests narrow the test set by changed paths, why image-name generation always runs the full suite, and how the aggregated manifest lets Sentry distinguish skipped snapshots from removed ones. No behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../workflows/ios_sentry_upload_snapshots.yml | 33 +++++++++++++++---- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ios_sentry_upload_snapshots.yml b/.github/workflows/ios_sentry_upload_snapshots.yml index 788f4a5..b2877fa 100644 --- a/.github/workflows/ios_sentry_upload_snapshots.yml +++ b/.github/workflows/ios_sentry_upload_snapshots.yml @@ -27,6 +27,10 @@ env: SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} jobs: + # Decide which snapshot test classes need to render images for this run. + # Pull requests use changed file paths to run only the snapshot test + # cases that cover the affected code. + # Pushes to main, empty diffs, and broad changes fall back to the full suite. select_snapshot_tests: runs-on: ubuntu-latest outputs: @@ -57,18 +61,14 @@ jobs: tests+=("${test}") } + # For pull requests, narrow the test set to the modules that changed. + # For any other event (or an empty diff), the fallback below selects + # every snapshot test. if [ "${{ github.event_name }}" = "pull_request" ]; then BASE_SHA="${{ github.event.pull_request.base.sha }}" HEAD_SHA="${{ github.event.pull_request.head.sha }}" changed_files="$(git diff --name-only "${BASE_SHA}...${HEAD_SHA}")" - else - changed_files="" - fi - if [ "${{ github.event_name }}" != "pull_request" ] || [ -z "${changed_files}" ]; then - add_test "MultiModuleDemoTests/MultiModuleDemoSnapshotSingleModuleAllowTests" - add_test "MultiModuleDemoTests/MultiModuleDemoSnapshotMultipleModuleAllowTests" - else while IFS= read -r file; do case "${file}" in Examples/MultiModuleDemo/ModuleA/*) @@ -85,6 +85,7 @@ jobs: done <<< "${changed_files}" fi + # Fallback: run the full suite when nothing module-specific was selected. if [ "${#tests[@]}" -eq 0 ]; then add_test "MultiModuleDemoTests/MultiModuleDemoSnapshotSingleModuleAllowTests" add_test "MultiModuleDemoTests/MultiModuleDemoSnapshotMultipleModuleAllowTests" @@ -97,6 +98,9 @@ jobs: echo "selected_tests_json=${selected_tests_json}" >> "${GITHUB_OUTPUT}" printf 'Selected snapshot tests:\n%s\n' "${tests[@]}" + # Compile the demo app and snapshot test bundle once, then archive the build + # products. Downstream jobs reuse this artifact with `test-without-building` + # so each shard does not repeat the expensive compile step. build_for_testing: runs-on: macos-26 needs: select_snapshot_tests @@ -155,6 +159,9 @@ jobs: path: ${{ env.BUILD_PRODUCTS_ARCHIVE }} if-no-files-found: error + # Render only the selected snapshot test classes. This is the selective part + # of the workflow: each chosen test class runs as an independent matrix job + # and exports PNG/JSON snapshot files for upload. generate_snapshots: runs-on: macos-26 needs: [select_snapshot_tests, build_for_testing] @@ -211,6 +218,9 @@ jobs: path: ${{ env.TEST_RUNNER_SNAPSHOTS_EXPORT_DIR }} if-no-files-found: error + # Produce the complete set of possible snapshot image names. This always runs + # all snapshot test classes, even when image rendering is selective, so Sentry + # can distinguish skipped snapshots from removed snapshots during comparison. generate_image_names: runs-on: macos-26 needs: build_for_testing @@ -270,6 +280,9 @@ jobs: path: ${{ env.IMAGE_NAMES_FILE }} if-no-files-found: error + # Collect rendered snapshot artifacts and per-shard image-name manifests, + # merge the manifests into one complete file, then upload the selective build + # to Sentry with enough context for snapshot comparison. upload_snapshots: runs-on: macos-26 needs: [generate_snapshots, generate_image_names] @@ -294,6 +307,9 @@ jobs: pattern: snapshot-image-names-* merge-multiple: true + # Combine the per-test-case image-name files into one sorted, deduplicated + # manifest. Sentry uses this complete manifest to know which snapshots + # exist even when this workflow only rendered a selected subset of tests. - name: Aggregate image names run: | set -euo pipefail @@ -323,6 +339,9 @@ jobs: - name: Install sentry-cli run: curl -sL https://sentry.io/get-cli/ | bash + # Upload the rendered snapshot files. The --all-image-file-names-file + # flag points Sentry at the full manifest, which lets Sentry distinguish + # snapshots skipped by selective testing from snapshots that were removed. - name: Upload snapshots to Sentry env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}