Fix route status indicator for dynamic (DNS) routes (#117) #261
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: TestFlight | |
| on: | |
| push: | |
| branches: [main] | |
| 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 == 'workflow_dispatch' || | |
| (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 }} | |
| build-number-tvos: ${{ steps.finalize.outputs.build-number-tvos }} | |
| 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 === 'workflow_dispatch' || 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, | |
| }); | |
| 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' | |
| id: asc-build | |
| 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_IOS: ${{ secrets.APP_STORE_APP_ID_IOS }} | |
| APP_ID_TVOS: ${{ secrets.APP_STORE_APP_ID_TVOS }} | |
| run: | | |
| VERSION="${{ steps.pre.outputs.version-override || steps.derive.outputs.version }}" | |
| pip install cryptography --quiet | |
| echo "$PRIVATE_KEY_BASE64" | base64 --decode > /tmp/AuthKey.p8 | |
| trap 'rm -f /tmp/AuthKey.p8' EXIT | |
| JWT=$(python3 -c "import base64,json,time,os;from cryptography.hazmat.primitives import hashes,serialization;from cryptography.hazmat.primitives.asymmetric import ec;from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature;key=serialization.load_pem_private_key(open('/tmp/AuthKey.p8','rb').read(),password=None);b64url=lambda d:base64.urlsafe_b64encode(d if isinstance(d,bytes) else d.encode()).rstrip(b'=').decode();now=int(time.time());h=b64url(json.dumps({'alg':'ES256','kid':os.environ['KEY_ID'],'typ':'JWT'},separators=(',',':')));p=b64url(json.dumps({'iss':os.environ['ISSUER_ID'],'exp':now+1200,'aud':'appstoreconnect-v1'},separators=(',',':')));msg=f'{h}.{p}'.encode();sig_der=key.sign(msg,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'{h}.{p}.{sig}')") | |
| fetch_latest() { | |
| local APP_ID=$1 | |
| local RESP STATUS LATEST | |
| STATUS=$(curl -sg \ | |
| -o /tmp/asc_response.json \ | |
| -w "%{http_code}" \ | |
| "https://api.appstoreconnect.apple.com/v1/builds?filter[app]=$APP_ID&filter[preReleaseVersion.version]=$VERSION&sort=-uploadedDate&limit=1" \ | |
| -H "Authorization: Bearer $JWT") | |
| RESP=$(cat /tmp/asc_response.json) | |
| if [ "$STATUS" != "200" ]; then | |
| echo "API error (app=$APP_ID):" && echo "$RESP" | jq . || echo "$RESP" | |
| exit 1 | |
| fi | |
| echo "$RESP" | jq -r 'if (.data | length) > 0 and (.data[0].attributes.version != null) then .data[0].attributes.version else "none" end' | |
| } | |
| LATEST_BUILD=$(fetch_latest "$APP_ID_IOS") | |
| LATEST_BUILD_TVOS=$(fetch_latest "$APP_ID_TVOS") | |
| echo "latest-build=$LATEST_BUILD" >> "$GITHUB_OUTPUT" | |
| echo "latest-build-tvos=$LATEST_BUILD_TVOS" >> "$GITHUB_OUTPUT" | |
| echo "=========================================" | |
| echo " App Store Connect — latest build info" | |
| echo " Version: $VERSION" | |
| echo " iOS latest: $LATEST_BUILD" | |
| echo " tvOS latest: $LATEST_BUILD_TVOS" | |
| echo "=========================================" | |
| - name: Finalize outputs | |
| id: finalize | |
| uses: actions/github-script@v7 | |
| env: | |
| PRE_REF: ${{ steps.pre.outputs.ref }} | |
| PRE_SHOULD_BUILD: ${{ steps.pre.outputs.should-build }} | |
| PRE_UPLOAD: ${{ steps.pre.outputs.upload }} | |
| PRE_NETBIRD_REF: ${{ steps.pre.outputs.netbird-ref }} | |
| PRE_BUILD_NUMBER: ${{ steps.pre.outputs.build-number }} | |
| PRE_VERSION_OVERRIDE: ${{ steps.pre.outputs.version-override }} | |
| DERIVE_VERSION: ${{ steps.derive.outputs.version }} | |
| ASC_LATEST_BUILD: ${{ steps.asc-build.outputs.latest-build }} | |
| ASC_LATEST_BUILD_TVOS: ${{ steps.asc-build.outputs.latest-build-tvos }} | |
| GH_RUN_NUMBER: ${{ github.run_number }} | |
| with: | |
| script: | | |
| core.setOutput('ref', process.env.PRE_REF); | |
| core.setOutput('should-build', process.env.PRE_SHOULD_BUILD); | |
| core.setOutput('upload', process.env.PRE_UPLOAD); | |
| core.setOutput('netbird-ref', process.env.PRE_NETBIRD_REF); | |
| const overrideBuild = process.env.PRE_BUILD_NUMBER || ''; | |
| const latestBuild = process.env.ASC_LATEST_BUILD || ''; | |
| const runNumber = process.env.GH_RUN_NUMBER || ''; | |
| let buildNumber; | |
| if (overrideBuild && overrideBuild !== runNumber) { | |
| buildNumber = overrideBuild; | |
| } else if (latestBuild && latestBuild !== 'none') { | |
| const parsed = parseInt(latestBuild, 10); | |
| buildNumber = !isNaN(parsed) ? String(parsed + 1) : '1'; | |
| } else { | |
| buildNumber = '1'; | |
| } | |
| core.info(`build-number: ${buildNumber} (latest=${latestBuild}, override=${overrideBuild})`); | |
| core.setOutput('build-number', buildNumber); | |
| const latestBuildTvos = process.env.ASC_LATEST_BUILD_TVOS || ''; | |
| let buildNumberTvos; | |
| if (overrideBuild && overrideBuild !== runNumber) { | |
| buildNumberTvos = overrideBuild; | |
| } else if (latestBuildTvos && latestBuildTvos !== 'none') { | |
| const parsed = parseInt(latestBuildTvos, 10); | |
| buildNumberTvos = !isNaN(parsed) ? String(parsed + 1) : '1'; | |
| } else { | |
| buildNumberTvos = '1'; | |
| } | |
| core.info(`build-number-tvos: ${buildNumberTvos} (latest=${latestBuildTvos})`); | |
| core.setOutput('build-number-tvos', buildNumberTvos); | |
| const override = process.env.PRE_VERSION_OVERRIDE || ''; | |
| const derived = process.env.DERIVE_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 }} | |
| version: ${{ needs.gate.outputs.version }} | |
| build-number: ${{ needs.gate.outputs.build-number-tvos }} | |
| 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, | |
| }); | |
| notify-merge: | |
| name: Comment on merge commit | |
| needs: [gate, build, build-tvos] | |
| if: > | |
| always() && | |
| github.event_name == 'push' && | |
| needs.gate.outputs.should-build == 'true' | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: write | |
| steps: | |
| - name: Comment on commit | |
| uses: actions/github-script@v7 | |
| env: | |
| VERSION: ${{ needs.gate.outputs.version }} | |
| BUILD_NUMBER: ${{ needs.gate.outputs.build-number }} | |
| IOS_RESULT: ${{ needs.build.result }} | |
| TVOS_RESULT: ${{ needs.build-tvos.result }} | |
| with: | |
| script: | | |
| const version = process.env.VERSION; | |
| const buildNumber = process.env.BUILD_NUMBER; | |
| const iosResult = process.env.IOS_RESULT; | |
| const tvosResult = process.env.TVOS_RESULT; | |
| const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; | |
| const iosOk = iosResult === 'success'; | |
| const tvosOk = tvosResult === 'success'; | |
| const iosBadge = iosOk ? '✅' : '❌'; | |
| const tvosBadge = tvosOk ? '✅' : '❌'; | |
| const body = `**TestFlight** \`${version} (${buildNumber})\` — iOS ${iosBadge} tvOS ${tvosBadge}\n\n[View workflow run](${runUrl})`; | |
| await github.rest.repos.createCommitComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| commit_sha: context.sha, | |
| body, | |
| }); |