Skip to content
34 changes: 33 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,54 @@ 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: ${{ 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}"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
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"
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 | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set +e

echo "Without --globoff:"
curl -sS -o /dev/null 'https://example.invalid/v1/builds?filter[app]=123' 2>&1
echo "exit=$?"

echo
echo "With --globoff:"
curl --globoff -sS -o /dev/null --connect-timeout 1 'https://example.invalid/v1/builds?filter[app]=123' 2>&1
echo "exit=$?"

Repository: netbirdio/ios-client

Length of output: 296


🏁 Script executed:

cat -n .github/workflows/release.yml | sed -n '40,60p'

Repository: netbirdio/ios-client

Length of output: 1264


Do not mask TestFlight lookup failures as build number 1.

The URL contains filter[app]-style brackets, which curl treats as URL globbing unless --globoff is used. Failures are currently swallowed with || echo '{}', silently converting auth/query/network failures into LATEST=0 and emitting build-number=1. Add --globoff to disable globbing, remove the failure-masking pattern, and validate the response is numeric:

🛠️ Proposed fix to fail closed and disable curl globbing
-          RESPONSE=$(curl -sf \
+          RESPONSE=$(curl --globoff -fsS \
             -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"')
+          LATEST=$(jq -r '.data[0].attributes.version // "0"' <<< "$RESPONSE")
+          if ! [[ "$LATEST" =~ ^[0-9]+$ ]]; then
+            echo "::error::Latest TestFlight build number is not numeric: '$LATEST'"
+            exit 1
+          fi
           NEXT=$((LATEST + 1))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.github/workflows/release.yml around lines 45 - 52, The curl call that sets
RESPONSE currently masks failures and allows shell globbing; update the call
that produces RESPONSE (the block using curl and assigning RESPONSE) to add
--globoff to disable URL globbing, remove the "|| echo '{}'" fallback so
failures propagate, then parse LATEST from RESPONSE as before but validate that
LATEST is a numeric value (the variable LATEST extracted from jq) and if not
numeric or empty, print a clear error and exit non-zero instead of defaulting to
0; finally compute NEXT and write build-number only after validation succeeds
(symbols to edit: RESPONSE assignment, LATEST, NEXT).

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
46 changes: 43 additions & 3 deletions .github/workflows/testflight.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,15 +39,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 Down Expand Up @@ -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}"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
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"
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 | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set +e

echo "Without --globoff:"
curl -sS -o /dev/null 'https://example.invalid/v1/builds?filter[app]=123' 2>&1
echo "exit=$?"

echo
echo "With --globoff:"
curl --globoff -sS -o /dev/null --connect-timeout 1 'https://example.invalid/v1/builds?filter[app]=123' 2>&1
echo "exit=$?"

Repository: netbirdio/ios-client

Length of output: 296


Add --globoff to the curl command and fail if the API request fails.

The URL contains square brackets (filter[app], filter[preReleaseVersion.version], fields[builds]) which curl interprets as glob patterns by default, causing it to exit with code 3 before reaching the API. The || echo '{}' fallback then masks this (and any legitimate auth/network/API failures) by silently returning an empty object, which causes the build number to default to 1. This risks overwriting existing TestFlight builds.

Proposed fix
-          RESPONSE=$(curl -sf \
+          RESPONSE=$(curl --globoff -fsS \
             -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"')
+          LATEST=$(jq -r '.data[0].attributes.version // "0"' <<< "$RESPONSE")
+          if ! [[ "$LATEST" =~ ^[0-9]+$ ]]; then
+            echo "::error::Latest TestFlight build number is not numeric: '$LATEST'"
+            exit 1
+          fi
           NEXT=$((LATEST + 1))
📝 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
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"
RESPONSE=$(curl --globoff -fsS \
-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" \
)
LATEST=$(jq -r '.data[0].attributes.version // "0"' <<< "$RESPONSE")
if ! [[ "$LATEST" =~ ^[0-9]+$ ]]; then
echo "::error::Latest TestFlight build number is not numeric: '$LATEST'"
exit 1
fi
NEXT=$((LATEST + 1))
echo "build-number=$NEXT" >> "$GITHUB_OUTPUT"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.github/workflows/testflight.yml around lines 176 - 183, The curl invocation
that sets RESPONSE should be changed to include --globoff and to stop masking
failures by removing the "|| echo '{}'" fallback so API/network/auth errors
cause the job to fail; update the command that builds RESPONSE (the curl line
referenced by RESPONSE) to add --globoff and drop the fallback, then ensure the
subsequent logic that parses LATEST and computes NEXT (variables LATEST and
NEXT) relies on a valid RESPONSE (or add an explicit check that RESPONSE is
non-empty or exit with an error) so you don't silently default the build number
to 1.

echo "Latest build for $VERSION: $LATEST → next: $NEXT"

- name: Finalize outputs
id: finalize
uses: actions/github-script@v7
Expand All @@ -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);
Expand Down
Loading