diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 97dabff..cc4f3eb 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,49 @@ 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: ${{ secrets.APP_STORE_APP_ID_IOS }} + VERSION: ${{ steps.parse.outputs.version }} + run: | + set -euo pipefail + 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" \ + "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[].attributes.version | tonumber] | max // 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 +72,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..4d99041 100644 --- a/.github/workflows/testflight.yml +++ b/.github/workflows/testflight.yml @@ -2,12 +2,20 @@ name: TestFlight on: push: - branches: [main] + branches: [main, ci/unified-build-number-from-testflight] pull_request: branches: [main] 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 @@ -22,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' @@ -39,7 +48,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 +55,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]); } @@ -74,6 +82,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'); @@ -149,6 +168,68 @@ 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' + 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: ${{ secrets.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 }}" + 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 + + 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" \ + "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[].attributes.version | tonumber] | max // 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 +238,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);