From 74d09c1bae7fb5e45b094298c30d0570edd69546 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:09:54 +0300 Subject: [PATCH 1/4] ci: allow a TestFlight build to take DashUIKit from a branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A wallet branch that needs an unreleased DashUIKit change cannot be built by CI at all. platform is checked out beside the wallet and can be pointed anywhere; DashUIKit is a remote Swift package, so the only way to say "use this branch" was to commit a pin — which then has to be remembered and undone before the merge, and which nobody should be reviewing in a feature diff. `dashuikit_ref` does it for a single run. Left blank, nothing is touched and the committed pin stands, so every existing dispatch behaves exactly as before. Given a branch, tag or SHA, the ref is resolved and written into `Package.resolved` before the archive — which already runs with `-onlyUsePackageVersionsFromResolvedFile` and therefore treats that file as the authority. The project's own branch requirement is rewritten alongside it, since the two have to agree, and quoted, because a branch name carries slashes and pbxproj leaves only bare tokens unquoted. Verified by running the rewrite against the committed `Package.resolved` and `project.pbxproj`: the pin and the requirement both land on the requested ref, no other `branch =` entry is touched, and `plutil -lint` still accepts the project file. Co-Authored-By: Claude Opus 5 --- .../workflows/release-dashpay-testflight.yml | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/.github/workflows/release-dashpay-testflight.yml b/.github/workflows/release-dashpay-testflight.yml index ad5065da0..fc60401f7 100644 --- a/.github/workflows/release-dashpay-testflight.yml +++ b/.github/workflows/release-dashpay-testflight.yml @@ -17,6 +17,11 @@ on: required: true default: v4.2-dev type: string + dashuikit_ref: + description: "dashpay/DashUIKit branch, tag, or commit SHA — blank uses the pin committed in Package.resolved" + required: false + default: "" + type: string release_channel: description: "TestFlight distribution channel" required: true @@ -78,6 +83,64 @@ 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 + # branch 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 + + # A branch or tag resolves to its tip; anything else is taken as a + # commit SHA already, and `Package.resolved` wants both a name and a + # revision either way. + sha="$(git ls-remote https://github.com/dashpay/DashUIKit "$DASHUIKIT_REF" | cut -f1 | head -1)" + if [ -z "$sha" ]; then + sha="$DASHUIKIT_REF" + fi + echo "DashUIKit → $DASHUIKIT_REF ($sha)" + + python3 - "$DASHUIKIT_REF" "$sha" <<'PY' + import json, re, sys + + ref, sha = sys.argv[1], sys.argv[2] + + 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} + with open(resolved, "w") as handle: + json.dump(document, handle, indent=2) + handle.write("\n") + + # The project pins a branch too, and `-onlyUsePackageVersionsFromResolvedFile` + # still checks the resolved revision against it. Rewrites only the + # requirement inside DashUIKit's own reference block. + project = "DashWallet.xcodeproj/project.pbxproj" + with open(project) as handle: + text = handle.read() + pattern = re.compile( + r'(XCRemoteSwiftPackageReference "DashUIKit" \*/ = \{.*?branch = )[^;]+(;)', + re.DOTALL) + # Quoted: a branch name carries slashes, and pbxproj only leaves bare + # tokens unquoted. Quoting a plain name too is harmless. + text, count = pattern.subn(rf'\g<1>"{ref}"\g<2>', text, count=1) + if count != 1: + sys.exit("could not rewrite the DashUIKit branch requirement") + with open(project, "w") as handle: + handle.write(text) + PY + - name: Checkout platform uses: actions/checkout@v6 with: From 69b509263603fa9abb52ff5ec9431e1cb04980c9 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:58:21 +0300 Subject: [PATCH 2/4] ci: take the release scripts from the workflow's own ref MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A branch cut before `.github/scripts` existed could not be released at all. Every step runs inside the `dashwallet-ios` checkout, so the scripts it invoked were whatever `wallet_ref` happened to carry — and dispatching the workflow from a newer ref did not help, because the ref chooses the workflow file, not the files the steps read. That is backwards for a release pipeline: which tooling runs a release is a property of the pipeline, not of the code being shipped. The scripts are now checked out from `github.ref` into a folder of their own — sparsely, since only `.github/scripts` is wanted — and the four call sites go through the path that checkout produces, with an explicit check so a missing script says so rather than surfacing as `LoadError` from ruby three steps later. Co-Authored-By: Claude Opus 5 --- .../workflows/release-dashpay-testflight.yml | 34 ++++++++++++++++--- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release-dashpay-testflight.yml b/.github/workflows/release-dashpay-testflight.yml index fc60401f7..ea39edb4b 100644 --- a/.github/workflows/release-dashpay-testflight.yml +++ b/.github/workflows/release-dashpay-testflight.yml @@ -141,6 +141,32 @@ jobs: 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: @@ -274,7 +300,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 @@ -419,7 +445,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: @@ -593,7 +619,7 @@ 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: @@ -629,7 +655,7 @@ 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' From e838a9c5e007ac3c3cd5b8f4f9379e3532501b1e Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:04:01 +0300 Subject: [PATCH 3/4] ci: add an internal-only channel and make the release version explicit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The release_channel input gains internal-only (the new default), which exports with testFlightInternalTestingOnly so App Store Connect marks the upload as TestFlight Internal Only — it can never be distributed externally or released to the App Store. app_version is now a required explicit version instead of 'auto': the build number is still assigned automatically from the train's builds, but the version is never silently substituted — a version at or below the live App Store version fails before archiving, since Apple closes a version train once it ships. --- .github/scripts/app_store_connect_release.rb | 41 ++++++++------- .../scripts/app_store_connect_release_test.rb | 50 +++++++++---------- .../workflows/release-dashpay-testflight.yml | 32 ++++++++---- 3 files changed, 65 insertions(+), 58 deletions(-) diff --git a/.github/scripts/app_store_connect_release.rb b/.github/scripts/app_store_connect_release.rb index 25c3d26d0..0198cc61b 100644 --- a/.github/scripts/app_store_connect_release.rb +++ b/.github/scripts/app_store_connect_release.rb @@ -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) @@ -86,31 +86,30 @@ 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. When an + # existing TestFlight train is numerically the same version, reuse its + # spelling so the upload continues that train instead of opening a + # parallel one. + existing_spelling = testflight_versions + .map { |value| MarketingVersion.new(value) } + .find { |version| version == requested_version } { - effective: effective.value, + effective: (existing_spelling || 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 @@ -359,8 +358,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( diff --git a/.github/scripts/app_store_connect_release_test.rb b/.github/scripts/app_store_connect_release_test.rb index f7722a4da..4876edd39 100644 --- a/.github/scripts/app_store_connect_release_test.rb +++ b/.github/scripts/app_store_connect_release_test.rb @@ -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 @@ -51,14 +54,6 @@ 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 - - assert_match "Provide an explicit app_version", error.message - end - def test_invalid_version_fails assert_raises(AppStoreConnectRelease::Error) do resolve(requested: "v9.1", testflight: ["9.0.0"]) @@ -80,6 +75,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 diff --git a/.github/workflows/release-dashpay-testflight.yml b/.github/workflows/release-dashpay-testflight.yml index ea39edb4b..cd50672f8 100644 --- a/.github/workflows/release-dashpay-testflight.yml +++ b/.github/workflows/release-dashpay-testflight.yml @@ -23,20 +23,20 @@ on: 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 @@ -625,6 +625,7 @@ jobs: 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 @@ -636,6 +637,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" \ @@ -658,7 +667,7 @@ jobs: 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 }} @@ -733,13 +742,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.' From 8221608d7941001dec3d9d5bcd42f7fb96fb7fb9 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Fri, 28 Aug 2026 11:58:24 +0300 Subject: [PATCH 4/4] ci: classify the DashUIKit ref and prefer an exact TestFlight train Review follow-ups: - dashuikit_ref advertised a branch, tag, or SHA but always wrote a branch requirement, so a full SHA became a branch named after the SHA and an annotated tag pinned the tag object. The ref is now classified: a branch keeps a branch requirement, a tag or SHA becomes a revision requirement pinned to the peeled commit, and anything that is none of those fails the step up front. - A requested version now selects the TestFlight train spelled exactly as requested before borrowing the spelling of a numerically equivalent train, so 9.1.0 no longer archives into 9.1 when both trains exist. --- .github/scripts/app_store_connect_release.rb | 16 ++--- .../scripts/app_store_connect_release_test.rb | 6 ++ .../workflows/release-dashpay-testflight.yml | 61 +++++++++++++------ 3 files changed, 59 insertions(+), 24 deletions(-) diff --git a/.github/scripts/app_store_connect_release.rb b/.github/scripts/app_store_connect_release.rb index 0198cc61b..d454f3d21 100644 --- a/.github/scripts/app_store_connect_release.rb +++ b/.github/scripts/app_store_connect_release.rb @@ -98,16 +98,18 @@ def resolve_effective_version(requested:, testflight_versions:, production_versi "#{latest_production.value}. Pick a higher version." end - # "9.1" and "9.1.0" are different trains to App Store Connect. When an - # existing TestFlight train is numerically the same version, reuse its - # spelling so the upload continues that train instead of opening a + # "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_spelling = testflight_versions - .map { |value| MarketingVersion.new(value) } - .find { |version| version == requested_version } + 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: (existing_spelling || requested_version).value, + effective: (matching_train || requested_version).value, latest_testflight: latest_testflight&.value, latest_production: latest_production&.value } diff --git a/.github/scripts/app_store_connect_release_test.rb b/.github/scripts/app_store_connect_release_test.rb index 4876edd39..6325b7545 100644 --- a/.github/scripts/app_store_connect_release_test.rb +++ b/.github/scripts/app_store_connect_release_test.rb @@ -54,6 +54,12 @@ def test_existing_train_spelling_wins_for_equivalent_version assert_equal "9.1", result.fetch(:effective) end + def test_exact_train_spelling_beats_equivalent_train + result = resolve(requested: "9.1.0", testflight: %w[9.1 9.1.0], production: []) + + assert_equal "9.1.0", result.fetch(:effective) + end + def test_invalid_version_fails assert_raises(AppStoreConnectRelease::Error) do resolve(requested: "v9.1", testflight: ["9.0.0"]) diff --git a/.github/workflows/release-dashpay-testflight.yml b/.github/workflows/release-dashpay-testflight.yml index cd50672f8..47e563b86 100644 --- a/.github/workflows/release-dashpay-testflight.yml +++ b/.github/workflows/release-dashpay-testflight.yml @@ -18,7 +18,7 @@ on: default: v4.2-dev type: string dashuikit_ref: - description: "dashpay/DashUIKit branch, tag, or commit SHA — blank uses the pin committed in Package.resolved" + description: "dashpay/DashUIKit branch, tag, or full commit SHA — blank uses the pin committed in Package.resolved" required: false default: "" type: string @@ -87,7 +87,7 @@ jobs: # 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 - # branch requirement, which must agree) at another ref is what selects it. + # 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 @@ -98,19 +98,39 @@ jobs: run: | set -euo pipefail - # A branch or tag resolves to its tip; anything else is taken as a - # commit SHA already, and `Package.resolved` wants both a name and a - # revision either way. - sha="$(git ls-remote https://github.com/dashpay/DashUIKit "$DASHUIKIT_REF" | cut -f1 | head -1)" - if [ -z "$sha" ]; then + # 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 ($sha)" + echo "DashUIKit → $DASHUIKIT_REF ($kind $sha)" - python3 - "$DASHUIKIT_REF" "$sha" <<'PY' + python3 - "$DASHUIKIT_REF" "$sha" "$kind" <<'PY' import json, re, sys - ref, sha = sys.argv[1], sys.argv[2] + ref, sha, kind = sys.argv[1], sys.argv[2], sys.argv[3] resolved = "DashWallet.xcworkspace/xcshareddata/swiftpm/Package.resolved" with open(resolved) as handle: @@ -118,25 +138,32 @@ jobs: 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} + 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 branch too, and `-onlyUsePackageVersionsFromResolvedFile` + # 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" \*/ = \{.*?branch = )[^;]+(;)', + r'(XCRemoteSwiftPackageReference "DashUIKit" \*/ = \{.*?requirement = \{\n)\t+[^}]*?(\n\t+\};)', re.DOTALL) - # Quoted: a branch name carries slashes, and pbxproj only leaves bare - # tokens unquoted. Quoting a plain name too is harmless. - text, count = pattern.subn(rf'\g<1>"{ref}"\g<2>', text, count=1) + 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 branch requirement") + sys.exit("could not rewrite the DashUIKit requirement") with open(project, "w") as handle: handle.write(text) PY