From d86b29a558aced8f2b5be386091f5e6c32e512b2 Mon Sep 17 00:00:00 2001 From: evgeniyChepelev <68751844+evgeniyChepelev@users.noreply.github.com> Date: Thu, 16 Apr 2026 15:55:15 +0200 Subject: [PATCH 1/5] ci: add widget build, tvOS TestFlight, and issue_comment trigger --- .github/workflows/testflight.yml | 166 +++++++++++++++++++++++++------ 1 file changed, 137 insertions(+), 29 deletions(-) diff --git a/.github/workflows/testflight.yml b/.github/workflows/testflight.yml index 943e907..0aa279d 100644 --- a/.github/workflows/testflight.yml +++ b/.github/workflows/testflight.yml @@ -6,13 +6,15 @@ on: pull_request: branches: [main] types: [opened, synchronize, reopened] + issue_comment: + types: [created] permissions: contents: read pull-requests: write concurrency: - group: testflight-${{ github.ref }}-${{ github.head_ref || github.actor_id }} + group: testflight-${{ github.event.pull_request.number || github.event.issue.number || github.ref }} cancel-in-progress: true jobs: @@ -21,29 +23,128 @@ jobs: if: > github.event_name == 'push' || (github.event_name == 'pull_request' && - github.event.pull_request.head.repo.full_name == github.repository) + github.event.pull_request.head.repo.full_name == github.repository) || + github.event_name == 'issue_comment' runs-on: ubuntu-latest outputs: - ref: ${{ steps.resolve.outputs.ref }} - should-build: ${{ steps.resolve.outputs.should-build }} - upload: ${{ steps.resolve.outputs.upload }} - build-number: ${{ steps.resolve.outputs.build-number }} + 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 build ref - id: resolve + - 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', 'true'); - core.setOutput('upload', 'true'); + core.setOutput('should-build', 'false'); if (context.eventName === 'push') { core.setOutput('ref', context.sha); - } else if (context.eventName === 'pull_request') { + core.setOutput('should-build', 'true'); + core.setOutput('upload', 'true'); + return; + } + + if (context.eventName === 'pull_request') { core.setOutput('ref', context.payload.pull_request.head.sha); + core.setOutput('should-build', 'true'); + core.setOutput('upload', 'true'); + 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 || ''; + if (!body.includes('/testflight')) { + 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.data.head.sha); + core.setOutput('should-build', 'true'); + core.setOutput('upload', 'true'); + + // Parse overrides from /testflight line + const line = body.split('\n').find(l => l.trim().startsWith('/testflight')) || ''; + const versionMatch = line.match(/version=(\S+)/); + if (versionMatch) core.setOutput('version-override', versionMatch[1]); + const buildMatch = line.match(/build-number=(\S+)/); + if (buildMatch) core.setOutput('build-number', buildMatch[1]); + const netbirdMatch = line.match(/netbird-ref=(\S+)/); + if (netbirdMatch) core.setOutput('netbird-ref', netbirdMatch[1]); } + - 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: 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 @@ -51,6 +152,8 @@ jobs: 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 @@ -69,7 +172,10 @@ jobs: notify: name: Notify PR needs: [gate, build, build-tvos] - if: always() && github.event_name == 'pull_request' + 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 @@ -78,27 +184,29 @@ jobs: 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 prNumber = context.payload.pull_request.number; + 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 iosFailed = iosResult === 'failure'; - const tvosFailed = tvosResult === 'failure'; + 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** \`(${buildNumber})\` for \`${shortSha}\` — iOS + tvOS\n\n[View workflow run](${runUrl})`; - } else if (iosOk && !tvosFailed) { - body = `**TestFlight build uploaded** \`(${buildNumber})\` for \`${shortSha}\` — iOS only (tvOS skipped)\n\n[View workflow run](${runUrl})`; - } else if (iosFailed || tvosFailed) { - const failed = [iosFailed && 'iOS', tvosFailed && 'tvOS'].filter(Boolean).join(', '); - body = `**Build failed** (${failed}) for \`${shortSha}\`\n\n[View workflow run](${runUrl})`; + 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; } @@ -107,5 +215,5 @@ jobs: owner: context.repo.owner, repo: context.repo.repo, issue_number: prNumber, - body: body, + body, }); From f33074d2b0ec795172d7123e439fac2cae7cef47 Mon Sep 17 00:00:00 2001 From: evgeniyChepelev <68751844+evgeniyChepelev@users.noreply.github.com> Date: Thu, 16 Apr 2026 16:08:16 +0200 Subject: [PATCH 2/5] Update build.yml --- .github/workflows/build.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1302028..4cd2641 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -212,7 +212,9 @@ jobs: run: | xcodebuild -resolvePackageDependencies \ -project NetBird.xcodeproj \ - -target "NetBird TV" + -scheme "NetBird TV" \ + -destination 'generic/platform=tvOS' \ + -derivedDataPath $RUNNER_TEMP/DerivedData-tvos - name: Build tvOS App working-directory: ios-client @@ -220,9 +222,10 @@ jobs: set -o pipefail xcodebuild build \ -project NetBird.xcodeproj \ - -target "NetBird TV" \ + -scheme "NetBird TV" \ -destination 'generic/platform=tvOS' \ -configuration Debug \ + -derivedDataPath $RUNNER_TEMP/DerivedData-tvos \ CODE_SIGNING_ALLOWED=NO \ CODE_SIGNING_REQUIRED=NO \ CODE_SIGN_IDENTITY="" \ @@ -237,6 +240,7 @@ jobs: -target "NetBirdTVNetworkExtension" \ -destination 'generic/platform=tvOS' \ -configuration Debug \ + -derivedDataPath $RUNNER_TEMP/DerivedData-tvos \ CODE_SIGNING_ALLOWED=NO \ CODE_SIGNING_REQUIRED=NO \ CODE_SIGN_IDENTITY="" \ From 1311d948d740c5ec6f6bce6df8b4c106c8994c55 Mon Sep 17 00:00:00 2001 From: evgeniyChepelev <68751844+evgeniyChepelev@users.noreply.github.com> Date: Thu, 16 Apr 2026 16:15:46 +0200 Subject: [PATCH 3/5] Update testflight.yml --- .github/workflows/testflight.yml | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/.github/workflows/testflight.yml b/.github/workflows/testflight.yml index 0aa279d..1af3f68 100644 --- a/.github/workflows/testflight.yml +++ b/.github/workflows/testflight.yml @@ -5,7 +5,7 @@ on: branches: [main] pull_request: branches: [main] - types: [opened, synchronize, reopened] + types: [opened, edited] issue_comment: types: [created] @@ -50,9 +50,23 @@ jobs: } if (context.eventName === 'pull_request') { + const body = context.payload.pull_request.body || ''; + if (!body.includes('/testflight')) { + 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'); + + // Parse overrides from PR description + const line = body.split('\n').find(l => l.trim().startsWith('/testflight')) || ''; + const versionMatch = line.match(/version=(\S+)/); + if (versionMatch) core.setOutput('version-override', versionMatch[1]); + const buildMatch = line.match(/build-number=(\S+)/); + if (buildMatch) core.setOutput('build-number', buildMatch[1]); + const netbirdMatch = line.match(/netbird-ref=(\S+)/); + if (netbirdMatch) core.setOutput('netbird-ref', netbirdMatch[1]); return; } From b82a54b472e367b1dfa3d94559ca6ceec62e56b7 Mon Sep 17 00:00:00 2001 From: evgeniyChepelev <68751844+evgeniyChepelev@users.noreply.github.com> Date: Thu, 16 Apr 2026 16:16:30 +0200 Subject: [PATCH 4/5] Update build.yml --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4cd2641..774b103 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -237,7 +237,7 @@ jobs: set -o pipefail xcodebuild build \ -project NetBird.xcodeproj \ - -target "NetBirdTVNetworkExtension" \ + -scheme "NetBirdTVNetworkExtension" \ -destination 'generic/platform=tvOS' \ -configuration Debug \ -derivedDataPath $RUNNER_TEMP/DerivedData-tvos \ From 86e81ecd6124e6419ba12fe34d8609255ea526fb Mon Sep 17 00:00:00 2001 From: evgeniyChepelev <68751844+evgeniyChepelev@users.noreply.github.com> Date: Thu, 16 Apr 2026 16:42:05 +0200 Subject: [PATCH 5/5] Update testflight.yml --- .github/workflows/testflight.yml | 42 ++++++++++++++++++-------------- 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/.github/workflows/testflight.yml b/.github/workflows/testflight.yml index 1af3f68..e9ac177 100644 --- a/.github/workflows/testflight.yml +++ b/.github/workflows/testflight.yml @@ -42,6 +42,16 @@ jobs: 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'); @@ -51,7 +61,8 @@ jobs: if (context.eventName === 'pull_request') { const body = context.payload.pull_request.body || ''; - if (!body.includes('/testflight')) { + const hasCommand = body.split('\n').some(l => l.trim().startsWith('/testflight')); + if (!hasCommand) { core.info('No /testflight in PR description — skipping'); return; } @@ -59,14 +70,7 @@ jobs: core.setOutput('should-build', 'true'); core.setOutput('upload', 'true'); - // Parse overrides from PR description - const line = body.split('\n').find(l => l.trim().startsWith('/testflight')) || ''; - const versionMatch = line.match(/version=(\S+)/); - if (versionMatch) core.setOutput('version-override', versionMatch[1]); - const buildMatch = line.match(/build-number=(\S+)/); - if (buildMatch) core.setOutput('build-number', buildMatch[1]); - const netbirdMatch = line.match(/netbird-ref=(\S+)/); - if (netbirdMatch) core.setOutput('netbird-ref', netbirdMatch[1]); + parseCommand(body); return; } @@ -76,7 +80,8 @@ jobs: return; } const body = context.payload.comment.body || ''; - if (!body.includes('/testflight')) { + const hasCommand = body.split('\n').some(l => l.trim().startsWith('/testflight')); + if (!hasCommand) { core.info('No /testflight command — skipping'); return; } @@ -99,18 +104,19 @@ jobs: 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.data.head.repo.full_name !== baseRepo) { + core.info(`PR is from fork ${pr.data.head.repo.full_name} — skipping`); + return; + } + core.setOutput('ref', pr.data.head.sha); core.setOutput('should-build', 'true'); core.setOutput('upload', 'true'); - // Parse overrides from /testflight line - const line = body.split('\n').find(l => l.trim().startsWith('/testflight')) || ''; - const versionMatch = line.match(/version=(\S+)/); - if (versionMatch) core.setOutput('version-override', versionMatch[1]); - const buildMatch = line.match(/build-number=(\S+)/); - if (buildMatch) core.setOutput('build-number', buildMatch[1]); - const netbirdMatch = line.match(/netbird-ref=(\S+)/); - if (netbirdMatch) core.setOutput('netbird-ref', netbirdMatch[1]); + parseCommand(body); } - name: Checkout for version derivation