Skip to content

Update testflight.yml #176

Update testflight.yml

Update testflight.yml #176

Workflow file for this run

name: TestFlight
on:
workflow_dispatch:
push:
pull_request:
branches: [main]
types: [opened, edited]
issue_comment:
types: [created]
permissions:
contents: read
pull-requests: write
concurrency:
group: testflight-${{ github.event.pull_request.number || github.event.issue.number || github.ref }}
cancel-in-progress: true
jobs:
gate:
name: Validate trigger
if: >
github.event_name == 'push' ||
(github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.full_name == github.repository) ||
github.event_name == 'issue_comment'
runs-on: ubuntu-latest
outputs:
ref: ${{ steps.finalize.outputs.ref }}
should-build: ${{ steps.finalize.outputs.should-build }}
upload: ${{ steps.finalize.outputs.upload }}
netbird-ref: ${{ steps.finalize.outputs.netbird-ref }}
version: ${{ steps.finalize.outputs.version }}
build-number: ${{ steps.finalize.outputs.build-number }}
steps:
- name: Resolve ref and check permissions
id: pre
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]);
const netbirdMatch = line.match(/netbird-ref=([a-zA-Z0-9._\/-]+)/);
if (netbirdMatch) core.setOutput('netbird-ref', netbirdMatch[1]);
}
if (context.eventName === 'push') {
core.setOutput('ref', context.sha);
core.setOutput('should-build', 'true');
core.setOutput('upload', 'true');
return;
}
if (context.eventName === 'pull_request') {
const body = context.payload.pull_request.body || '';
const hasCommand = body.split('\n').some(l => l.trim().startsWith('/testflight'));
if (!hasCommand) {
core.info('No /testflight in PR description — skipping');
return;
}
core.setOutput('ref', context.payload.pull_request.head.sha);
core.setOutput('should-build', 'true');
core.setOutput('upload', 'true');
parseCommand(body);
return;
}
if (context.eventName === 'issue_comment') {
if (!context.payload.issue.pull_request) {
core.info('Not a PR comment — skipping');
return;
}
const body = context.payload.comment.body || '';
const hasCommand = body.split('\n').some(l => l.trim().startsWith('/testflight'));
if (!hasCommand) {
core.info('No /testflight command — skipping');
return;
}
// Permission check
const { data: perm } = await github.rest.repos.getCollaboratorPermissionLevel({
owner: context.repo.owner,
repo: context.repo.repo,
username: context.payload.comment.user.login,
});
const level = perm.permission;
if (level !== 'admin' && level !== 'write') {
core.info(`User ${context.payload.comment.user.login} has '${level}' — skipping`);
return;
}
// Get PR head SHA
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.payload.issue.number,
});
// Block fork PRs — untrusted code must not run with privileged credentials
const baseRepo = `${context.repo.owner}/${context.repo.repo}`;
if (pr.head.repo.full_name !== baseRepo) {
core.info(`PR is from fork ${pr.head.repo.full_name} — skipping`);
return;
}
core.setOutput('ref', pr.head.sha);
core.setOutput('should-build', 'true');
core.setOutput('upload', 'true');
parseCommand(body);
}
- name: Checkout for version derivation
if: steps.pre.outputs.should-build == 'true'
uses: actions/checkout@v4
with:
ref: ${{ steps.pre.outputs.ref }}
fetch-depth: 0
fetch-tags: true
sparse-checkout: .
- name: Derive version from git tags
if: steps.pre.outputs.should-build == 'true'
id: derive
run: |
LATEST_TAG=$(git describe --tags --match 'v*' --abbrev=0 2>/dev/null || echo "")
if [ -z "$LATEST_TAG" ]; then
echo "::error::No v* tags found — cannot derive version"
exit 1
elif ! echo "$LATEST_TAG" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "::error::Tag '$LATEST_TAG' is not valid semver (expected vX.Y.Z)"
exit 1
fi
VERSION="${LATEST_TAG#v}"
MAJOR="${VERSION%%.*}"
REST="${VERSION#*.}"
MINOR="${REST%%.*}"
PATCH="${REST#*.}"
NEXT="${MAJOR}.${MINOR}.$((PATCH + 1))"
echo "version=$NEXT" >> "$GITHUB_OUTPUT"
echo "Tag: $LATEST_TAG → next: $NEXT"
- name: Fetch latest build number from App Store Connect
if: steps.pre.outputs.should-build == 'true'
env:
ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
PRIVATE_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }}
APP_ID: ${{ secrets.APP_STORE_APP_ID_IOS }}
run: |
VERSION="${{ steps.pre.outputs.version-override || steps.derive.outputs.version }}"
echo "$PRIVATE_KEY_BASE64" | base64 --decode > /tmp/AuthKey.p8
NOW=$(date +%s)
EXP=$(($NOW + 1200))
HEADER=$(echo -n '{"alg":"ES256","kid":"'"$KEY_ID"'","typ":"JWT"}' | base64 -w0 | tr -d '=' | tr '/+' '_-')
PAYLOAD=$(echo -n '{"iss":"'"$ISSUER_ID"'","exp":'"$EXP"',"aud":"appstoreconnect-v1"}' | base64 -w0 | tr -d '=' | tr '/+' '_-')
SIGNATURE=$(echo -n "$HEADER.$PAYLOAD" | openssl dgst -sha256 -sign /tmp/AuthKey.p8 | base64 -w0 | tr -d '=' | tr '/+' '_-')
JWT="$HEADER.$PAYLOAD.$SIGNATURE"
RESPONSE=$(curl -sfg \
"https://api.appstoreconnect.apple.com/v1/builds?filter[app]=$APP_ID&filter[preReleaseVersion.version]=$VERSION&sort=-uploadedDate&limit=1" \
-H "Authorization: Bearer $JWT")
LATEST_BUILD=$(echo "$RESPONSE" | jq -r 'if (.data | length) > 0 then .data[0].attributes.buildNumber else "none" end')
echo "========================================="
echo " App Store Connect — latest build info"
echo " Version: $VERSION"
echo " Latest uploaded build: $LATEST_BUILD"
echo "========================================="
rm -f /tmp/AuthKey.p8
- name: Finalize outputs
id: finalize
uses: actions/github-script@v7
with:
script: |
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 override = '${{ steps.pre.outputs.version-override }}';
const derived = '${{ steps.derive.outputs.version }}';
core.setOutput('version', override || derived);
core.info(`version: ${override || derived} (override=${override || 'none'}, derived=${derived})`);
build:
name: Build and Upload iOS
needs: gate
if: needs.gate.outputs.should-build == 'true'
uses: ./.github/workflows/build-upload.yml
with:
ref: ${{ needs.gate.outputs.ref }}
netbird-ref: ${{ needs.gate.outputs.netbird-ref }}
version: ${{ needs.gate.outputs.version }}
build-number: ${{ needs.gate.outputs.build-number }}
upload: ${{ needs.gate.outputs.upload == 'true' }}
secrets: inherit
build-tvos:
name: Build and Upload tvOS
needs: gate
if: needs.gate.outputs.should-build == 'true'
uses: ./.github/workflows/build-upload-tvos.yml
with:
ref: ${{ needs.gate.outputs.ref }}
build-number: ${{ needs.gate.outputs.build-number }}
upload: ${{ needs.gate.outputs.upload == 'true' }}
secrets: inherit
notify:
name: Notify PR
needs: [gate, build, build-tvos]
if: >
always() &&
(github.event_name == 'pull_request' || github.event_name == 'issue_comment') &&
needs.gate.outputs.should-build == 'true'
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: Comment build result on PR
uses: actions/github-script@v7
with:
script: |
const iosResult = '${{ needs.build.result }}';
const tvosResult = '${{ needs.build-tvos.result }}';
const ref = '${{ needs.gate.outputs.ref }}';
const shortSha = ref.substring(0, 7);
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
const buildNumber = '${{ needs.gate.outputs.build-number }}';
const version = '${{ needs.gate.outputs.version }}';
const prNumber = context.eventName === 'pull_request'
? context.payload.pull_request.number
: context.payload.issue.number;
const iosOk = iosResult === 'success';
const tvosOk = tvosResult === 'success';
const iosFail = iosResult === 'failure';
const tvosFail = tvosResult === 'failure';
let body;
if (iosOk && tvosOk) {
body = `**TestFlight builds uploaded** \`${version} (${buildNumber})\` for \`${shortSha}\` — iOS + tvOS\n\n[View workflow run](${runUrl})`;
} else if ((iosFail || tvosFail)) {
const failed = [iosFail && 'iOS', tvosFail && 'tvOS'].filter(Boolean).join(', ');
body = `**Build failed** (${failed}) \`${version} (${buildNumber})\` for \`${shortSha}\`\n\n[View workflow run](${runUrl})`;
} else {
return;
}
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body,
});