ci: add action to create PR test builds

This commit is contained in:
Marius Butz 2026-05-16 12:55:41 +02:00
parent b27503407d
commit d2aa358bb9

247
.github/workflows/pr-test-build.yml vendored Normal file
View 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,
})