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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/actionlint.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
self-hosted-runner:
labels:
- xcode-27
87 changes: 83 additions & 4 deletions .github/workflows/ios.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -33,7 +56,7 @@ jobs:
uses: actions/checkout@v4
with:
repository: qtmleap/Mudmouth
ref: 3c1468aaaea5140835982bc8e38c9d3bfc49d2a0
ref: ${{ env.MUDMOUTH_REV }}
path: Mudmouth
persist-credentials: false

Expand Down Expand Up @@ -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"
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -176,4 +176,7 @@ GoogleService-Info.plist
# Environment Varialbes
.env
.env.*
!.env.example
!.env.example

# Release automation test artifacts
__pycache__/
57 changes: 57 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
79 changes: 60 additions & 19 deletions fastlane/Fastfile
Original file line number Diff line number Diff line change
Expand Up @@ -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_<id>.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"
Expand All @@ -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

Expand Down
48 changes: 48 additions & 0 deletions fastlane/lib/testflight_config.rb
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading