From f0e5661feefc533fc43b0b96fd48aadf07c6600b Mon Sep 17 00:00:00 2001 From: evgeniyChepelev <68751844+evgeniyChepelev@users.noreply.github.com> Date: Thu, 23 Apr 2026 01:06:16 +0200 Subject: [PATCH 01/12] ci: resolve build number from TestFlight API instead of run number --- .github/workflows/release.yml | 34 ++++++++++++++++++++++- .github/workflows/testflight.yml | 46 +++++++++++++++++++++++++++++--- 2 files changed, 76 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 97dabff..4ee999b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,10 +10,11 @@ permissions: jobs: prepare: - name: Parse tag version + name: Parse tag and resolve build number runs-on: ubuntu-latest outputs: version: ${{ steps.parse.outputs.version }} + build-number: ${{ steps.tfbuild.outputs.build-number }} steps: - name: Strip v prefix from tag id: parse @@ -21,6 +22,36 @@ jobs: TAG="${GITHUB_REF_NAME}" echo "version=${TAG#v}" >> "$GITHUB_OUTPUT" + - name: Resolve build number from TestFlight + id: tfbuild + env: + API_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }} + API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} + API_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} + APP_ID: ${{ vars.APP_STORE_APP_ID_IOS }} + VERSION: ${{ steps.parse.outputs.version }} + run: | + KEY_FILE=$(mktemp) + echo "$API_KEY_BASE64" | base64 --decode > "$KEY_FILE" + + NOW=$(date +%s) + EXP=$((NOW + 1200)) + HEADER=$(printf '{"alg":"ES256","kid":"%s","typ":"JWT"}' "$API_KEY_ID" | base64 -w 0 | tr -d '=' | tr '+/' '-_') + PAYLOAD=$(printf '{"iss":"%s","iat":%d,"exp":%d,"aud":"appstoreconnect-v1"}' "$API_ISSUER_ID" "$NOW" "$EXP" | base64 -w 0 | tr -d '=' | tr '+/' '-_') + SIGNATURE=$(printf '%s.%s' "$HEADER" "$PAYLOAD" | openssl dgst -binary -sha256 -sign "$KEY_FILE" | base64 -w 0 | tr -d '=' | tr '+/' '-_') + JWT="${HEADER}.${PAYLOAD}.${SIGNATURE}" + rm -f "$KEY_FILE" + + RESPONSE=$(curl -sf \ + -H "Authorization: Bearer $JWT" \ + "https://api.appstoreconnect.apple.com/v1/builds?filter[app]=${APP_ID}&filter[preReleaseVersion.version]=${VERSION}&sort=-version&limit=1&fields[builds]=version" \ + || echo '{}') + + LATEST=$(echo "$RESPONSE" | jq -r '.data[0].attributes.version // "0"') + NEXT=$((LATEST + 1)) + echo "build-number=$NEXT" >> "$GITHUB_OUTPUT" + echo "Latest build for $VERSION: $LATEST → next: $NEXT" + build: name: Build and Upload Release needs: prepare @@ -28,4 +59,5 @@ jobs: with: ref: ${{ github.ref }} version: ${{ needs.prepare.outputs.version }} + build-number: ${{ needs.prepare.outputs.build-number }} secrets: inherit diff --git a/.github/workflows/testflight.yml b/.github/workflows/testflight.yml index 932b69f..c4fab59 100644 --- a/.github/workflows/testflight.yml +++ b/.github/workflows/testflight.yml @@ -39,7 +39,6 @@ jobs: uses: actions/github-script@v7 with: script: | - core.setOutput('build-number', '${{ github.run_number }}'); core.setOutput('should-build', 'false'); function parseCommand(body) { @@ -47,7 +46,7 @@ jobs: const versionMatch = line.match(/version=([0-9]+\.[0-9]+\.[0-9]+)/); if (versionMatch) core.setOutput('version-override', versionMatch[1]); const buildMatch = line.match(/build-number=([0-9]+)/); - if (buildMatch) core.setOutput('build-number', buildMatch[1]); + if (buildMatch) core.setOutput('build-number-override', buildMatch[1]); const netbirdMatch = line.match(/netbird-ref=([a-zA-Z0-9._\/-]+)/); if (netbirdMatch) core.setOutput('netbird-ref', netbirdMatch[1]); } @@ -149,6 +148,41 @@ jobs: echo "version=$NEXT" >> "$GITHUB_OUTPUT" echo "Tag: $LATEST_TAG → next: $NEXT" + - name: Resolve build number from TestFlight + id: tfbuild + if: steps.pre.outputs.should-build == 'true' && steps.pre.outputs.build-number-override == '' + env: + API_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }} + API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} + API_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} + APP_ID: ${{ vars.APP_STORE_APP_ID_IOS }} + run: | + VERSION="${{ steps.pre.outputs.version-override }}" + if [ -z "$VERSION" ]; then + VERSION="${{ steps.derive.outputs.version }}" + fi + + KEY_FILE=$(mktemp) + echo "$API_KEY_BASE64" | base64 --decode > "$KEY_FILE" + + NOW=$(date +%s) + EXP=$((NOW + 1200)) + HEADER=$(printf '{"alg":"ES256","kid":"%s","typ":"JWT"}' "$API_KEY_ID" | base64 -w 0 | tr -d '=' | tr '+/' '-_') + PAYLOAD=$(printf '{"iss":"%s","iat":%d,"exp":%d,"aud":"appstoreconnect-v1"}' "$API_ISSUER_ID" "$NOW" "$EXP" | base64 -w 0 | tr -d '=' | tr '+/' '-_') + SIGNATURE=$(printf '%s.%s' "$HEADER" "$PAYLOAD" | openssl dgst -binary -sha256 -sign "$KEY_FILE" | base64 -w 0 | tr -d '=' | tr '+/' '-_') + JWT="${HEADER}.${PAYLOAD}.${SIGNATURE}" + rm -f "$KEY_FILE" + + RESPONSE=$(curl -sf \ + -H "Authorization: Bearer $JWT" \ + "https://api.appstoreconnect.apple.com/v1/builds?filter[app]=${APP_ID}&filter[preReleaseVersion.version]=${VERSION}&sort=-version&limit=1&fields[builds]=version" \ + || echo '{}') + + LATEST=$(echo "$RESPONSE" | jq -r '.data[0].attributes.version // "0"') + NEXT=$((LATEST + 1)) + echo "build-number=$NEXT" >> "$GITHUB_OUTPUT" + echo "Latest build for $VERSION: $LATEST → next: $NEXT" + - name: Finalize outputs id: finalize uses: actions/github-script@v7 @@ -157,9 +191,15 @@ jobs: core.setOutput('ref', '${{ steps.pre.outputs.ref }}'); core.setOutput('should-build', '${{ steps.pre.outputs.should-build }}'); core.setOutput('upload', '${{ steps.pre.outputs.upload }}'); - core.setOutput('build-number', '${{ steps.pre.outputs.build-number }}'); core.setOutput('netbird-ref', '${{ steps.pre.outputs.netbird-ref }}'); + const buildNumber = + '${{ steps.pre.outputs.build-number-override }}' || + '${{ steps.tfbuild.outputs.build-number }}' || + '${{ github.run_number }}'; + core.setOutput('build-number', buildNumber); + core.info(`build-number: ${buildNumber}`); + const override = '${{ steps.pre.outputs.version-override }}'; const derived = '${{ steps.derive.outputs.version }}'; core.setOutput('version', override || derived); From 8bf5e24e95617b7f3ad2d906c47ba5c4eb9d31ae Mon Sep 17 00:00:00 2001 From: evgeniyChepelev <68751844+evgeniyChepelev@users.noreply.github.com> Date: Thu, 23 Apr 2026 01:30:28 +0200 Subject: [PATCH 02/12] Update testflight.yml --- .github/workflows/testflight.yml | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/.github/workflows/testflight.yml b/.github/workflows/testflight.yml index c4fab59..138b75e 100644 --- a/.github/workflows/testflight.yml +++ b/.github/workflows/testflight.yml @@ -157,10 +157,18 @@ jobs: API_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} APP_ID: ${{ vars.APP_STORE_APP_ID_IOS }} run: | + set -euo pipefail + VERSION="${{ steps.pre.outputs.version-override }}" if [ -z "$VERSION" ]; then VERSION="${{ steps.derive.outputs.version }}" fi + echo "Querying TestFlight for app_id='${APP_ID}' version='${VERSION}'" + + if [ -z "$APP_ID" ]; then + echo "::error::APP_STORE_APP_ID_IOS repository variable is not set" + exit 1 + fi KEY_FILE=$(mktemp) echo "$API_KEY_BASE64" | base64 --decode > "$KEY_FILE" @@ -175,10 +183,10 @@ jobs: RESPONSE=$(curl -sf \ -H "Authorization: Bearer $JWT" \ - "https://api.appstoreconnect.apple.com/v1/builds?filter[app]=${APP_ID}&filter[preReleaseVersion.version]=${VERSION}&sort=-version&limit=1&fields[builds]=version" \ - || echo '{}') + "https://api.appstoreconnect.apple.com/v1/builds?filter[app]=${APP_ID}&filter[preReleaseVersion.version]=${VERSION}&sort=-version&limit=25&fields[builds]=version") + echo "API response: $RESPONSE" - LATEST=$(echo "$RESPONSE" | jq -r '.data[0].attributes.version // "0"') + LATEST=$(echo "$RESPONSE" | jq -r '[.data[].attributes.version | tonumber] | max // 0') NEXT=$((LATEST + 1)) echo "build-number=$NEXT" >> "$GITHUB_OUTPUT" echo "Latest build for $VERSION: $LATEST → next: $NEXT" From 6b7b1de30661ad94759607c25d66d5d61b5924d6 Mon Sep 17 00:00:00 2001 From: evgeniyChepelev <68751844+evgeniyChepelev@users.noreply.github.com> Date: Thu, 23 Apr 2026 01:59:55 +0200 Subject: [PATCH 03/12] ci: fix JWT ES256 signature format for TestFlight API --- .github/workflows/release.yml | 19 ++++++++++++++----- .github/workflows/testflight.yml | 23 +++++++++++++++++++---- 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4ee999b..986d7fd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,6 +31,7 @@ jobs: APP_ID: ${{ vars.APP_STORE_APP_ID_IOS }} VERSION: ${{ steps.parse.outputs.version }} run: | + set -euo pipefail KEY_FILE=$(mktemp) echo "$API_KEY_BASE64" | base64 --decode > "$KEY_FILE" @@ -38,16 +39,24 @@ jobs: EXP=$((NOW + 1200)) HEADER=$(printf '{"alg":"ES256","kid":"%s","typ":"JWT"}' "$API_KEY_ID" | base64 -w 0 | tr -d '=' | tr '+/' '-_') PAYLOAD=$(printf '{"iss":"%s","iat":%d,"exp":%d,"aud":"appstoreconnect-v1"}' "$API_ISSUER_ID" "$NOW" "$EXP" | base64 -w 0 | tr -d '=' | tr '+/' '-_') - SIGNATURE=$(printf '%s.%s' "$HEADER" "$PAYLOAD" | openssl dgst -binary -sha256 -sign "$KEY_FILE" | base64 -w 0 | tr -d '=' | tr '+/' '-_') + # openssl produces DER-encoded ECDSA; JWT ES256 requires raw 64-byte R||S + SIGNATURE=$(printf '%s.%s' "$HEADER" "$PAYLOAD" | \ + openssl dgst -binary -sha256 -sign "$KEY_FILE" | \ + python3 -c "import sys,base64;d=sys.stdin.buffer.read();i=2;rl=d[i+1];r=d[i+2:i+2+rl];i+=2+rl;sl=d[i+1];s=d[i+2:i+2+sl];r=r.lstrip(b'\x00').rjust(32,b'\x00');s=s.lstrip(b'\x00').rjust(32,b'\x00');print(base64.urlsafe_b64encode(r[-32:]+s[-32:]).rstrip(b'=').decode())") JWT="${HEADER}.${PAYLOAD}.${SIGNATURE}" rm -f "$KEY_FILE" - RESPONSE=$(curl -sf \ + HTTP_STATUS=$(curl -s -o /tmp/tf_response.json -w "%{http_code}" \ -H "Authorization: Bearer $JWT" \ - "https://api.appstoreconnect.apple.com/v1/builds?filter[app]=${APP_ID}&filter[preReleaseVersion.version]=${VERSION}&sort=-version&limit=1&fields[builds]=version" \ - || echo '{}') + "https://api.appstoreconnect.apple.com/v1/builds?filter[app]=${APP_ID}&filter[preReleaseVersion.version]=${VERSION}&sort=-version&limit=25&fields[builds]=version") + RESPONSE=$(cat /tmp/tf_response.json) + echo "HTTP $HTTP_STATUS — response: $RESPONSE" + if [ "$HTTP_STATUS" != "200" ]; then + echo "::error::TestFlight API returned HTTP $HTTP_STATUS" + exit 1 + fi - LATEST=$(echo "$RESPONSE" | jq -r '.data[0].attributes.version // "0"') + LATEST=$(echo "$RESPONSE" | jq -r '[.data[].attributes.version | tonumber] | max // 0') NEXT=$((LATEST + 1)) echo "build-number=$NEXT" >> "$GITHUB_OUTPUT" echo "Latest build for $VERSION: $LATEST → next: $NEXT" diff --git a/.github/workflows/testflight.yml b/.github/workflows/testflight.yml index 138b75e..79aab4c 100644 --- a/.github/workflows/testflight.yml +++ b/.github/workflows/testflight.yml @@ -150,15 +150,22 @@ jobs: - name: Resolve build number from TestFlight id: tfbuild - if: steps.pre.outputs.should-build == 'true' && steps.pre.outputs.build-number-override == '' + if: steps.pre.outputs.should-build == 'true' env: API_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }} API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} API_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} APP_ID: ${{ vars.APP_STORE_APP_ID_IOS }} + BUILD_OVERRIDE: ${{ steps.pre.outputs.build-number-override }} run: | set -euo pipefail + if [ -n "$BUILD_OVERRIDE" ]; then + echo "Using manual build-number override: $BUILD_OVERRIDE" + echo "build-number=$BUILD_OVERRIDE" >> "$GITHUB_OUTPUT" + exit 0 + fi + VERSION="${{ steps.pre.outputs.version-override }}" if [ -z "$VERSION" ]; then VERSION="${{ steps.derive.outputs.version }}" @@ -177,14 +184,22 @@ jobs: EXP=$((NOW + 1200)) HEADER=$(printf '{"alg":"ES256","kid":"%s","typ":"JWT"}' "$API_KEY_ID" | base64 -w 0 | tr -d '=' | tr '+/' '-_') PAYLOAD=$(printf '{"iss":"%s","iat":%d,"exp":%d,"aud":"appstoreconnect-v1"}' "$API_ISSUER_ID" "$NOW" "$EXP" | base64 -w 0 | tr -d '=' | tr '+/' '-_') - SIGNATURE=$(printf '%s.%s' "$HEADER" "$PAYLOAD" | openssl dgst -binary -sha256 -sign "$KEY_FILE" | base64 -w 0 | tr -d '=' | tr '+/' '-_') + # openssl produces DER-encoded ECDSA; JWT ES256 requires raw 64-byte R||S + SIGNATURE=$(printf '%s.%s' "$HEADER" "$PAYLOAD" | \ + openssl dgst -binary -sha256 -sign "$KEY_FILE" | \ + python3 -c "import sys,base64;d=sys.stdin.buffer.read();i=2;rl=d[i+1];r=d[i+2:i+2+rl];i+=2+rl;sl=d[i+1];s=d[i+2:i+2+sl];r=r.lstrip(b'\x00').rjust(32,b'\x00');s=s.lstrip(b'\x00').rjust(32,b'\x00');print(base64.urlsafe_b64encode(r[-32:]+s[-32:]).rstrip(b'=').decode())") JWT="${HEADER}.${PAYLOAD}.${SIGNATURE}" rm -f "$KEY_FILE" - RESPONSE=$(curl -sf \ + HTTP_STATUS=$(curl -s -o /tmp/tf_response.json -w "%{http_code}" \ -H "Authorization: Bearer $JWT" \ "https://api.appstoreconnect.apple.com/v1/builds?filter[app]=${APP_ID}&filter[preReleaseVersion.version]=${VERSION}&sort=-version&limit=25&fields[builds]=version") - echo "API response: $RESPONSE" + RESPONSE=$(cat /tmp/tf_response.json) + echo "HTTP $HTTP_STATUS — response: $RESPONSE" + if [ "$HTTP_STATUS" != "200" ]; then + echo "::error::TestFlight API returned HTTP $HTTP_STATUS" + exit 1 + fi LATEST=$(echo "$RESPONSE" | jq -r '[.data[].attributes.version | tonumber] | max // 0') NEXT=$((LATEST + 1)) From 316b15b2eeb119dad3d52c22d56e3471953d82f1 Mon Sep 17 00:00:00 2001 From: evgeniyChepelev <68751844+evgeniyChepelev@users.noreply.github.com> Date: Thu, 23 Apr 2026 02:15:07 +0200 Subject: [PATCH 04/12] Update testflight.yml --- .github/workflows/testflight.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/.github/workflows/testflight.yml b/.github/workflows/testflight.yml index 79aab4c..a238236 100644 --- a/.github/workflows/testflight.yml +++ b/.github/workflows/testflight.yml @@ -8,6 +8,14 @@ on: types: [opened, edited] issue_comment: types: [created] + workflow_dispatch: + inputs: + version: + description: 'Version (e.g. 0.1.6)' + required: false + build-number: + description: 'Build number override (leave empty to resolve from TestFlight)' + required: false permissions: contents: read @@ -73,6 +81,17 @@ jobs: return; } + if (context.eventName === 'workflow_dispatch') { + core.setOutput('ref', context.sha); + core.setOutput('should-build', 'true'); + core.setOutput('upload', 'false'); + const v = context.payload.inputs?.version; + if (v) core.setOutput('version-override', v); + const b = context.payload.inputs?.['build-number']; + if (b) core.setOutput('build-number-override', b); + return; + } + if (context.eventName === 'issue_comment') { if (!context.payload.issue.pull_request) { core.info('Not a PR comment — skipping'); From 9373bc46c5b4923bc171a8a05178f99ec3a542aa Mon Sep 17 00:00:00 2001 From: evgeniyChepelev <68751844+evgeniyChepelev@users.noreply.github.com> Date: Thu, 23 Apr 2026 02:17:38 +0200 Subject: [PATCH 05/12] Update testflight.yml --- .github/workflows/testflight.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/testflight.yml b/.github/workflows/testflight.yml index a238236..d44fb29 100644 --- a/.github/workflows/testflight.yml +++ b/.github/workflows/testflight.yml @@ -2,7 +2,7 @@ name: TestFlight on: push: - branches: [main] + branches: [main, ci/unified-build-number-from-testflight] pull_request: branches: [main] types: [opened, edited] From fe2109bbeddc47095a3a225593f8f61bf5f633be Mon Sep 17 00:00:00 2001 From: evgeniyChepelev <68751844+evgeniyChepelev@users.noreply.github.com> Date: Thu, 23 Apr 2026 10:45:13 +0200 Subject: [PATCH 06/12] Update testflight.yml --- .github/workflows/testflight.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/testflight.yml b/.github/workflows/testflight.yml index d44fb29..c098df6 100644 --- a/.github/workflows/testflight.yml +++ b/.github/workflows/testflight.yml @@ -30,6 +30,7 @@ jobs: name: Validate trigger if: > github.event_name == 'push' || + github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) || github.event_name == 'issue_comment' From 0145ba35a7f6b71545f48052e91f2e669b3bb1b2 Mon Sep 17 00:00:00 2001 From: evgeniyChepelev <68751844+evgeniyChepelev@users.noreply.github.com> Date: Thu, 23 Apr 2026 10:50:16 +0200 Subject: [PATCH 07/12] move id to secrets --- .github/workflows/release.yml | 2 +- .github/workflows/testflight.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 986d7fd..5581e58 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,7 +28,7 @@ jobs: API_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }} API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} API_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} - APP_ID: ${{ vars.APP_STORE_APP_ID_IOS }} + APP_ID: ${{ secrets.APP_STORE_APP_ID_IOS }} VERSION: ${{ steps.parse.outputs.version }} run: | set -euo pipefail diff --git a/.github/workflows/testflight.yml b/.github/workflows/testflight.yml index c098df6..de67a17 100644 --- a/.github/workflows/testflight.yml +++ b/.github/workflows/testflight.yml @@ -175,7 +175,7 @@ jobs: API_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }} API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} API_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} - APP_ID: ${{ vars.APP_STORE_APP_ID_IOS }} + APP_ID: ${{ secrets.APP_STORE_APP_ID_IOS }} BUILD_OVERRIDE: ${{ steps.pre.outputs.build-number-override }} run: | set -euo pipefail From c0ea9ab36a6c35ff1f0130f02590e230862985ee Mon Sep 17 00:00:00 2001 From: evgeniyChepelev <68751844+evgeniyChepelev@users.noreply.github.com> Date: Thu, 23 Apr 2026 10:52:10 +0200 Subject: [PATCH 08/12] Update ci/cd --- .github/workflows/release.yml | 6 +++++- .github/workflows/testflight.yml | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5581e58..2cc0365 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,7 +33,11 @@ jobs: run: | set -euo pipefail KEY_FILE=$(mktemp) - echo "$API_KEY_BASE64" | base64 --decode > "$KEY_FILE" + if echo "$API_KEY_BASE64" | grep -q "BEGIN"; then + printf '%s' "$API_KEY_BASE64" > "$KEY_FILE" + else + echo "$API_KEY_BASE64" | base64 --decode > "$KEY_FILE" + fi NOW=$(date +%s) EXP=$((NOW + 1200)) diff --git a/.github/workflows/testflight.yml b/.github/workflows/testflight.yml index de67a17..ef50460 100644 --- a/.github/workflows/testflight.yml +++ b/.github/workflows/testflight.yml @@ -198,7 +198,11 @@ jobs: fi KEY_FILE=$(mktemp) - echo "$API_KEY_BASE64" | base64 --decode > "$KEY_FILE" + if echo "$API_KEY_BASE64" | grep -q "BEGIN"; then + printf '%s' "$API_KEY_BASE64" > "$KEY_FILE" + else + echo "$API_KEY_BASE64" | base64 --decode > "$KEY_FILE" + fi NOW=$(date +%s) EXP=$((NOW + 1200)) From 503e9744e1cc9be7769949b3ec253eed270acb2c Mon Sep 17 00:00:00 2001 From: evgeniyChepelev <68751844+evgeniyChepelev@users.noreply.github.com> Date: Thu, 23 Apr 2026 10:53:16 +0200 Subject: [PATCH 09/12] Update testflight.yml --- .github/workflows/testflight.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/testflight.yml b/.github/workflows/testflight.yml index ef50460..afb93cd 100644 --- a/.github/workflows/testflight.yml +++ b/.github/workflows/testflight.yml @@ -203,6 +203,9 @@ jobs: else echo "$API_KEY_BASE64" | base64 --decode > "$KEY_FILE" fi + echo "Key file first line: $(head -1 "$KEY_FILE")" + echo "Key file size: $(wc -c < "$KEY_FILE") bytes" + openssl ec -in "$KEY_FILE" -noout 2>&1 || openssl pkey -in "$KEY_FILE" -noout 2>&1 || echo "::warning::Key check failed" NOW=$(date +%s) EXP=$((NOW + 1200)) From 673ce6ebcac3609c306b8d5c46f60b4bae7119c7 Mon Sep 17 00:00:00 2001 From: evgeniyChepelev <68751844+evgeniyChepelev@users.noreply.github.com> Date: Thu, 23 Apr 2026 10:56:49 +0200 Subject: [PATCH 10/12] update ci/cd --- .github/workflows/release.yml | 39 ++++++++++++++++------------ .github/workflows/testflight.yml | 44 +++++++++++++++++--------------- 2 files changed, 47 insertions(+), 36 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2cc0365..c77bc56 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,23 +32,30 @@ jobs: VERSION: ${{ steps.parse.outputs.version }} run: | set -euo pipefail - KEY_FILE=$(mktemp) - if echo "$API_KEY_BASE64" | grep -q "BEGIN"; then - printf '%s' "$API_KEY_BASE64" > "$KEY_FILE" - else - echo "$API_KEY_BASE64" | base64 --decode > "$KEY_FILE" - fi + pip3 install cryptography --quiet --disable-pip-version-check + + JWT=$(python3 - << 'PYEOF' +import os, time, json, base64 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature + +raw = os.environ['API_KEY_BASE64'] +pem = raw.encode() if b'BEGIN' in raw.encode() else base64.b64decode(raw) +key = serialization.load_pem_private_key(pem, password=None) + +now = int(time.time()) +def b64url(data): return base64.urlsafe_b64encode(data).rstrip(b'=').decode() + +hdr = b64url(json.dumps({"alg":"ES256","kid":os.environ['API_KEY_ID'],"typ":"JWT"}).encode()) +pld = b64url(json.dumps({"iss":os.environ['API_ISSUER_ID'],"iat":now,"exp":now+1200,"aud":"appstoreconnect-v1"}).encode()) - NOW=$(date +%s) - EXP=$((NOW + 1200)) - HEADER=$(printf '{"alg":"ES256","kid":"%s","typ":"JWT"}' "$API_KEY_ID" | base64 -w 0 | tr -d '=' | tr '+/' '-_') - PAYLOAD=$(printf '{"iss":"%s","iat":%d,"exp":%d,"aud":"appstoreconnect-v1"}' "$API_ISSUER_ID" "$NOW" "$EXP" | base64 -w 0 | tr -d '=' | tr '+/' '-_') - # openssl produces DER-encoded ECDSA; JWT ES256 requires raw 64-byte R||S - SIGNATURE=$(printf '%s.%s' "$HEADER" "$PAYLOAD" | \ - openssl dgst -binary -sha256 -sign "$KEY_FILE" | \ - python3 -c "import sys,base64;d=sys.stdin.buffer.read();i=2;rl=d[i+1];r=d[i+2:i+2+rl];i+=2+rl;sl=d[i+1];s=d[i+2:i+2+sl];r=r.lstrip(b'\x00').rjust(32,b'\x00');s=s.lstrip(b'\x00').rjust(32,b'\x00');print(base64.urlsafe_b64encode(r[-32:]+s[-32:]).rstrip(b'=').decode())") - JWT="${HEADER}.${PAYLOAD}.${SIGNATURE}" - rm -f "$KEY_FILE" +sig_der = key.sign(f"{hdr}.{pld}".encode(), ec.ECDSA(hashes.SHA256())) +r, s = decode_dss_signature(sig_der) +sig = b64url(r.to_bytes(32, 'big') + s.to_bytes(32, 'big')) +print(f"{hdr}.{pld}.{sig}") +PYEOF + ) HTTP_STATUS=$(curl -s -o /tmp/tf_response.json -w "%{http_code}" \ -H "Authorization: Bearer $JWT" \ diff --git a/.github/workflows/testflight.yml b/.github/workflows/testflight.yml index afb93cd..863100a 100644 --- a/.github/workflows/testflight.yml +++ b/.github/workflows/testflight.yml @@ -197,26 +197,30 @@ jobs: exit 1 fi - KEY_FILE=$(mktemp) - if echo "$API_KEY_BASE64" | grep -q "BEGIN"; then - printf '%s' "$API_KEY_BASE64" > "$KEY_FILE" - else - echo "$API_KEY_BASE64" | base64 --decode > "$KEY_FILE" - fi - echo "Key file first line: $(head -1 "$KEY_FILE")" - echo "Key file size: $(wc -c < "$KEY_FILE") bytes" - openssl ec -in "$KEY_FILE" -noout 2>&1 || openssl pkey -in "$KEY_FILE" -noout 2>&1 || echo "::warning::Key check failed" - - NOW=$(date +%s) - EXP=$((NOW + 1200)) - HEADER=$(printf '{"alg":"ES256","kid":"%s","typ":"JWT"}' "$API_KEY_ID" | base64 -w 0 | tr -d '=' | tr '+/' '-_') - PAYLOAD=$(printf '{"iss":"%s","iat":%d,"exp":%d,"aud":"appstoreconnect-v1"}' "$API_ISSUER_ID" "$NOW" "$EXP" | base64 -w 0 | tr -d '=' | tr '+/' '-_') - # openssl produces DER-encoded ECDSA; JWT ES256 requires raw 64-byte R||S - SIGNATURE=$(printf '%s.%s' "$HEADER" "$PAYLOAD" | \ - openssl dgst -binary -sha256 -sign "$KEY_FILE" | \ - python3 -c "import sys,base64;d=sys.stdin.buffer.read();i=2;rl=d[i+1];r=d[i+2:i+2+rl];i+=2+rl;sl=d[i+1];s=d[i+2:i+2+sl];r=r.lstrip(b'\x00').rjust(32,b'\x00');s=s.lstrip(b'\x00').rjust(32,b'\x00');print(base64.urlsafe_b64encode(r[-32:]+s[-32:]).rstrip(b'=').decode())") - JWT="${HEADER}.${PAYLOAD}.${SIGNATURE}" - rm -f "$KEY_FILE" + pip3 install cryptography --quiet --disable-pip-version-check + + JWT=$(python3 - << 'PYEOF' +import os, time, json, base64 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature + +raw = os.environ['API_KEY_BASE64'] +pem = raw.encode() if b'BEGIN' in raw.encode() else base64.b64decode(raw) +key = serialization.load_pem_private_key(pem, password=None) + +now = int(time.time()) +def b64url(data): return base64.urlsafe_b64encode(data).rstrip(b'=').decode() + +hdr = b64url(json.dumps({"alg":"ES256","kid":os.environ['API_KEY_ID'],"typ":"JWT"}).encode()) +pld = b64url(json.dumps({"iss":os.environ['API_ISSUER_ID'],"iat":now,"exp":now+1200,"aud":"appstoreconnect-v1"}).encode()) + +sig_der = key.sign(f"{hdr}.{pld}".encode(), ec.ECDSA(hashes.SHA256())) +r, s = decode_dss_signature(sig_der) +sig = b64url(r.to_bytes(32, 'big') + s.to_bytes(32, 'big')) +print(f"{hdr}.{pld}.{sig}") +PYEOF + ) HTTP_STATUS=$(curl -s -o /tmp/tf_response.json -w "%{http_code}" \ -H "Authorization: Bearer $JWT" \ From 63df0155f9686669f68d90f982bc9fd80708b6c0 Mon Sep 17 00:00:00 2001 From: evgeniyChepelev <68751844+evgeniyChepelev@users.noreply.github.com> Date: Thu, 23 Apr 2026 11:01:08 +0200 Subject: [PATCH 11/12] code refactoring --- .github/workflows/release.yml | 40 ++++++++++++++------------------ .github/workflows/testflight.yml | 40 ++++++++++++++------------------ 2 files changed, 34 insertions(+), 46 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c77bc56..7bfb509 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,29 +33,23 @@ jobs: run: | set -euo pipefail pip3 install cryptography --quiet --disable-pip-version-check - - JWT=$(python3 - << 'PYEOF' -import os, time, json, base64 -from cryptography.hazmat.primitives import hashes, serialization -from cryptography.hazmat.primitives.asymmetric import ec -from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature - -raw = os.environ['API_KEY_BASE64'] -pem = raw.encode() if b'BEGIN' in raw.encode() else base64.b64decode(raw) -key = serialization.load_pem_private_key(pem, password=None) - -now = int(time.time()) -def b64url(data): return base64.urlsafe_b64encode(data).rstrip(b'=').decode() - -hdr = b64url(json.dumps({"alg":"ES256","kid":os.environ['API_KEY_ID'],"typ":"JWT"}).encode()) -pld = b64url(json.dumps({"iss":os.environ['API_ISSUER_ID'],"iat":now,"exp":now+1200,"aud":"appstoreconnect-v1"}).encode()) - -sig_der = key.sign(f"{hdr}.{pld}".encode(), ec.ECDSA(hashes.SHA256())) -r, s = decode_dss_signature(sig_der) -sig = b64url(r.to_bytes(32, 'big') + s.to_bytes(32, 'big')) -print(f"{hdr}.{pld}.{sig}") -PYEOF - ) + JWT=$(python3 -c " + import os, time, json, base64 + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric import ec + from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature + raw = os.environ['API_KEY_BASE64'] + pem = raw.encode() if b'BEGIN' in raw.encode() else base64.b64decode(raw) + key = serialization.load_pem_private_key(pem, password=None) + now = int(time.time()) + b64u = lambda d: base64.urlsafe_b64encode(d).rstrip(b'=').decode() + hdr = b64u(json.dumps({'alg':'ES256','kid':os.environ['API_KEY_ID'],'typ':'JWT'}).encode()) + pld = b64u(json.dumps({'iss':os.environ['API_ISSUER_ID'],'iat':now,'exp':now+1200,'aud':'appstoreconnect-v1'}).encode()) + sig_der = key.sign((hdr+'.'+pld).encode(), ec.ECDSA(hashes.SHA256())) + r, s = decode_dss_signature(sig_der) + sig = b64u(r.to_bytes(32,'big') + s.to_bytes(32,'big')) + print(hdr+'.'+pld+'.'+sig) + ") HTTP_STATUS=$(curl -s -o /tmp/tf_response.json -w "%{http_code}" \ -H "Authorization: Bearer $JWT" \ diff --git a/.github/workflows/testflight.yml b/.github/workflows/testflight.yml index 863100a..dde5ed7 100644 --- a/.github/workflows/testflight.yml +++ b/.github/workflows/testflight.yml @@ -198,29 +198,23 @@ jobs: fi pip3 install cryptography --quiet --disable-pip-version-check - - JWT=$(python3 - << 'PYEOF' -import os, time, json, base64 -from cryptography.hazmat.primitives import hashes, serialization -from cryptography.hazmat.primitives.asymmetric import ec -from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature - -raw = os.environ['API_KEY_BASE64'] -pem = raw.encode() if b'BEGIN' in raw.encode() else base64.b64decode(raw) -key = serialization.load_pem_private_key(pem, password=None) - -now = int(time.time()) -def b64url(data): return base64.urlsafe_b64encode(data).rstrip(b'=').decode() - -hdr = b64url(json.dumps({"alg":"ES256","kid":os.environ['API_KEY_ID'],"typ":"JWT"}).encode()) -pld = b64url(json.dumps({"iss":os.environ['API_ISSUER_ID'],"iat":now,"exp":now+1200,"aud":"appstoreconnect-v1"}).encode()) - -sig_der = key.sign(f"{hdr}.{pld}".encode(), ec.ECDSA(hashes.SHA256())) -r, s = decode_dss_signature(sig_der) -sig = b64url(r.to_bytes(32, 'big') + s.to_bytes(32, 'big')) -print(f"{hdr}.{pld}.{sig}") -PYEOF - ) + JWT=$(python3 -c " + import os, time, json, base64 + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric import ec + from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature + raw = os.environ['API_KEY_BASE64'] + pem = raw.encode() if b'BEGIN' in raw.encode() else base64.b64decode(raw) + key = serialization.load_pem_private_key(pem, password=None) + now = int(time.time()) + b64u = lambda d: base64.urlsafe_b64encode(d).rstrip(b'=').decode() + hdr = b64u(json.dumps({'alg':'ES256','kid':os.environ['API_KEY_ID'],'typ':'JWT'}).encode()) + pld = b64u(json.dumps({'iss':os.environ['API_ISSUER_ID'],'iat':now,'exp':now+1200,'aud':'appstoreconnect-v1'}).encode()) + sig_der = key.sign((hdr+'.'+pld).encode(), ec.ECDSA(hashes.SHA256())) + r, s = decode_dss_signature(sig_der) + sig = b64u(r.to_bytes(32,'big') + s.to_bytes(32,'big')) + print(hdr+'.'+pld+'.'+sig) + ") HTTP_STATUS=$(curl -s -o /tmp/tf_response.json -w "%{http_code}" \ -H "Authorization: Bearer $JWT" \ From b9a57d122517893b9a89aef3c3c589124b65187a Mon Sep 17 00:00:00 2001 From: evgeniyChepelev <68751844+evgeniyChepelev@users.noreply.github.com> Date: Thu, 23 Apr 2026 12:51:00 +0200 Subject: [PATCH 12/12] Update ci/cd --- .github/workflows/release.yml | 1 - .github/workflows/testflight.yml | 1 - 2 files changed, 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7bfb509..cc4f3eb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,7 +32,6 @@ jobs: VERSION: ${{ steps.parse.outputs.version }} run: | set -euo pipefail - pip3 install cryptography --quiet --disable-pip-version-check JWT=$(python3 -c " import os, time, json, base64 from cryptography.hazmat.primitives import hashes, serialization diff --git a/.github/workflows/testflight.yml b/.github/workflows/testflight.yml index dde5ed7..4d99041 100644 --- a/.github/workflows/testflight.yml +++ b/.github/workflows/testflight.yml @@ -197,7 +197,6 @@ jobs: exit 1 fi - pip3 install cryptography --quiet --disable-pip-version-check JWT=$(python3 -c " import os, time, json, base64 from cryptography.hazmat.primitives import hashes, serialization