mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-16 15:01:59 +00:00
ci: add action to create PR test builds
This commit is contained in:
parent
b27503407d
commit
d2aa358bb9
1 changed files with 247 additions and 0 deletions
247
.github/workflows/pr-test-build.yml
vendored
Normal file
247
.github/workflows/pr-test-build.yml
vendored
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
name: PR test build
|
||||
|
||||
# Two flows in one workflow:
|
||||
# 1. When a PR is opened, the bot drops a greeting that tells the author
|
||||
# how to request a testable APK.
|
||||
# 2. When anyone comments `!test-build` on a PR, build the Android APK from
|
||||
# the PR head and reply with a download link. Maintainer comments get a
|
||||
# fully-configured build (runtime secrets injected); everyone else gets
|
||||
# an APK built with empty runtime config, so the binary can be installed
|
||||
# but cannot exfiltrate secrets.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened]
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
greet:
|
||||
name: Greet new PR
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Post greeting comment
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const body = [
|
||||
`### Thanks for the PR! :tada:`,
|
||||
``,
|
||||
`If you'd like a **testable Android APK** built from this branch, post a comment containing:`,
|
||||
``,
|
||||
'```',
|
||||
'!test-build',
|
||||
'```',
|
||||
``,
|
||||
`A maintainer triggering the build will produce a fully-configured APK. Builds triggered by non-maintainers run **without runtime config** (Supabase, Trakt, IntroDB, IMDB, community URLs are empty) so the binary is installable but cannot reach those services.`,
|
||||
].join('\n')
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body,
|
||||
})
|
||||
|
||||
test-build:
|
||||
name: Build APK for PR
|
||||
if: >-
|
||||
github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request != null &&
|
||||
startsWith(github.event.comment.body, '!test-build')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: Acknowledge command (rocket reaction)
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
await github.rest.reactions.createForIssueComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: context.payload.comment.id,
|
||||
content: 'rocket',
|
||||
})
|
||||
|
||||
# author_association reflects the commenter's relationship to the BASE
|
||||
# repo (not the fork), which is exactly what we want for trust gating.
|
||||
# Trusted associations: repo owner, org member, or explicit collaborator.
|
||||
- name: Resolve PR metadata and trust level
|
||||
id: pr
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const pr = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.issue.number,
|
||||
})
|
||||
const trustedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR']
|
||||
const trusted = trustedAssociations.includes(
|
||||
context.payload.comment.author_association
|
||||
)
|
||||
core.setOutput('sha', pr.data.head.sha)
|
||||
core.setOutput('short_sha', pr.data.head.sha.slice(0, 7))
|
||||
core.setOutput('repo_full_name', pr.data.head.repo.full_name)
|
||||
core.setOutput('trusted', trusted ? 'true' : 'false')
|
||||
core.info(`PR #${context.issue.number} head: ${pr.data.head.sha}`)
|
||||
core.info(`Commenter association: ${context.payload.comment.author_association} (trusted=${trusted})`)
|
||||
|
||||
- name: Checkout PR head
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: ${{ steps.pr.outputs.repo_full_name }}
|
||||
ref: ${{ steps.pr.outputs.sha }}
|
||||
submodules: recursive
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
|
||||
- name: Set up Gradle
|
||||
uses: gradle/actions/setup-gradle@v4
|
||||
|
||||
- name: Make gradlew executable
|
||||
run: chmod +x ./gradlew
|
||||
|
||||
# Runtime config only flows in for trusted commenters. Untrusted builds
|
||||
# configure the build script with no local.properties, so the generated
|
||||
# config classes hold empty strings — same behavior as a fresh clone.
|
||||
- name: Write runtime config to local.properties (trusted only)
|
||||
if: steps.pr.outputs.trusted == 'true'
|
||||
uses: ./.github/actions/write-runtime-config
|
||||
with:
|
||||
supabase-url: ${{ secrets.SUPABASE_URL }}
|
||||
supabase-anon-key: ${{ secrets.SUPABASE_ANON_KEY }}
|
||||
trakt-client-id: ${{ secrets.TRAKT_CLIENT_ID }}
|
||||
trakt-client-secret: ${{ secrets.TRAKT_CLIENT_SECRET }}
|
||||
trakt-redirect-uri: ${{ secrets.TRAKT_REDIRECT_URI }}
|
||||
introdb-api-url: ${{ secrets.INTRODB_API_URL }}
|
||||
imdb-ratings-api-base-url: ${{ secrets.IMDB_RATINGS_API_BASE_URL }}
|
||||
imdb-tapframe-api-base-url: ${{ secrets.IMDB_TAPFRAME_API_BASE_URL }}
|
||||
contributions-url: ${{ secrets.CONTRIBUTIONS_URL }}
|
||||
donations-base-url: ${{ secrets.DONATIONS_BASE_URL }}
|
||||
donations-donate-url: ${{ secrets.DONATIONS_DONATE_URL }}
|
||||
|
||||
- name: Build Android APK (full, debug)
|
||||
run: ./gradlew :composeApp:assembleFullDebug --stacktrace
|
||||
|
||||
- name: Stage APK with descriptive name
|
||||
id: stage
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p artifacts
|
||||
src=$(ls composeApp/build/outputs/apk/full/debug/composeApp-full-debug*.apk 2>/dev/null | head -n1 || true)
|
||||
if [[ -z "$src" ]]; then
|
||||
echo "::error::Built APK not found under composeApp/build/outputs/apk/full/debug/"
|
||||
ls -la composeApp/build/outputs/apk/full/debug/ || true
|
||||
exit 1
|
||||
fi
|
||||
apk_name="Nuvio-pr${PR_NUMBER}-${SHORT_SHA}-full-debug.apk"
|
||||
cp "$src" "artifacts/$apk_name"
|
||||
echo "apk_name=$apk_name" >> "$GITHUB_OUTPUT"
|
||||
ls -la artifacts/
|
||||
env:
|
||||
PR_NUMBER: ${{ github.event.issue.number }}
|
||||
SHORT_SHA: ${{ steps.pr.outputs.short_sha }}
|
||||
|
||||
- name: Upload APK artifact
|
||||
id: upload
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ steps.stage.outputs.apk_name }}
|
||||
path: artifacts/*.apk
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
- name: Post download comment
|
||||
if: success()
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
TRUSTED: ${{ steps.pr.outputs.trusted }}
|
||||
APK_NAME: ${{ steps.stage.outputs.apk_name }}
|
||||
ARTIFACT_NAME: ${{ steps.stage.outputs.apk_name }}
|
||||
PR_SHORT_SHA: ${{ steps.pr.outputs.short_sha }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
with:
|
||||
script: |
|
||||
const trusted = process.env.TRUSTED === 'true'
|
||||
const apkName = process.env.APK_NAME
|
||||
const artifactName = process.env.ARTIFACT_NAME
|
||||
const runUrl = process.env.RUN_URL
|
||||
const prShortSha = process.env.PR_SHORT_SHA
|
||||
|
||||
const cfgBanner = trusted
|
||||
? ':white_check_mark: Built **with** runtime config — fully functional.'
|
||||
: ':warning: Built **without** runtime config — Supabase, Trakt, IntroDB, IMDB, and community URLs are empty. The app installs and runs but cannot reach those services. A maintainer can re-trigger by commenting `!test-build` for a fully-configured build.'
|
||||
|
||||
// --- Download section: GitHub workflow artifact (requires login) ---
|
||||
const downloadSection = [
|
||||
'### Download',
|
||||
'',
|
||||
`[**Open workflow run**](${runUrl}) → scroll to the **Artifacts** section → \`${artifactName}\``,
|
||||
'',
|
||||
'_Downloading workflow artifacts requires being signed in to GitHub._',
|
||||
].join('\n')
|
||||
|
||||
// --- Alternative: nightly.link (public, no GitHub login required) ---
|
||||
// To swap the download mechanism, delete `downloadSection` above and
|
||||
// uncomment the block below. nightly.link is a third-party rewriter
|
||||
// (https://nightly.link) that exposes workflow artifacts as
|
||||
// unauthenticated download URLs.
|
||||
//
|
||||
// const nightlyUrl =
|
||||
// `https://nightly.link/${context.repo.owner}/${context.repo.repo}` +
|
||||
// `/actions/runs/${context.runId}/${artifactName}.zip`
|
||||
// const downloadSection = [
|
||||
// '### Download',
|
||||
// '',
|
||||
// `[**\`${apkName}\` (zip)**](${nightlyUrl}) — public link via [nightly.link](https://nightly.link), no GitHub login required.`,
|
||||
// '',
|
||||
// `Alternative: [open workflow run](${runUrl}) and grab the artifact directly (login required).`,
|
||||
// ].join('\n')
|
||||
|
||||
const body = [
|
||||
'### Test build ready',
|
||||
'',
|
||||
cfgBanner,
|
||||
'',
|
||||
`Built from PR head \`${prShortSha}\`. Artifact retained for 14 days.`,
|
||||
'',
|
||||
downloadSection,
|
||||
].join('\n')
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body,
|
||||
})
|
||||
|
||||
- name: Post failure comment
|
||||
if: failure()
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
with:
|
||||
script: |
|
||||
const body = [
|
||||
'### Test build failed :x:',
|
||||
'',
|
||||
`The Android build for this PR did not complete. See [workflow run](${process.env.RUN_URL}) for logs.`,
|
||||
].join('\n')
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body,
|
||||
})
|
||||
Loading…
Reference in a new issue