From 49e0b70f74b7b197f54b32c0a35f70e0a01748a3 Mon Sep 17 00:00:00 2001 From: tkgstrator <29420801+tkgstrator@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:55:30 +0900 Subject: [PATCH] ci: deploy tested develop builds to TestFlight --- .github/actionlint.yaml | 3 + .github/workflows/ios.yml | 87 +++++++++++++++++++++- .gitignore | 5 +- README.md | 57 +++++++++++++++ fastlane/Fastfile | 79 +++++++++++++++----- fastlane/lib/testflight_config.rb | 48 ++++++++++++ fastlane/test/testflight_config_test.rb | 60 +++++++++++++++ scripts/ci-testflight.sh | 76 +++++++++++++++++++ scripts/tests/test_ci_testflight.py | 97 +++++++++++++++++++++++++ 9 files changed, 488 insertions(+), 24 deletions(-) create mode 100644 .github/actionlint.yaml create mode 100644 fastlane/lib/testflight_config.rb create mode 100644 fastlane/test/testflight_config_test.rb create mode 100644 scripts/ci-testflight.sh create mode 100644 scripts/tests/test_ci_testflight.py diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 0000000..2179770 --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,3 @@ +self-hosted-runner: + labels: + - xcode-27 diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index 8eb8526..78d9dd0 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -2,19 +2,42 @@ name: iOS Simulator on: pull_request: - branches: [master] + branches: [master, develop] push: - branches: [master] + branches: [master, develop] workflow_dispatch: permissions: contents: read +env: + # Keep simulator verification and the signing build on the same package commit. + MUDMOUTH_REV: 3c1468aaaea5140835982bc8e38c9d3bfc49d2a0 + concurrency: - group: ios-${{ github.workflow }}-${{ github.ref }} + # Every develop push must reach the deployment queue. PR runs still supersede + # older tests, but separate develop pushes must not cancel one another. + group: ios-${{ github.workflow }}-${{ github.event_name == 'push' && github.ref == 'refs/heads/develop' && github.run_id || github.ref }} cancel-in-progress: true jobs: + release_checks: + name: Validate TestFlight automation + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.3' + - name: Validate release configuration and cleanup + run: | + ruby -c fastlane/Fastfile + ruby fastlane/test/testflight_config_test.rb + python3 -m unittest discover -s scripts/tests -v + bash -n scripts/ci-testflight.sh + simulator: name: Build and test (Xcode 27) runs-on: xcode-27 @@ -33,7 +56,7 @@ jobs: uses: actions/checkout@v4 with: repository: qtmleap/Mudmouth - ref: 3c1468aaaea5140835982bc8e38c9d3bfc49d2a0 + ref: ${{ env.MUDMOUTH_REV }} path: Mudmouth persist-credentials: false @@ -112,3 +135,59 @@ jobs: ${{ runner.temp }}/xcodebuild-sample-*.txt if-no-files-found: ignore retention-days: 7 + + testflight: + name: Deploy to TestFlight + needs: [simulator, release_checks] + if: github.event_name == 'push' && github.ref == 'refs/heads/develop' + runs-on: xcode-27 + timeout-minutes: 90 + environment: testflight + concurrency: + group: interceptor-testflight + cancel-in-progress: false + queue: max + defaults: + run: + working-directory: Interceptor + shell: bash + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + path: Interceptor + persist-credentials: false + - uses: actions/checkout@v4 + with: + repository: qtmleap/Mudmouth + ref: ${{ env.MUDMOUTH_REV }} + path: Mudmouth + persist-credentials: false + - name: Select Ruby on the self-hosted Mac + run: | + if ! brew list --versions ruby@3.3 >/dev/null 2>&1; then + HOMEBREW_NO_AUTO_UPDATE=1 brew install ruby@3.3 + fi + echo "$(brew --prefix ruby@3.3)/bin" >> "$GITHUB_PATH" + echo "GEM_HOME=$RUNNER_TEMP/testflight-gems" >> "$GITHUB_ENV" + echo "GEM_PATH=$RUNNER_TEMP/testflight-gems" >> "$GITHUB_ENV" + echo "$RUNNER_TEMP/testflight-gems/bin" >> "$GITHUB_PATH" + echo "BUNDLE_PATH=$RUNNER_TEMP/testflight-bundle" >> "$GITHUB_ENV" + echo "BUNDLE_FROZEN=true" >> "$GITHUB_ENV" + - name: Install locked fastlane dependencies + run: | + ruby --version + gem install bundler -v 2.6.9 --no-document + bundle _2.6.9_ install + - name: Archive and upload the tested commit + env: + QUANTUMLEAP_READ_TOKEN: ${{ secrets.QUANTUMLEAP_READ_TOKEN }} + APP_STORE_CONNECT_API_KEY_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_KEY_ID }} + APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }} + APP_STORE_CONNECT_API_KEY_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY_KEY }} + MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} + MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }} + run: bash scripts/ci-testflight.sh + - name: Remove per-job Ruby dependencies + if: always() + run: rm -rf "$RUNNER_TEMP/testflight-gems" "$RUNNER_TEMP/testflight-bundle" diff --git a/.gitignore b/.gitignore index 82f8356..593fada 100644 --- a/.gitignore +++ b/.gitignore @@ -176,4 +176,7 @@ GoogleService-Info.plist # Environment Varialbes .env .env.* -!.env.example \ No newline at end of file +!.env.example + +# Release automation test artifacts +__pycache__/ diff --git a/README.md b/README.md index 6f0e680..71d1cb7 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,62 @@ ## Interceptor +### Automatic TestFlight deployment + +Pushes to `develop` (including merged pull requests) run the simulator and release +automation checks. If both succeed, the same commit is archived and uploaded to +TestFlight. Pull requests and `master` pushes run checks only. App Store submission +and external beta review are separate release actions. + +Deployments are serialized with GitHub Actions `queue: max`; up to 100 pending +deployments can wait without replacing earlier pending runs. Build numbers start +after the maximum of the project number, the latest TestFlight number for the +marketing version, and the previously uploaded build 31. The job waits for Apple +to finish processing before releasing the deployment queue. A failed processing +step must be investigated before retrying; the upload may already exist on Apple. + +Configure these repository secrets (or secrets in the `testflight` environment): + +| Secret | Purpose | +| --- | --- | +| `QUANTUMLEAP_READ_TOKEN` | Read the private QuantumLeap Swift package; already used by simulator CI. | +| `APP_STORE_CONNECT_API_KEY_KEY_ID` | App Store Connect API key ID. | +| `APP_STORE_CONNECT_API_KEY_ISSUER_ID` | App Store Connect API issuer ID. | +| `APP_STORE_CONNECT_API_KEY_KEY` | Base64-encoded contents of the API key's `.p8` file. | +| `MATCH_PASSWORD` | Password for encrypted signing assets in `qtmleap/match`. | +| `MATCH_GIT_BASIC_AUTHORIZATION` | Base64-encoded `github-user:read-token`, with access to `qtmleap/match`. | + +Use an App Manager API key with access to Interceptor. The upload uses the official +App Store Connect API and does not require an Apple ID browser session or 2FA. +Keep keys, passwords, and tokens outside this repository. Register secrets through +GitHub's secret settings or `gh secret set` using file/stdin input. + +The signing repository must contain a valid App Store distribution certificate +and private key, plus App Store profiles for `jp.qleap.intrcptr` and +`jp.qleap.intrcptr.packet-tunnel` with the app's required entitlements. The lane +reads existing assets only; it does not create certificates or profiles. + +The `xcode-27` runner must have Xcode 27 and Homebrew. The deployment job selects +Homebrew Ruby 3.3 and installs the checked-in Gemfile.lock with Bundler 2.6.9. +Use a dedicated macOS runner account: signing temporarily changes its keychain +search list and `.netrc`. Both are restored by the release wrapper, including on +failure. Its private working directory contains the temporary signing keychain, +Transporter key files, archive output, and package checkouts and is removed on exit. +Other signing jobs must not use that account concurrently. For automatic delivery, +the `testflight` environment must allow `develop` deployments without a required +manual reviewer. Protect `develop` so changes enter through reviewed pull requests. + +Validate the release automation locally without Apple credentials: + +```sh +ruby fastlane/test/testflight_config_test.rb +python3 -m unittest discover -s scripts/tests -v +bash -n scripts/ci-testflight.sh +``` + +The actual upload is performed by `bash scripts/ci-testflight.sh`, with the same +credentials supplied via environment variables. This command uploads a build; +the checks above do not contact Apple. + This is an iOS application that uses a self-signed certificate to obtain an access token from Nintendo Switch Online. ### Requirements diff --git a/fastlane/Fastfile b/fastlane/Fastfile index b6b0105..eb1244d 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -13,27 +13,69 @@ # Uncomment the line if you want fastlane to automatically update itself # update_fastlane -default_platform(:ios) +require_relative "lib/testflight_config" -before_all do |lane, options| - if lane == :beta && ENV["ENVIRONMENT"] == "CI" - setup_ci(provider: ENV["SETUP_CI_PROVIDER"]) - end -end +default_platform(:ios) platform :ios do desc "Push a new beta build to TestFlight" lane :beta do - # scan + TestFlightConfig.validate_credentials!(ENV) + api_key = asc_api_key + version = get_version_number(xcodeproj: "Interceptor.xcodeproj", target: "Interceptor") + local_number = get_build_number(xcodeproj: "Interceptor.xcodeproj") + remote_number = latest_testflight_build_number( + api_key: api_key, + app_identifier: TestFlightConfig::APP_IDENTIFIER, + version: version, + initial_build_number: 0 + ) + build_number = TestFlightConfig.next_build_number(local: local_number, remote: remote_number) fetch_testflight_profile - increment_build_number(xcodeproj: "Interceptor.xcodeproj") - build_app(scheme: "Interceptor", xcargs: "-allowProvisioningUpdates", clean: true) - upload_to_testflight( - skip_waiting_for_build_processing: true, - demo_account_required: false, - notify_external_testers: false, - expire_previous_builds: true + profiles = lane_context[SharedValues::MATCH_PROVISIONING_PROFILE_MAPPING] + TestFlightConfig.signing_targets(profiles).each do |target, profile| + update_code_signing_settings( + path: "Interceptor.xcodeproj", + targets: [target], + build_configurations: ["Release"], + use_automatic_signing: false, + team_id: TestFlightConfig::TEAM_ID, + code_sign_identity: "Apple Distribution", + profile_name: profile + ) + end + increment_build_number(build_number: build_number, xcodeproj: "Interceptor.xcodeproj") + output_path = ENV.fetch("RELEASE_OUTPUT_PATH", "build") + ipa = build_app( + project: "Interceptor.xcodeproj", + scheme: "Interceptor", + configuration: "Release", + clean: true, + export_method: "app-store", + export_team_id: TestFlightConfig::TEAM_ID, + export_options: {signingStyle: "manual", provisioningProfiles: profiles}, + cloned_source_packages_path: ENV["PLL_SOURCE_PACKAGES_PATH"], + derived_data_path: ENV["RELEASE_DERIVED_DATA_PATH"], + output_directory: output_path, + archive_path: File.join(output_path, "Interceptor.xcarchive"), + buildlog_path: File.join(output_path, "logs"), + xcargs: "-packageAuthorizationProvider netrc -onlyUsePackageVersionsFromResolvedFile -skipPackagePluginValidation" ) + # Transporter writes AuthKey_.p8 under HOME. Keep that file in the + # per-run private directory rather than the self-hosted runner's real home. + TestFlightConfig.with_upload_home(ENV.fetch("RELEASE_UPLOAD_HOME")) do + upload_to_testflight( + api_key: api_key, + app_identifier: TestFlightConfig::APP_IDENTIFIER, + ipa: ipa, + skip_waiting_for_build_processing: false, + wait_processing_timeout_duration: 1800, + demo_account_required: false, + distribute_external: false, + notify_external_testers: false, + expire_previous_builds: false + ) + end end desc "Fetch TestFlight profile and cert" @@ -42,11 +84,10 @@ platform :ios do match( api_key: api_key, type: "appstore", - app_identifier: [ - "jp.qleap.intrcptr", - "jp.qleap.intrcptr.packet-tunnel", - ], - readonly: ENV["MATCH_FETCH_READ_ONLY_MODE"] + app_identifier: TestFlightConfig::TARGETS.values, + readonly: true, + keychain_name: ENV["MATCH_KEYCHAIN_NAME"], + keychain_password: ENV["MATCH_KEYCHAIN_PASSWORD"] ) end diff --git a/fastlane/lib/testflight_config.rb b/fastlane/lib/testflight_config.rb new file mode 100644 index 0000000..821be3d --- /dev/null +++ b/fastlane/lib/testflight_config.rb @@ -0,0 +1,48 @@ +require "fileutils" + +module TestFlightConfig + APP_IDENTIFIER = "jp.qleap.intrcptr".freeze + TEAM_ID = "5Q94QJ7G98".freeze + TARGETS = { + "Interceptor" => APP_IDENTIFIER, + "PacketTunnel" => "jp.qleap.intrcptr.packet-tunnel" + }.freeze + REQUIRED_CREDENTIALS = %w[ + APP_STORE_CONNECT_API_KEY_KEY_ID + APP_STORE_CONNECT_API_KEY_ISSUER_ID + APP_STORE_CONNECT_API_KEY_KEY + MATCH_PASSWORD + MATCH_GIT_BASIC_AUTHORIZATION + ].freeze + + def self.validate_credentials!(env) + missing = REQUIRED_CREDENTIALS.select { |name| env[name].to_s.strip.empty? } + raise ArgumentError, "Missing credentials: #{missing.join(', ')}" unless missing.empty? + end + + def self.next_build_number(local:, remote:) + values = [local, remote].map do |value| + raise ArgumentError, "Build numbers must be nonnegative integers" unless value.to_s.match?(/\A[0-9]+\z/) + Integer(value.to_s, 10) + end + # Build 31 was uploaded before automatic deployments were introduced. + [31, *values].max + 1 + end + + def self.signing_targets(profiles) + TARGETS.each_with_object({}) do |(target, identifier), result| + profile = profiles[identifier] + raise ArgumentError, "Missing App Store profile for #{identifier}" if profile.to_s.strip.empty? + result[target] = profile + end + end + + def self.with_upload_home(path) + original = ENV["HOME"] + FileUtils.mkdir_p(path, mode: 0700) + ENV["HOME"] = path + yield + ensure + ENV["HOME"] = original + end +end diff --git a/fastlane/test/testflight_config_test.rb b/fastlane/test/testflight_config_test.rb new file mode 100644 index 0000000..19e90f2 --- /dev/null +++ b/fastlane/test/testflight_config_test.rb @@ -0,0 +1,60 @@ +require "minitest/autorun" +require "tmpdir" + +require_relative "../lib/testflight_config" + +class TestFlightConfigTest < Minitest::Test + def test_new_upload_uses_number_after_remote_build + assert_equal 48, TestFlightConfig.next_build_number(local: "30", remote: 47) + end + + def test_new_version_preserves_local_build_floor + assert_equal 51, TestFlightConfig.next_build_number(local: "50", remote: 0) + end + + def test_existing_release_31_is_not_reused + assert_equal 32, TestFlightConfig.next_build_number(local: "30", remote: 0) + end + + def test_invalid_remote_number_fails_instead_of_falling_back + [nil, "", "31.2", "unavailable", -1].each do |remote| + assert_raises(ArgumentError) { TestFlightConfig.next_build_number(local: 30, remote: remote) } + end + end + + def test_missing_credentials_report_names_without_values + error = assert_raises(ArgumentError) do + TestFlightConfig.validate_credentials!("APP_STORE_CONNECT_API_KEY_KEY" => "private-test-value") + end + assert_includes error.message, "MATCH_PASSWORD" + refute_includes error.message, "private-test-value" + end + + def test_both_signing_profiles_are_required_before_archiving + assert_raises(ArgumentError) do + TestFlightConfig.signing_targets("jp.qleap.intrcptr" => "match AppStore jp.qleap.intrcptr") + end + end + + def test_profiles_are_mapped_to_the_app_and_extension_targets + result = TestFlightConfig.signing_targets( + "jp.qleap.intrcptr" => "App Profile", + "jp.qleap.intrcptr.packet-tunnel" => "VPN Profile" + ) + assert_equal({"Interceptor" => "App Profile", "PacketTunnel" => "VPN Profile"}, result) + end + + def test_upload_uses_private_home_and_restores_home_on_failure + original = ENV["HOME"] + Dir.mktmpdir do |dir| + assert_raises(RuntimeError) do + TestFlightConfig.with_upload_home(File.join(dir, "upload")) do + assert_equal File.join(dir, "upload"), Dir.home + assert_equal 0700, File.stat(Dir.home).mode & 0777 + raise "upload failed" + end + end + end + assert_equal original, ENV["HOME"] + end +end diff --git a/scripts/ci-testflight.sh b/scripts/ci-testflight.sh new file mode 100644 index 0000000..65ba20c --- /dev/null +++ b/scripts/ci-testflight.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +set -euo pipefail +umask 077 + +project_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +cd "$project_dir" + +# Validate before creating a keychain or contacting Apple. Never echo secret values. +ruby -r ./fastlane/lib/testflight_config -e 'TestFlightConfig.validate_credentials!(ENV)' +if [[ ! "${APP_STORE_CONNECT_API_KEY_KEY_ID:-}" =~ ^[A-Za-z0-9]{10}$ ]]; then + printf 'APP_STORE_CONNECT_API_KEY_KEY_ID must be a ten-character key ID.\n' >&2 + exit 2 +fi +if [[ -z "${QUANTUMLEAP_READ_TOKEN:-}" ]]; then + printf 'Missing credential: QUANTUMLEAP_READ_TOKEN\n' >&2 + exit 2 +fi + +work=$(mktemp -d "${RUNNER_TEMP:-${TMPDIR:-/tmp}}/interceptor-testflight.XXXXXXXX") +keychain_path="$work/signing.keychain-db" +original_keychains=() +netrc_installed=false + +cleanup() { + result=$? + trap - EXIT INT TERM + security list-keychains -d user -s "${original_keychains[@]}" >/dev/null 2>&1 || true + security delete-keychain "$keychain_path" >/dev/null 2>&1 || true + if [[ "$netrc_installed" == true ]]; then + rm -f "$HOME/.netrc" + if [[ -e "$work/original.netrc" || -L "$work/original.netrc" ]]; then + mv "$work/original.netrc" "$HOME/.netrc" + fi + fi + rm -rf "$work" + exit "$result" +} + +security list-keychains -d user > "$work/keychains.txt" +while IFS= read -r line; do + # security emits one quoted absolute path per line. Preserve spaces in paths. + line=${line#*\"} + line=${line%\"*} + [[ -n "$line" ]] && original_keychains+=("$line") +done < "$work/keychains.txt" +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +if [[ -e "$HOME/.netrc" || -L "$HOME/.netrc" ]]; then + mv "$HOME/.netrc" "$work/original.netrc" +fi +netrc_installed=true +printf 'machine github.com\n login x-access-token\n password %s\n' "$QUANTUMLEAP_READ_TOKEN" > "$HOME/.netrc" +unset QUANTUMLEAP_READ_TOKEN + +keychain_password=$(openssl rand -hex 24) +security create-keychain -p "$keychain_password" "$keychain_path" +security set-keychain-settings -lut 7200 "$keychain_path" +security unlock-keychain -p "$keychain_password" "$keychain_path" +security list-keychains -d user -s "$keychain_path" "${original_keychains[@]}" +export MATCH_KEYCHAIN_NAME="$keychain_path" +export MATCH_KEYCHAIN_PASSWORD="$keychain_password" +export PLL_SOURCE_PACKAGES_PATH="$work/SourcePackages" +export RELEASE_DERIVED_DATA_PATH="$work/DerivedData" +export RELEASE_OUTPUT_PATH="$work/output" +export RELEASE_UPLOAD_HOME="$work/upload-home" +# Altool/Java Transporter uses Dir.mktmpdir rather than HOME for its .p8 file. +# Own both locations so interrupted uploads are covered by the same cleanup. +export TMPDIR="$work/tmp" +mkdir -p "$PLL_SOURCE_PACKAGES_PATH" "$RELEASE_DERIVED_DATA_PATH" "$TMPDIR" +# LicenseList's build plugin locates its checkout through DerivedData/SourcePackages. +ln -s "$PLL_SOURCE_PACKAGES_PATH" "$RELEASE_DERIVED_DATA_PATH/SourcePackages" + +export CI=true FASTLANE_SKIP_UPDATE_CHECK=1 FASTLANE_SKIP_WELCOME=1 +bundle exec fastlane ios beta diff --git a/scripts/tests/test_ci_testflight.py b/scripts/tests/test_ci_testflight.py new file mode 100644 index 0000000..fdb7605 --- /dev/null +++ b/scripts/tests/test_ci_testflight.py @@ -0,0 +1,97 @@ +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + + +SCRIPT = Path(__file__).resolve().parents[1] / "ci-testflight.sh" + + +class TestFlightCleanupTests(unittest.TestCase): + def run_release(self, exit_code=0, missing=None): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + home = root / "home" + home.mkdir() + netrc = home / ".netrc" + netrc.write_text("original host credentials\n") + netrc.chmod(0o600) + commands = root / "bin" + commands.mkdir() + calls = root / "security.jsonl" + security = commands / "security" + security.write_text("""#!/usr/bin/env python3 +import json, os, sys +args = sys.argv[1:] +if '-p' in args: + args[args.index('-p') + 1] = '' +with open(os.environ['TEST_SECURITY_LOG'], 'a') as log: + log.write(json.dumps(args) + '\\n') +if args == ['list-keychains', '-d', 'user']: + print(' "/fixture/Login Keychain-db"') + print(' "/fixture/second.keychain-db"') +""") + security.chmod(0o755) + bundle = commands / "bundle" + bundle.write_text("""#!/usr/bin/env python3 +import os, pathlib, subprocess, sys +assert sys.argv[1:] == ['exec', 'fastlane', 'ios', 'beta'] +assert pathlib.Path(os.environ['HOME'], '.netrc').stat().st_mode & 0o777 == 0o600 +assert pathlib.Path(os.environ['MATCH_KEYCHAIN_NAME']).parent.is_dir() +assert pathlib.Path(os.environ['RELEASE_DERIVED_DATA_PATH'], 'SourcePackages').is_symlink() +import tempfile +assert pathlib.Path(tempfile.gettempdir()).parent == pathlib.Path(os.environ['MATCH_KEYCHAIN_NAME']).parent +with tempfile.TemporaryDirectory(prefix='deliver-') as private: + pathlib.Path(private, 'AuthKey_fixture.p8').write_text('temporary key fixture') +subprocess.run(['ruby', '-rtmpdir', '-e', 'Dir.mktmpdir("deliver-") { |d| abort "unsafe temporary directory" unless File.realpath(d).start_with?(File.realpath(ENV.fetch("TMPDIR")) + "/") }'], check=True) +upload_home = pathlib.Path(os.environ['RELEASE_UPLOAD_HOME']) +upload_home.mkdir() +(upload_home / 'temporary-private-key.p8').write_text('test key fixture') +sys.exit(int(os.environ['TEST_BUNDLE_EXIT'])) +""") + bundle.chmod(0o755) + env = os.environ.copy() + env.update({ + "HOME": str(home), "RUNNER_TEMP": str(root), + "PATH": str(commands) + os.pathsep + env["PATH"], + "TEST_SECURITY_LOG": str(calls), "TEST_BUNDLE_EXIT": str(exit_code), + "APP_STORE_CONNECT_API_KEY_KEY_ID": "ABCDEFGHIJ", + "APP_STORE_CONNECT_API_KEY_ISSUER_ID": "issuer-fixture", + "APP_STORE_CONNECT_API_KEY_KEY": "private-api-key-fixture", + "MATCH_PASSWORD": "password-fixture", + "MATCH_GIT_BASIC_AUTHORIZATION": "git-fixture", + "QUANTUMLEAP_READ_TOKEN": "quantum-token-fixture", + }) + if missing: + del env[missing] + result = subprocess.run(["bash", str(SCRIPT)], env=env, text=True, capture_output=True) + self.assertEqual(netrc.read_text(), "original host credentials\n") + self.assertEqual(netrc.stat().st_mode & 0o777, 0o600) + self.assertEqual(list(root.glob("interceptor-testflight.*")), []) + for secret in ("private-api-key-fixture", "password-fixture", "quantum-token-fixture"): + self.assertNotIn(secret, result.stdout + result.stderr) + recorded = [json.loads(line) for line in calls.read_text().splitlines()] if calls.exists() else [] + return result, recorded + + def test_success_restores_keychains_and_removes_private_files(self): + result, calls = self.run_release() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn(["list-keychains", "-d", "user", "-s", "/fixture/Login Keychain-db", "/fixture/second.keychain-db"], calls) + self.assertEqual(calls[-1][0], "delete-keychain") + + def test_failed_upload_preserves_failure_and_cleans_up(self): + result, calls = self.run_release(exit_code=42) + self.assertEqual(result.returncode, 42, result.stderr) + self.assertEqual(calls[-1][0], "delete-keychain") + + def test_missing_credentials_stop_before_changing_keychains(self): + result, calls = self.run_release(missing="MATCH_PASSWORD") + self.assertNotEqual(result.returncode, 0) + self.assertIn("MATCH_PASSWORD", result.stderr) + self.assertEqual(calls, []) + + +if __name__ == "__main__": + unittest.main()