diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index fc5fe53c2..bb157e6e9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 diff --git a/.github/workflows/pr-release-publish.yml b/.github/workflows/pr-release-publish.yml index 85f972a1b..452b6af63 100644 --- a/.github/workflows/pr-release-publish.yml +++ b/.github/workflows/pr-release-publish.yml @@ -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='' 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' }} diff --git a/tools/release/report-build.sh b/tools/release/report-build.sh index 835bb6c65..e9888229d 100644 --- a/tools/release/report-build.sh +++ b/tools/release/report-build.sh @@ -13,7 +13,7 @@ MARK="" 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="" 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."