Skip to content
47 changes: 46 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,67 @@ 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
run: |
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
uses: ./.github/workflows/build-upload.yml
with:
ref: ${{ github.ref }}
version: ${{ needs.prepare.outputs.version }}
build-number: ${{ needs.prepare.outputs.build-number }}
secrets: inherit
95 changes: 91 additions & 4 deletions .github/workflows/testflight.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,20 @@ name: TestFlight

on:
push:
branches: [main]
branches: [main, ci/unified-build-number-from-testflight]
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Remove the feature branch from the push trigger before merging.

ci/unified-build-number-from-testflight is the PR's source branch and shouldn't run TestFlight on every push to it once merged (it also bypasses the PR gate). Drop it before merge.

♻️ Proposed change
-    branches: [main, ci/unified-build-number-from-testflight]
+    branches: [main]
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
branches: [main, ci/unified-build-number-from-testflight]
branches: [main]
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.github/workflows/testflight.yml at line 5, Remove the feature branch
"ci/unified-build-number-from-testflight" from the push trigger in the
TestFlight workflow so it doesn't run on pushes to the PR source after merge;
edit the branches array in .github/workflows/testflight.yml (the line containing
branches: [main, ci/unified-build-number-from-testflight]) and delete the
feature branch entry so only the intended branch(es) (e.g., main) remain.

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
Expand All @@ -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'
Expand All @@ -39,15 +48,14 @@ jobs:
uses: actions/github-script@v7
with:
script: |
core.setOutput('build-number', '${{ github.run_number }}');
core.setOutput('should-build', 'false');

function parseCommand(body) {
const line = body.split('\n').find(l => l.trim().startsWith('/testflight')) || '';
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]);
}
Expand All @@ -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');
Expand Down Expand Up @@ -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
Expand All @@ -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);
Expand Down
Loading