ci: maintainer PR builds are release-signed as soon as the APK uploads

This commit is contained in:
Kurt Himebauch committed 2026-10-30 15:06:48 -04:00
1 parent 7c49eef79f
commit 4bc2aebce6
3 files changed
+160 -69

No files matched your search

+40 -25
View File
@@ -292,6 +292,10 @@ jobs:
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
runs-on: ubuntu-24.04
timeout-minutes: 45
permissions:
contents: read
actions: write
pull-requests: write
outputs:
variants: ${{ steps.variants.outputs.list }}
steps:
@@ -532,7 +536,7 @@ jobs:
run: |
set -o pipefail
python3 tools/release/runtime_inputs.py record
./gradlew assembleRelease --console=plain -PndkVersion="$NDK_VERSION" 2>&1 | tee /tmp/build.log
./gradlew assembleRelease -x lintVitalRelease --console=plain -PndkVersion="$NDK_VERSION" 2>&1 | tee /tmp/build.log
python3 tools/release/check_session_assets.py app/build/outputs/apk/release/app-release.apk
python3 tools/release/runtime_inputs.py apk app/build/outputs/apk/release/app-release.apk
@@ -554,29 +558,6 @@ jobs:
path: app/src/main/rust/stores/Cargo.lock
if-no-files-found: ignore
- name: Unit tests
id: unit-tests
env:
DROIDDECK_BUILD_LABEL: ${{ steps.build-label.outputs.label }}
SGDB_API_KEY: ${{ secrets.SGDB_API_KEY }}
run: |
set -o pipefail
mkdir -p app/build
./gradlew testReleaseUnitTest --console=plain --info --stacktrace -Drobolectric.logging=stderr -PndkVersion="$NDK_VERSION" 2>&1 | tee app/build/unit-tests.log
- name: Upload unit test diagnostics
id: unit-test-diagnostics
if: always() && steps.unit-tests.outcome != 'skipped'
uses: actions/upload-artifact@v4
with:
name: unit-test-results
path: |
app/build/unit-tests.log
app/build/test-results/testReleaseUnitTest/
app/build/reports/tests/testReleaseUnitTest/
if-no-files-found: ignore
retention-days: 14
- name: Sign with the testkey (v1 + v2 + v3)
run: |
set -euo pipefail
@@ -614,7 +595,8 @@ jobs:
retention-days: 30
compression-level: 0
- uses: actions/upload-artifact@v4
- name: Upload the add-on packs
uses: actions/upload-artifact@v4
with:
name: droiddeck-packs
path: app/build/packs/*.tar.zst
@@ -622,6 +604,39 @@ jobs:
retention-days: 30
compression-level: 0
- name: Sign this PR build before its tests finish
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
PR: ${{ github.event.pull_request.number }}
run: |
gh workflow run pr-release-publish.yml -R "$GITHUB_REPOSITORY" --ref main \
-f "pr_number=$PR" -f "apk_run_id=$GITHUB_RUN_ID"
- name: Unit tests
id: unit-tests
env:
DROIDDECK_BUILD_LABEL: ${{ steps.build-label.outputs.label }}
SGDB_API_KEY: ${{ secrets.SGDB_API_KEY }}
run: |
set -o pipefail
mkdir -p app/build
./gradlew lintVitalRelease testReleaseUnitTest --console=plain --info --stacktrace -Drobolectric.logging=stderr -PndkVersion="$NDK_VERSION" 2>&1 | tee app/build/unit-tests.log
- name: Upload unit test diagnostics
id: unit-test-diagnostics
if: always() && steps.unit-tests.outcome != 'skipped'
uses: actions/upload-artifact@v4
with:
name: unit-test-results
path: |
app/build/unit-tests.log
app/build/test-results/testReleaseUnitTest/
app/build/reports/tests/testReleaseUnitTest/
if-no-files-found: ignore
retention-days: 14
- name: Report on the pull request
if: always() && github.event_name == 'pull_request'
continue-on-error: true
+118 -42
View File
@@ -12,6 +12,10 @@ on:
description: Completed build run for automatic publishing
required: false
type: string
apk_run_id:
description: Same-repository build run that has uploaded its APK; signs it before its tests finish
required: false
type: string
pull_request_target:
types: [labeled]
branches: [main]
@@ -81,6 +85,8 @@ jobs:
label: ${{ steps.gate.outputs.label }}
skip: ${{ steps.gate.outputs.skip }}
automatic: ${{ steps.gate.outputs.automatic }}
early: ${{ steps.gate.outputs.early }}
announce: ${{ steps.gate.outputs.announce }}
variants: ${{ steps.variants.outputs.list }}
steps:
- uses: actions/checkout@v4
@@ -100,6 +106,7 @@ jobs:
ACTION: ${{ github.event.action }}
BUILT_SHA: ${{ github.event.workflow_run.head_sha }}
AUTO_RUN: ${{ inputs.automatic_build_run_id }}
APK_RUN: ${{ inputs.apk_run_id }}
run: |
set -euo pipefail
[[ "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]] || { echo "::error::Invalid PR number '$PR_NUMBER'"; exit 1; }
@@ -126,52 +133,83 @@ jobs:
opener=$(jq -r .user.login <<<"$pr")
git show origin/main:.github/trusted-test-builds.txt | grep -Fixq -- "$opener" || skip "PR opener @$opener is not trusted for automatic signing"
echo "automatic=true" >> "$GITHUB_OUTPUT"
elif [ "$EVENT_NAME" = workflow_dispatch ] && [ -n "$APK_RUN" ]; then
[[ "$APK_RUN" =~ ^[1-9][0-9]*$ ]] || { echo '::error::Invalid APK build run ID'; exit 1; }
[ "$(jq -r .head.repo.full_name <<<"$pr")" = "$GITHUB_REPOSITORY" ] || skip "it comes from a fork"
jq -e 'any(.labels[]; .name == "no-test-build")' <<<"$pr" >/dev/null && skip "it is labelled no-test-build"
echo "automatic=true" >> "$GITHUB_OUTPUT"
echo "early=true" >> "$GITHUB_OUTPUT"
fi
head=$(jq -r '.head.sha' <<<"$pr")
base=$(jq -r '.base.sha' <<<"$pr")
if [ "$EVENT_NAME" = workflow_run ] &&
gh release view "pr-$PR_NUMBER" -R Droid-Deck/DroidDeck-CI --json body --jq .body 2>/dev/null | grep -Fq "Commit: $head"; then
{ echo "announce=true"; echo "pr_number=$PR_NUMBER"; echo "head_sha=$head"; } >> "$GITHUB_OUTPUT"
skip "${head:0:7} was signed when its APK was built"
fi
run_id=
for attempt in $(seq 1 240); do
latest=$(gh api "repos/$GITHUB_REPOSITORY/actions/workflows/build.yml/runs?event=pull_request&head_sha=$head&per_page=100" |
jq -c --arg head "$head" --arg repo "$(jq -r .head.repo.full_name <<<"$pr")" --arg ref "$(jq -r .head.ref <<<"$pr")" '
[.workflow_runs[] | select(.head_sha == $head and .head_repository.full_name == $repo and .head_branch == $ref)] |
sort_by([.created_at, .run_attempt]) | last // empty')
if [[ -z "$latest" && "$attempt" == 1 ]]; then
git fetch --no-tags origin main
git fetch --no-tags origin "refs/pull/$PR_NUMBER/head"
git diff --no-renames --name-only -z "$base...FETCH_HEAD" > "$RUNNER_TEMP/pr-changed-paths"
excluded=true
while IFS= read -r -d '' path; do
case "$path" in
*.md|LICENSE|.gitignore) ;;
*) excluded=false; break ;;
esac
done < "$RUNNER_TEMP/pr-changed-paths"
[ "$excluded" = false ] || skip "its changes do not trigger the APK build"
fi
if [[ -n "$latest" && $(jq -r .status <<<"$latest") == completed ]]; then
conclusion=$(jq -r .conclusion <<<"$latest")
if [[ "$conclusion" == success ]]; then
run_id=$(jq -r .id <<<"$latest")
if [ -n "$AUTO_RUN" ] && [ "$run_id" != "$AUTO_RUN" ]; then
skip "a newer build exists"
fi
break
if [ -n "$APK_RUN" ]; then
run=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$APK_RUN")
jq -e --arg head "$head" --arg repo "$GITHUB_REPOSITORY" --arg ref "$(jq -r .head.ref <<<"$pr")" '
.event == "pull_request" and .head_sha == $head and
.head_repository.full_name == $repo and .head_branch == $ref
' <<<"$run" >/dev/null || skip "run $APK_RUN is not a build of ${head:0:7}"
jobs=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$APK_RUN/jobs?per_page=100")
for job in 'Pin source tree' 'Prepare verified components' 'Xbox ARM64 backend'; do
jq -e --arg name "$job" 'any(.jobs[]; .name == $name and .conclusion == "success")' <<<"$jobs" >/dev/null ||
{ echo "::error::$job did not pass in run $APK_RUN"; exit 1; }
done
jq -e 'any(.jobs[]; .name == "Compile APK and Android tests" and
any(.steps[]; .name == "Upload the add-on packs" and .conclusion == "success"))' <<<"$jobs" >/dev/null ||
{ echo "::error::Run $APK_RUN has not uploaded its APK and packs"; exit 1; }
validation=$(gh api "repos/$GITHUB_REPOSITORY/commits/$head/statuses?per_page=100" --jq '
[.[] | select(.context == "Build APK" and .creator.login == "github-actions[bot]")] | first | .description // ""')
[[ "$validation" == "Source validated"* ]] || skip "source validation has not passed for ${head:0:7}"
run_id=$APK_RUN
else
for attempt in $(seq 1 240); do
latest=$(gh api "repos/$GITHUB_REPOSITORY/actions/workflows/build.yml/runs?event=pull_request&head_sha=$head&per_page=100" |
jq -c --arg head "$head" --arg repo "$(jq -r .head.repo.full_name <<<"$pr")" --arg ref "$(jq -r .head.ref <<<"$pr")" '
[.workflow_runs[] | select(.head_sha == $head and .head_repository.full_name == $repo and .head_branch == $ref)] |
sort_by([.created_at, .run_attempt]) | last // empty')
if [[ -z "$latest" && "$attempt" == 1 ]]; then
git fetch --no-tags origin main
git fetch --no-tags origin "refs/pull/$PR_NUMBER/head"
git diff --no-renames --name-only -z "$base...FETCH_HEAD" > "$RUNNER_TEMP/pr-changed-paths"
excluded=true
while IFS= read -r -d '' path; do
case "$path" in
*.md|LICENSE|.gitignore) ;;
*) excluded=false; break ;;
esac
done < "$RUNNER_TEMP/pr-changed-paths"
[ "$excluded" = false ] || skip "its changes do not trigger the APK build"
fi
[[ "$conclusion" == action_required ]] || { echo "::error::The latest PR build is not green ($conclusion)"; exit 1; }
fi
status=not-started
[[ -z "$latest" ]] || status=$(jq -r .status <<<"$latest")
echo "Waiting for the PR build of ${head:0:7} to finish ($status)"
sleep 30
done
[[ -n "$run_id" ]] || { echo '::error::The PR build did not finish within 120 minutes'; exit 1; }
run=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$run_id")
jq -e --arg head "$head" --arg repo "$(jq -r .head.repo.full_name <<<"$pr")" --arg ref "$(jq -r .head.ref <<<"$pr")" '
.event == "pull_request" and .head_sha == $head and .conclusion == "success" and
.head_repository.full_name == $repo and .head_branch == $ref
' <<<"$run" >/dev/null ||
{ echo "::error::Build run $run_id is not a green build of PR #$PR_NUMBER at ${head:0:7}"; exit 1; }
if [[ -n "$latest" && $(jq -r .status <<<"$latest") == completed ]]; then
conclusion=$(jq -r .conclusion <<<"$latest")
if [[ "$conclusion" == success ]]; then
run_id=$(jq -r .id <<<"$latest")
if [ -n "$AUTO_RUN" ] && [ "$run_id" != "$AUTO_RUN" ]; then
skip "a newer build exists"
fi
break
fi
[[ "$conclusion" == action_required ]] || { echo "::error::The latest PR build is not green ($conclusion)"; exit 1; }
fi
status=not-started
[[ -z "$latest" ]] || status=$(jq -r .status <<<"$latest")
echo "Waiting for the PR build of ${head:0:7} to finish ($status)"
sleep 30
done
[[ -n "$run_id" ]] || { echo '::error::The PR build did not finish within 120 minutes'; exit 1; }
run=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$run_id")
jq -e --arg head "$head" --arg repo "$(jq -r .head.repo.full_name <<<"$pr")" --arg ref "$(jq -r .head.ref <<<"$pr")" '
.event == "pull_request" and .head_sha == $head and .conclusion == "success" and
.head_repository.full_name == $repo and .head_branch == $ref
' <<<"$run" >/dev/null ||
{ echo "::error::Build run $run_id is not a green build of PR #$PR_NUMBER at ${head:0:7}"; exit 1; }
fi
sdk=${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}
tools=$(find "$sdk/build-tools" -mindepth 1 -maxdepth 1 -type d | sort -V | tail -1)
@@ -373,7 +411,7 @@ jobs:
short=${HEAD_SHA:0:7}
mark='<!-- droiddeck-pr-release -->'
if [ "$TRUSTED_OPENER" = true ]; then
NEXT='Each green build of a new push is signed and published here on its own. Label the PR `no-test-build` to stop that.'
NEXT='Each new push is signed and published here on its own once its APK builds. Label the PR `no-test-build` to stop that.'
else
NEXT='Pushes to this PR do not re-sign automatically. After a new green build, a maintainer can add the `publish-test` label again.'
fi
@@ -402,7 +440,7 @@ jobs:
fi
- name: Update the Discord PR announcement
if: ${{ always() && steps.publish.outcome == 'success' }}
if: ${{ always() && steps.publish.outcome == 'success' && needs.authorize.outputs.early != 'true' }}
env:
WEBHOOK: ${{ secrets.DISCORD_PR_BUILDS_WEBHOOK }}
GH_TOKEN: ${{ github.token }}
@@ -412,6 +450,44 @@ jobs:
APK: ${{ steps.publish.outputs.apk }}
run: python3 tools/release/pr_discord.py publish
announce:
needs: authorize
if: ${{ needs.authorize.outputs.announce == 'true' }}
runs-on: ubuntu-24.04
timeout-minutes: 5
permissions:
contents: read
issues: write
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
ref: main
persist-credentials: false
- name: Find the signed build
id: release
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ needs.authorize.outputs.pr_number }}
run: |
set -euo pipefail
repo=Droid-Deck/DroidDeck-CI
tag="pr-$PR_NUMBER"
apk=$(gh release view "$tag" -R "$repo" --json assets --jq '.assets[].name' |
awk '/^DroidDeck-.*\.apk$/ && (length(a) == 0 || length($0) < length(a)) { a = $0 } END { print a }')
[ -n "$apk" ] || { echo "::error::$tag has no APK"; exit 1; }
echo "release=$GITHUB_SERVER_URL/$repo/releases/tag/$tag" >> "$GITHUB_OUTPUT"
echo "apk=$GITHUB_SERVER_URL/$repo/releases/download/$tag/$apk" >> "$GITHUB_OUTPUT"
- name: Update the Discord PR announcement
env:
WEBHOOK: ${{ secrets.DISCORD_PR_BUILDS_WEBHOOK }}
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ needs.authorize.outputs.pr_number }}
HEAD_SHA: ${{ needs.authorize.outputs.head_sha }}
RELEASE: ${{ steps.release.outputs.release }}
APK: ${{ steps.release.outputs.apk }}
run: python3 tools/release/pr_discord.py publish
catalog:
needs: publish
if: ${{ always() && needs.publish.result != 'skipped' }}
+2 -2
View File
@@ -13,7 +13,7 @@ MARK="<!-- droiddeck-pr-build -->"
echo
echo "The \`droiddeck-apk\` artifact is [on this run]($RUN_URL), signed with the public test key."
echo
echo "Same-repository PRs and PRs opened by contributors listed in \`.github/trusted-test-builds.txt\` are eligible for automatic release-signed test builds after the full build passes. Label the PR \`no-test-build\` to skip that."
echo "Same-repository PRs get an automatic release-signed test build as soon as the APK is built; PRs opened by contributors listed in \`.github/trusted-test-builds.txt\` get one after the full build passes. Label the PR \`no-test-build\` to skip that."
echo "For other PRs, a maintainer can add the \`publish-test\` label to request a signed build."
echo
echo "Fallback: [open **PR Release Publish**]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/workflows/pr-release-publish.yml) on \`main\` and enter PR \`$PR\`."
@@ -26,7 +26,7 @@ MARK="<!-- droiddeck-pr-build -->"
success:failure:*)
echo "### ERROR: Unit tests failed"
echo
echo "The APK compiled, but unit tests failed. No installable artifact was published."
echo "The APK compiled, but unit tests failed."
echo
if [ -n "$TEST_DIAGNOSTICS_URL" ]; then
echo "The [unit-test-results]($TEST_DIAGNOSTICS_URL) artifact contains the test log and any generated reports."