Skip to content
Open
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
43 changes: 22 additions & 21 deletions .github/scripts/app_store_connect_release.rb
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,9 @@ def self.collect(initial_url, max_pages: 50)
module_function

def validate_release_channel(channel)
return channel if %w[internal external].include?(channel)
return channel if %w[internal-only internal external].include?(channel)

raise Error, "Invalid release channel '#{channel}'. Expected 'internal' or 'external'."
raise Error, "Invalid release channel '#{channel}'. Expected 'internal-only', 'internal' or 'external'."
end

def validate_external_group(requested_group, groups)
Expand All @@ -86,31 +86,32 @@ def maximum_version(values)
end

def resolve_effective_version(requested:, testflight_versions:, production_versions:)
requested_version = MarketingVersion.new(requested)
latest_testflight = maximum_version(testflight_versions)
latest_production = maximum_version(production_versions)
baseline = [latest_testflight, latest_production].compact.max

if requested == "auto"
unless baseline
raise Error, "No TestFlight or live App Store version exists. Provide an explicit app_version input."
end

return {
effective: baseline.value,
latest_testflight: latest_testflight&.value,
latest_production: latest_production&.value,
bumped: false
}
# Apple closes a version train once it ships to the App Store: a build
# with the same or a lower version would be rejected at upload, so fail
# here instead of after a two-hour archive.
if latest_production && requested_version <= latest_production
raise Error, "App version #{requested} is not above the live App Store version " \
"#{latest_production.value}. Pick a higher version."
end

requested_version = MarketingVersion.new(requested)
effective = baseline && requested_version <= baseline ? baseline : requested_version
# "9.1" and "9.1.0" are different trains to App Store Connect. A train
# spelled exactly as requested always wins; failing that, an existing
# TestFlight train that is numerically the same version lends its
# spelling, so the upload continues that train instead of opening a
# parallel one.
existing_trains = testflight_versions.map { |value| MarketingVersion.new(value) }
matching_train =
existing_trains.find { |version| version.value == requested_version.value } ||
existing_trains.find { |version| version == requested_version }

{
effective: effective.value,
effective: (matching_train || requested_version).value,
latest_testflight: latest_testflight&.value,
latest_production: latest_production&.value,
bumped: baseline ? requested_version < baseline : false
latest_production: latest_production&.value
}
end

Expand Down Expand Up @@ -359,8 +360,8 @@ def resolve_version(client, app_id)
)
end

if result.fetch(:bumped)
@output.puts "::warning::Requested app version #{requested} is below App Store Connect. Using #{result.fetch(:effective)}."
if result.fetch(:effective) != requested
@output.puts "::notice::Requested version #{requested} continues the existing TestFlight train #{result.fetch(:effective)}."
end

write_outputs(
Expand Down
50 changes: 26 additions & 24 deletions .github/scripts/app_store_connect_release_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,37 +12,40 @@ def resolve(requested:, testflight: [], production: [])
)
end

def test_auto_uses_latest_testflight_version
result = resolve(requested: "auto", testflight: %w[9.0.1 9.1.0], production: ["9.0.1"])
def test_requested_version_is_used_as_given
result = resolve(requested: "9.2.0", testflight: ["9.1.0"], production: ["9.0.1"])

assert_equal "9.1.0", result.fetch(:effective)
refute result.fetch(:bumped)
assert_equal "9.2.0", result.fetch(:effective)
assert_equal "9.1.0", result.fetch(:latest_testflight)
assert_equal "9.0.1", result.fetch(:latest_production)
end

def test_auto_uses_production_when_it_is_higher
result = resolve(requested: "auto", testflight: ["9.1.0"], production: ["9.2.0"])
def test_version_below_testflight_but_above_production_is_allowed
result = resolve(requested: "9.0.5", testflight: ["9.1.0"], production: ["9.0.1"])

assert_equal "9.2.0", result.fetch(:effective)
assert_equal "9.0.5", result.fetch(:effective)
end

def test_lower_explicit_version_is_bumped
result = resolve(requested: "9.0.1", testflight: ["9.1.0"], production: [])
def test_version_below_live_app_store_fails
error = assert_raises(AppStoreConnectRelease::Error) do
resolve(requested: "9.0.1", testflight: [], production: ["9.1.0"])
end

assert_equal "9.1.0", result.fetch(:effective)
assert result.fetch(:bumped)
assert_match "not above the live App Store version 9.1.0", error.message
end

def test_higher_explicit_version_opens_new_train
result = resolve(requested: "9.2.0", testflight: ["9.1.0"], production: [])

assert_equal "9.2.0", result.fetch(:effective)
refute result.fetch(:bumped)
def test_version_equal_to_live_app_store_fails
assert_raises(AppStoreConnectRelease::Error) do
resolve(requested: "9.1.0", testflight: [], production: ["9.1"])
end
end

def test_versions_are_compared_numerically
result = resolve(requested: "auto", testflight: %w[9.2 9.10], production: [])
def test_first_release_needs_no_existing_versions
result = resolve(requested: "1.0.0")

assert_equal "9.10", result.fetch(:effective)
assert_equal "1.0.0", result.fetch(:effective)
assert_nil result.fetch(:latest_testflight)
assert_nil result.fetch(:latest_production)
end

def test_existing_train_spelling_wins_for_equivalent_version
Expand All @@ -51,12 +54,10 @@ def test_existing_train_spelling_wins_for_equivalent_version
assert_equal "9.1", result.fetch(:effective)
end

def test_auto_without_existing_versions_fails
error = assert_raises(AppStoreConnectRelease::Error) do
resolve(requested: "auto")
end
def test_exact_train_spelling_beats_equivalent_train
result = resolve(requested: "9.1.0", testflight: %w[9.1 9.1.0], production: [])

assert_match "Provide an explicit app_version", error.message
assert_equal "9.1.0", result.fetch(:effective)
end

def test_invalid_version_fails
Expand All @@ -80,6 +81,7 @@ def test_non_integer_build_number_fails
end

def test_release_channel_validation
assert_equal "internal-only", AppStoreConnectRelease.validate_release_channel("internal-only")
assert_equal "internal", AppStoreConnectRelease.validate_release_channel("internal")
assert_equal "external", AppStoreConnectRelease.validate_release_channel("external")
assert_raises(AppStoreConnectRelease::Error) do
Expand Down
156 changes: 142 additions & 14 deletions .github/workflows/release-dashpay-testflight.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,21 +17,26 @@ on:
required: true
default: v4.2-dev
type: string
dashuikit_ref:
description: "dashpay/DashUIKit branch, tag, or full commit SHA — blank uses the pin committed in Package.resolved"
required: false
default: ""
type: string
release_channel:
description: "TestFlight distribution channel"
description: "TestFlight distribution channel — internal-only marks the upload TestFlight Internal Only"
required: true
default: internal
default: internal-only
type: choice
options:
- internal-only
- internal
- external
app_version:
description: "App version, or 'auto' to follow the latest TestFlight/App Store version"
description: "App version to ship, e.g. 9.1.0 — the build number is assigned automatically"
required: true
default: auto
type: string
testflight_group:
description: "Exact external TestFlight group name (ignored for internal releases)"
description: "Exact external TestFlight group name (ignored unless release_channel is external)"
required: true
default: Public Beta v9.0
type: string
Expand Down Expand Up @@ -78,6 +83,117 @@ jobs:
fetch-depth: 1
persist-credentials: false

# DashUIKit is a remote Swift package, so a branch of it cannot be tested
# by checking it out beside the wallet the way platform is. The archive
# runs with `-onlyUsePackageVersionsFromResolvedFile`, which makes
# `Package.resolved` the authority — so pointing that (and the project's
# requirement, which must agree) at another ref is what selects it.
#
# Left blank, nothing is touched and the committed pin is used.
- name: Override the DashUIKit pin
if: inputs.dashuikit_ref != ''
working-directory: ${{ github.workspace }}/dashwallet-ios
env:
DASHUIKIT_REF: ${{ inputs.dashuikit_ref }}
run: |
set -euo pipefail

# Classify the ref: a branch keeps a branch requirement, while a
# tag or a bare commit SHA becomes a revision requirement — SwiftPM
# rejects a branch requirement naming something that is not a
# branch. An annotated tag lists both the tag object and the peeled
# commit ("^{}"); the peeled commit wins because a Package.resolved
# revision must be a commit, not a tag object.
refs="$(git ls-remote https://github.com/dashpay/DashUIKit \
"refs/heads/$DASHUIKIT_REF" \
"refs/tags/$DASHUIKIT_REF" \
"refs/tags/$DASHUIKIT_REF^{}")"
branch_sha="$(printf '%s\n' "$refs" | awk -v ref="refs/heads/$DASHUIKIT_REF" '$2 == ref { print $1 }')"
peeled_tag_sha="$(printf '%s\n' "$refs" | awk -v ref="refs/tags/$DASHUIKIT_REF^{}" '$2 == ref { print $1 }')"
tag_sha="$(printf '%s\n' "$refs" | awk -v ref="refs/tags/$DASHUIKIT_REF" '$2 == ref { print $1 }')"

if [ -n "$branch_sha" ]; then
kind=branch
sha="$branch_sha"
elif [ -n "${peeled_tag_sha}${tag_sha}" ]; then
kind=revision
sha="${peeled_tag_sha:-$tag_sha}"
elif [[ "$DASHUIKIT_REF" =~ ^[0-9a-f]{40}$ ]]; then
kind=revision
sha="$DASHUIKIT_REF"
else
echo "::error::dashuikit_ref '$DASHUIKIT_REF' is not a DashUIKit branch, tag, or full 40-hex commit SHA."
exit 1
fi
echo "DashUIKit → $DASHUIKIT_REF ($kind $sha)"

python3 - "$DASHUIKIT_REF" "$sha" "$kind" <<'PY'
import json, re, sys

ref, sha, kind = sys.argv[1], sys.argv[2], sys.argv[3]

resolved = "DashWallet.xcworkspace/xcshareddata/swiftpm/Package.resolved"
with open(resolved) as handle:
document = json.load(handle)
pins = [p for p in document["pins"] if p["identity"].lower() == "dashuikit"]
if not pins:
sys.exit("no DashUIKit pin in Package.resolved")
pins[0]["state"] = {"branch": ref, "revision": sha} if kind == "branch" else {"revision": sha}
with open(resolved, "w") as handle:
json.dump(document, handle, indent=2)
handle.write("\n")

# The project pins a requirement too, and `-onlyUsePackageVersionsFromResolvedFile`
# still checks the resolved revision against it. Rewrites only the
# requirement inside DashUIKit's own reference block.
# The branch name is quoted: it carries slashes, and pbxproj only
# leaves bare tokens unquoted. Quoting a plain name too is harmless.
if kind == "branch":
requirement = f'branch = "{ref}";\n\t\t\t\tkind = branch;'
else:
requirement = f'kind = revision;\n\t\t\t\trevision = {sha};'
project = "DashWallet.xcodeproj/project.pbxproj"
with open(project) as handle:
text = handle.read()
pattern = re.compile(
r'(XCRemoteSwiftPackageReference "DashUIKit" \*/ = \{.*?requirement = \{\n)\t+[^}]*?(\n\t+\};)',
re.DOTALL)
text, count = pattern.subn(
lambda match: match.group(1) + "\t\t\t\t" + requirement + match.group(2),
text,
count=1)
if count != 1:
sys.exit("could not rewrite the DashUIKit requirement")
with open(project, "w") as handle:
handle.write(text)
PY

# The release scripts come from the ref this workflow was dispatched
# from, not from the branch being built. Every step runs inside the
# `dashwallet-ios` checkout, so `.github/scripts` there is whatever
# `wallet_ref` happens to carry — and a branch cut before those scripts
# existed cannot be released at all, which is the opposite of what a
# release pipeline is for. Sparse: only the scripts are needed.
- name: Checkout release tooling
uses: actions/checkout@v6
with:
ref: ${{ github.ref }}
path: release-tooling
sparse-checkout: .github/scripts
sparse-checkout-cone-mode: false
fetch-depth: 1
persist-credentials: false

- name: Locate release tooling
run: |
set -euo pipefail
script="$GITHUB_WORKSPACE/release-tooling/.github/scripts/app_store_connect_release.rb"
if [[ ! -f "$script" ]]; then
echo "::error::release tooling missing at $script"
exit 1
fi
echo "RELEASE_SCRIPT=$script" >> "$GITHUB_ENV"

- name: Checkout platform
uses: actions/checkout@v6
with:
Expand Down Expand Up @@ -211,7 +327,7 @@ jobs:
TESTFLIGHT_GROUP: ${{ inputs.testflight_group }}
run: |
set -euo pipefail
ruby .github/scripts/app_store_connect_release.rb resolve-version
ruby "$RELEASE_SCRIPT" resolve-version

- name: Select Xcode 26.6
uses: maxim-lobanov/setup-xcode@v1
Expand Down Expand Up @@ -356,7 +472,7 @@ jobs:
EFFECTIVE_VERSION: ${{ steps.release-version.outputs.effective_version }}
run: |
set -euo pipefail
ruby .github/scripts/app_store_connect_release.rb resolve-build
ruby "$RELEASE_SCRIPT" resolve-build

- name: Install Apple signing certificates
env:
Expand Down Expand Up @@ -530,12 +646,13 @@ jobs:
BUILD_NUMBER: ${{ steps.app-version.outputs.build }}
run: |
set -euo pipefail
ruby .github/scripts/app_store_connect_release.rb assert-build-free
ruby "$RELEASE_SCRIPT" assert-build-free

- name: Upload archive to TestFlight
env:
API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
API_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_ISSUER_ID }}
RELEASE_CHANNEL: ${{ inputs.release_channel }}
run: |
set -euo pipefail

Expand All @@ -547,6 +664,14 @@ jobs:
/usr/libexec/PlistBuddy -c 'Add :manageAppVersionAndBuildNumber bool false' "$export_options"
/usr/libexec/PlistBuddy -c 'Add :uploadSymbols bool true' "$export_options"

# Xcode Organizer's "TestFlight Internal Only": App Store Connect
# marks the uploaded build so it can only reach internal testers —
# it can never be distributed externally or released to the App
# Store. The export method stays app-store-connect either way.
if [[ "$RELEASE_CHANNEL" == 'internal-only' ]]; then
/usr/libexec/PlistBuddy -c 'Add :testFlightInternalTestingOnly bool true' "$export_options"
fi

xcodebuild -exportArchive \
-archivePath "$ARCHIVE_PATH" \
-exportPath "$EXPORT_PATH" \
Expand All @@ -566,10 +691,10 @@ jobs:
BUILD_NUMBER: ${{ steps.app-version.outputs.build }}
run: |
set -euo pipefail
ruby .github/scripts/app_store_connect_release.rb wait-build
ruby "$RELEASE_SCRIPT" wait-build

- name: Finalize internal TestFlight build
if: inputs.release_channel == 'internal'
if: inputs.release_channel != 'external'
env:
WHAT_TO_TEST: ${{ inputs.what_to_test }}
VERSION: ${{ steps.app-version.outputs.version }}
Expand Down Expand Up @@ -644,13 +769,16 @@ jobs:
echo "- dashwallet-ios: \`${WALLET_SHA}\` (requested: \`${REQUESTED_WALLET_REF}\`)"
echo "- platform: \`${PLATFORM_SHA}\` (requested: \`${REQUESTED_PLATFORM_REF}\`)"
echo "- Channel: \`${RELEASE_CHANNEL}\`"
if [[ "$RELEASE_CHANNEL" == 'internal' ]]; then
if [[ "$RELEASE_CHANNEL" == 'external' ]]; then
echo "- External group: \`${TESTFLIGHT_GROUP}\`"
echo '- External tester notifications: enabled'
else
echo '- Internal group: `App Store Connect Users`'
echo '- External distribution: disabled'
echo '- Beta App Review: not submitted'
else
echo "- External group: \`${TESTFLIGHT_GROUP}\`"
echo '- External tester notifications: enabled'
if [[ "$RELEASE_CHANNEL" == 'internal-only' ]]; then
echo '- Marked TestFlight Internal Only: this build can never go external or to the App Store'
fi
fi
echo
echo 'The uploaded build is processed and ready for TestFlight.'
Expand Down
Loading