diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b2377bf6d..81068f159 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,35 +1,13 @@ name: Build APK run-name: ${{ github.event_name == 'pull_request' && format('building pr {0} on {1}', github.event.pull_request.number, github.event.pull_request.head.sha) || format('building {0} on {1}', github.ref_name, github.sha) }} -# One job: the two glibc preload libraries the session needs, then the app around them. -# Nothing is built on a developer machine - this workflow is the only thing that produces an apk. -# -# This repo is private, so every minute here is billed. Three caches carry the cost of a rebuild -# down to what actually changed: -# • Gradle's own (dependencies, wrapper, build cache, configuration cache) via setup-gradle -# • the CMake/ninja tree, so a Kotlin-only change does not recompile the compositor -# • the two cross-compiled preloads, which also skips installing the cross toolchain -# A run that changes only Kotlin should not touch the C compiler at all. - on: push: branches: [ main ] - # Documentation changes cannot alter the apk, so they do not buy a build. paths-ignore: - '**/*.md' - 'LICENSE' - '.gitignore' - # A pull request against main builds by itself: the contributor's branch, as it would be once - # merged onto main, comes back as an apk on the run without anyone here pressing a button. - # Every push to the pull request rebuilds it and cancels the build it replaces; a draft waits - # until it is marked ready. - # - # pull_request, NOT pull_request_target. The _target form runs a pull request's code with main's - # context: its caches are main's caches, so a hostile pull request could leave a poisoned - # Gradle/CMake cache that main's next build - which is signed with DroidDeck's key - picks up. - # Under pull_request a fork's run has a read-only token, no secrets, and caches of its own that - # main never reads. The price: GitHub does not run pull_request while a pull request conflicts - # with main, so a conflicting one gets no build (the pull request page still shows the conflict). pull_request: branches: [ main ] types: [ opened, synchronize, reopened, ready_for_review ] @@ -41,39 +19,27 @@ on: permissions: contents: read - # The pull-request report below is one comment on the pull request, kept up to date. pull-requests: write -# A newer push makes the running build pointless; stop paying for it. concurrency: group: build-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true env: - # Bump to force the preload cache to miss (e.g. after changing a compiler flag below). PRELOAD_CACHE_VERSION: v1 jobs: build: + name: Build APK if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} runs-on: ubuntu-24.04 timeout-minutes: 45 steps: - # A pull request is checked out at its own head with full history, not at GitHub's merge - # ref: that ref does not exist while the pull request conflicts with main, and the point of - # the next step is to say so and show where. - uses: actions/checkout@v4 with: ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} - # Full history: the merge below needs it (a shallow clone refuses to merge at all). fetch-depth: 0 - # The pull request as it would be once merged onto main is what gets built. When it cannot - # be merged, the conflicting files and the conflicting hunks go into the run summary and the - # pull-request comment, and the job stops here: there is nothing coherent to build. - # DroidDeck's signing key lives only in GitHub environment secrets (see sign.yml). A key file - # committed by mistake would be in the history for good: stop at the first one. The AOSP - # testkey is public and meant to be here. - name: No private key in the tree run: | set -euo pipefail @@ -103,7 +69,6 @@ jobs: fi FILES=$(git diff --name-only --diff-filter=U) if [ -z "$FILES" ]; then - # The merge did not even start (git said why): quote it rather than list nothing. { echo "### ERROR: Could not merge onto main (\`$BASE\`)" echo @@ -145,8 +110,6 @@ jobs: distribution: temurin java-version: '17' - # Caches ~/.gradle (dependencies, wrapper distribution, build cache, configuration cache) - # and prunes what the run did not use, so the entry cannot grow without bound. - uses: gradle/actions/setup-gradle@v4 with: cache-cleanup: on-success @@ -155,7 +118,6 @@ jobs: run: | NDK="${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}" VERSION=$(sed -n 's/^Pkg.Revision *= *//p' "$NDK/source.properties") - echo "NDK $VERSION at $NDK" echo "NDK_VERSION=$VERSION" >> "$GITHUB_ENV" - name: Set the APK build label @@ -180,11 +142,6 @@ jobs: echo "label=$label" >> "$GITHUB_OUTPUT" echo "Build: \`$label\`" >> "$GITHUB_STEP_SUMMARY" - # ── The session's preloads ─────────────────────────────────────────────────────────── - # Both are glibc aarch64 libraries that live inside the rootfs, so they are cross-compiled - # here rather than by the NDK. They ship in assets (not in the runtime tarball) so a fix in - # either reaches an already-installed runtime on the next launch instead of waiting on a - # ~790 MB runtime re-host. Their inputs change rarely, so most runs restore them. - name: Cache the preloads id: preloads uses: actions/cache@v4 @@ -202,9 +159,6 @@ jobs: sudo apt-get install -y -qq gcc-aarch64-linux-gnu g++-aarch64-linux-gnu >/dev/null D=app/src/main/assets/linuxfs mkdir -p "$D" - # Statically linked C++ runtime: this is preloaded into every Steam process, and Steam - # ships its own libstdc++ while rewriting LD_LIBRARY_PATH. Pulling the system one in - # ahead of it is a version conflict waiting to happen. Only libc is left as a dependency. aarch64-linux-gnu-g++ -shared -fPIC -O2 -Wall -Wno-attributes -Wno-nonnull-compare \ -pthread -std=c++17 -static-libstdc++ -static-libgcc \ -o "$D/libfakeinput.so" app/src/main/cpp/fakeinput_steam.cpp -ldl @@ -212,26 +166,20 @@ jobs: aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -pthread \ -o "$D/libblsession.so" tools/linuxfs/preload/*.c -ldl aarch64-linux-gnu-strip --strip-unneeded "$D/libblsession.so" - # The session's scripts travel the same way, for the same reason. for script in tools/linuxfs/overlay/usr/local/bin/bannerlator-*; do install -Dm644 "$script" "$D/usr/local/bin/$(basename "$script")" done - # The SteamOS helpers the client calls in Deck mode, as no-op stubs (see each file). for f in tools/linuxfs/overlay/usr/bin/* tools/linuxfs/overlay/usr/bin/steamos-polkit-helpers/*; do [ -f "$f" ] && install -Dm644 "$f" "$D/${f#tools/linuxfs/overlay/}" done - # The patched gamescope, when a build of it has been published (tools/gamescope/release.env - # names the release and the archive's sha256); see tools/gamescope/PATCHES.md. if [ -f tools/gamescope/release.env ]; then . tools/gamescope/release.env - # Through the API: the repository is private, so the plain download URL answers 404. gh release download "${GAMESCOPE_TAG}" -R "${GITHUB_REPOSITORY}" -p gamescope.tzst -O gamescope.tzst echo "${GAMESCOPE_SHA256} gamescope.tzst" | sha256sum -c - tar --use-compress-program=unzstd -xf gamescope.tzst -C "$D" test -f "$D/usr/local/bin/gamescope" rm gamescope.tzst fi - # The patched wlroots for the desktop's GPU renderers (tools/wlroots), the same way. if [ -f tools/wlroots/release.env ]; then . tools/wlroots/release.env gh release download "${WLROOTS_TAG}" -R "${GITHUB_REPOSITORY}" -p wlroots.tzst -O wlroots.tzst @@ -240,9 +188,6 @@ jobs: test -f "$D/usr/local/lib/droiddeck-wlroots/libwlroots-0.20.so" rm wlroots.tzst fi - # Valve's aarch64 mangoapp for Deck mode's performance overlay, plus the five libraries the - # runtime lacks, pinned by sha256 in tools/mangoapp/packages.txt. Stored under the SONAME - # each is loaded by, as real files: assets cannot carry symlinks. M="$D/usr/local/lib/mangoapp" mkdir -p "$M" mango-pkgs grep -v '^#' tools/mangoapp/packages.txt | while read -r sum url; do @@ -259,7 +204,6 @@ jobs: install -Dm644 tools/mangoapp/mangoapp "$D/usr/local/bin/mangoapp" rm -rf mango-pkgs ls -l "$M" - # And the desktop's launcher and labwc defaults, staged over the desktop package's copies. install -Dm644 tools/linuxfs/desktop/droiddeck-desktop "$D/usr/local/bin/droiddeck-desktop" install -Dm644 tools/linuxfs/desktop/droiddeck-gpu "$D/usr/local/bin/droiddeck-gpu" install -Dm644 tools/linuxfs/desktop/droiddeck-desktop-gpu "$D/usr/local/bin/droiddeck-desktop-gpu" @@ -269,10 +213,6 @@ jobs: install -Dm644 tools/linuxfs/desktop/firefox-droiddeck.js "$D/usr/lib/firefox/defaults/pref/droiddeck.js" ls -lR "$D" - # The daemon's AAudio sink (tools/aaudio-sink), compiled here against upstream PulseAudio 13 - # headers and the app's own libpulse*, then put into the audio bundle the apk carries. The - # committed bundle holds every other module and no sink, so a build without this step would - # have no audio - the unpack check in PulseAudioComponent would then fail loudly, not quietly. - name: Cache PulseAudio 13.0 source id: pa-src uses: actions/cache@v4 @@ -300,21 +240,17 @@ jobs: tar --use-compress-program=unzstd -tf app/src/main/assets/pulseaudio.tzst | grep -c '\.so$' rm -rf pa-bundle sink-out - # Runs whether the preloads were built or restored: readelf reads any ELF whatever the host, - # so these assertions cost nothing and need no cross toolchain. - name: Check the preloads run: | set -euo pipefail D=app/src/main/assets/linuxfs NEED=$(readelf -d "$D/libfakeinput.so" | sed -n 's/.*NEEDED.*\[\(.*\)\]/\1/p') - echo "libfakeinput NEEDED: $NEED" for bad in libstdc++.so.6 libgcc_s.so.1; do if printf '%s\n' "$NEED" | grep -qx "$bad"; then echo "::error::libfakeinput.so links $bad - the C++ runtime must stay static"; exit 1 fi done syms() { readelf -Ws "$1" | awk '$4 == "FUNC" && $5 == "GLOBAL" {sub(/@.*/, "", $8); print $8}'; } - # The interposers are the whole point: if one is missing the client sees no controller. FAKE=$(syms "$D/libfakeinput.so") for sym in open openat ioctl read close poll ppoll select stat fstat access scandir; do grep -qx "$sym" <<<"$FAKE" || { echo "::error::libfakeinput.so does not export $sym"; exit 1; } @@ -325,12 +261,7 @@ jobs: done test -f "$D/usr/local/bin/bannerlator-session" test -f "$D/usr/local/bin/bannerlator-proton-extra" - echo "preloads check out" - # ── The native build ───────────────────────────────────────────────────────────────── - # The compositor and adrenotools are the slowest part of a build and change least often. - # The key pins the C sources and the NDK; the restore-key still hands an older tree to - # ninja, which then rebuilds only the files that differ. - name: Cache the CMake build tree uses: actions/cache@v4 with: @@ -341,9 +272,6 @@ jobs: restore-keys: | cxx-${{ env.NDK_VERSION }}- - # Release, not debug: AGP quietly drops v1 (JAR) signing from a debug build once minSdk is - # 24 or higher, and the apk has to carry all three schemes. Nothing is minified either way. - # The whole log is kept so a failed build's errors can be quoted back on a pull request. - name: Build id: build env: @@ -352,18 +280,6 @@ jobs: set -o pipefail ./gradlew assembleRelease --console=plain -PndkVersion="$NDK_VERSION" 2>&1 | tee /tmp/build.log - - name: Describe the apk - run: | - set -euo pipefail - APK=$(find app/build/outputs/apk -name '*.apk' | head -1) - echo "apk: $APK" - ls -l "$APK" - sha256sum "$APK" - unzip -l "$APK" | grep -E 'lib/arm64-v8a|assets/linuxfs' | awk 'NR <= 30' # awk reads to the end: head would break the pipe under pipefail - - # Every NEEDED of every library we ship must resolve inside the apk or on the device. The - # first build shipped libwayland-server.so without libffi.so, and the app died on - # System.loadLibrary the moment a session started - a check this step would have caught. - name: Check the native dependency closure run: | set -euo pipefail @@ -371,8 +287,6 @@ jobs: WORK=$(mktemp -d) unzip -q "$APK" 'lib/arm64-v8a/*' -d "$WORK" cd "$WORK/lib/arm64-v8a" - # Provided by the platform, so never packaged. libstdc++.so is Android's minimal system - # C++ runtime; the Termux PTY JNI library has this in its NEEDED list. SYSTEM="libc.so libm.so libdl.so liblog.so libandroid.so libz.so libvulkan.so libstdc++.so libGLESv2.so libEGL.so libnativewindow.so libjnigraphics.so libaaudio.so libOpenSLES.so libmediandk.so libcamera2ndk.so libsync.so libneuralnetworks.so" @@ -380,19 +294,13 @@ jobs: for so in *.so; do for need in $(readelf -d "$so" | sed -n 's/.*NEEDED.*\[\(.*\)\]/\1/p'); do [ -f "$need" ] && continue - # $SYSTEM spans lines; unquoted echo folds its newlines to spaces, or the last - # name on each line could never match (libaaudio.so did not, for the relay helper). case " $(echo $SYSTEM) " in *" $need "*) continue ;; esac echo "::error::$so needs $need, which is neither in the apk nor a platform library" fail=1 done done - [ "$fail" = 0 ] && echo "every NEEDED resolves" exit $fail - # AGP signs with v2+v3 and drops v1 whenever minSdk is 24 or higher, and setting - # enableV1Signing in the DSL does not move it. Re-signing here with apksigner is the one way - # to be sure of what the apk carries: zipalign first, then all three schemes explicitly. - name: Sign with the testkey (v1 + v2 + v3) run: | set -euo pipefail @@ -406,18 +314,12 @@ jobs: --out "$APK" "$APK.aligned" rm -f "$APK.aligned" "$APK.idsig" - # The apk must stay installable over the last one, and on every Android version: that means - # one stable key and all three signature schemes. Cheap to assert, easy to lose silently. - name: Check the signature run: | set -euo pipefail APK=$(find app/build/outputs/apk -name '*.apk' | head -1) APKSIGNER=$(find "$ANDROID_HOME/build-tools" -name apksigner | sort -V | tail -1) - # --min-sdk-version 21: apksigner only verifies the schemes an apk's own minSdk requires, - # so at minSdk 26 it reports v1 as false whether or not the JAR signature is there. - # Asking it to verify from 21 makes it actually look. "$APKSIGNER" verify --min-sdk-version 21 --verbose --print-certs "$APK" | tee /tmp/sig.txt - # And the files themselves, which is what an old installer reads. unzip -l "$APK" | grep -qE 'META-INF/.*\.(SF|RSA|DSA)$' \ || { echo "::error::apk carries no JAR signature files"; exit 1; } for scheme in "v1 scheme (JAR signing): true" "v2 scheme (APK Signature Scheme v2): true" \ @@ -427,10 +329,6 @@ jobs: grep -qi "CN=Android, OU=Android, O=Android" /tmp/sig.txt \ || { echo "::error::apk is not signed with the AOSP testkey"; exit 1; } - # ── The pull-request report ────────────────────────────────────────────────────────── - # One comment per pull request, rewritten on every push: whether it merges, and when the - # build failed, the compiler's own errors with paths made short. Fork pull requests carry a - # read-only token and cannot comment; the same text is in the run summary either way. - name: Report on the pull request if: always() && github.event_name == 'pull_request' continue-on-error: true @@ -452,7 +350,7 @@ jobs: success) echo "### Build passed" echo - echo "The apk is the \`droiddeck-apk\` artifact on [this run]($RUN_URL) A pull request's build is signed only with the public test key: it installs on a phone without an official DroidDeck. To install it over one, sign it with DroidDeck's key or ask for a test build of the branch." + echo "The \`droiddeck-apk\` artifact is [on this run]($RUN_URL), signed with the public test key. After approval, a maintainer can run **PR Release Publish** from \`main\` to create an APK that installs over the release." ;; failure) echo "### ERROR: Build failed" @@ -479,7 +377,6 @@ jobs: ;; esac echo - echo "_Updated on every push to this pull request._" } > /tmp/pr-report.md cat /tmp/pr-report.md >> "$GITHUB_STEP_SUMMARY" ID=$(gh api "repos/${GITHUB_REPOSITORY}/issues/${PR}/comments" --paginate --jq ".[] | select(.body | startswith(\"$MARK\")) | .id" | head -1 || true) @@ -495,23 +392,16 @@ jobs: path: app/build/outputs/apk/release/*.apk if-no-files-found: error retention-days: 30 - # An apk is a zip already; compressing it again buys nothing and costs seconds. compression-level: 0 - # The apk signed with DroidDeck's own key (tools/release), so a test build installs over a release - # and keeps its data. Main's builds sign straight away (signing-main); any other branch's wait for - # the owner's approval (signing-branch). A pull request's never: its code is not ours to sign, and - # the job that would hold the key never runs for it. The testkey apk above is unaffected. sign: needs: build - if: github.event_name != 'pull_request' - # What sign.yml asks for; a called workflow never gets more than its caller gives it. + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' permissions: contents: read actions: read uses: ./.github/workflows/sign.yml with: - environment: ${{ github.ref == 'refs/heads/main' && 'signing-main' || 'signing-branch' }} + environment: signing-main variants: '["standard"]' - # The key is an environment secret; a called workflow sees secrets only when they are passed on. secrets: inherit diff --git a/.github/workflows/pr-release-publish.yml b/.github/workflows/pr-release-publish.yml index 7c327015b..72ef506be 100644 --- a/.github/workflows/pr-release-publish.yml +++ b/.github/workflows/pr-release-publish.yml @@ -1,169 +1,150 @@ name: PR Release Publish +run-name: sign PR #${{ inputs.pr_number }} with the release key on: - workflow_run: - workflows: - - Build APK - types: - - completed + workflow_dispatch: + inputs: + pr_number: + description: Open, approved pull request + required: true + type: number + +permissions: + actions: read + contents: read + pull-requests: read concurrency: - group: pr-release-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }} + group: pr-release-${{ inputs.pr_number }} cancel-in-progress: false -permissions: {} - jobs: + authorize: + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-24.04 + outputs: + run_id: ${{ steps.gate.outputs.run_id }} + head_sha: ${{ steps.gate.outputs.head_sha }} + label: ${{ steps.gate.outputs.label }} + steps: + - name: Check the PR, review, and build + id: gate + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ inputs.pr_number }} + run: | + set -euo pipefail + pr=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER") + jq -e '.state == "open" and .draft == false and .base.ref == "main"' <<<"$pr" >/dev/null + head=$(jq -r '.head.sha' <<<"$pr") + base=$(jq -r '.base.sha' <<<"$pr") + + run_id=$(gh run list -R "$GITHUB_REPOSITORY" --workflow build.yml \ + --event pull_request --commit "$head" \ + --json databaseId,headSha,status,conclusion,createdAt,attempt -L 100 | + jq -r --arg head "$head" '[.[] | select(.headSha == $head)] | + sort_by([.createdAt, .attempt]) | last | + select(.status == "completed" and .conclusion == "success") | .databaseId // empty') + [[ -n "$run_id" ]] || { echo '::error::The latest PR build is not green'; exit 1; } + run=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$run_id") + jq -e --arg head "$head" --arg base "$base" --argjson number "$PR_NUMBER" ' + .event == "pull_request" and .head_sha == $head and .conclusion == "success" and + any(.pull_requests[]?; .number == $number and .head.sha == $head and .base.sha == $base) + ' <<<"$run" >/dev/null + + approved=$(gh api --paginate --slurp \ + "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/reviews?per_page=100" | + jq -r --arg head "$head" '[.[][] | select(.commit_id == $head)] | + group_by(.user.login) | map(max_by(.id)) | .[] | + select(.state == "APPROVED") | .user.login') + maintainer_approved=false + while IFS= read -r reviewer; do + [[ -n "$reviewer" ]] || continue + permission=$(gh api "repos/$GITHUB_REPOSITORY/collaborators/$reviewer/permission" --jq .permission) + [[ "$permission" == write || "$permission" == admin ]] && maintainer_approved=true + done <<<"$approved" + [[ "$maintainer_approved" == true ]] || { echo '::error::A maintainer must approve this commit'; exit 1; } + + sdk=${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}} + tools=$(find "$sdk/build-tools" -mindepth 1 -maxdepth 1 -type d | sort -V | tail -1) + stable_tag=$(gh api repos/The412Banner/DroidDeck/releases/latest --jq .tag_name) + mkdir -p "$RUNNER_TEMP/stable" "$RUNNER_TEMP/pr" + gh release download "$stable_tag" -R The412Banner/DroidDeck \ + -p "DroidDeck-$stable_tag.apk" -D "$RUNNER_TEMP/stable" + gh run download "$run_id" -R "$GITHUB_REPOSITORY" -n droiddeck-apk -D "$RUNNER_TEMP/pr" + mapfile -t apks < <(find "$RUNNER_TEMP/pr" -type f -name '*.apk') + [[ ${#apks[@]} == 1 ]] || { echo '::error::Expected one PR APK'; exit 1; } + package_line() { + "$tools/aapt" dump badging "$1" | sed -n "s/^package: name='\\([^']*\\)' versionCode='\\([^']*\\)'.*/\\1 \\2/p" + } + stable=$(package_line "$RUNNER_TEMP/stable/DroidDeck-$stable_tag.apk") + candidate=$(package_line "${apks[0]}") + [[ "$stable" =~ ^com\.droiddeck\.launcher\ [0-9]+$ && "$candidate" == "$stable" ]] || { + echo "::error::PR package/versionCode '$candidate' differs from stable '$stable'"; exit 1; + } + + current=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER") + jq -e --arg head "$head" --arg base "$base" ' + .state == "open" and .draft == false and .base.ref == "main" and + .head.sha == $head and .base.sha == $base + ' <<<"$current" >/dev/null + [[ $(gh pr view "$PR_NUMBER" -R "$GITHUB_REPOSITORY" --json reviewDecision --jq .reviewDecision) == APPROVED ]] || { + echo '::error::The current PR revision still needs approval'; exit 1; + } + { + echo "run_id=$run_id" + echo "head_sha=$head" + echo "label=PR.$PR_NUMBER-${head:0:7}" + } >> "$GITHUB_OUTPUT" + + sign: + needs: authorize + uses: ./.github/workflows/sign.yml + with: + environment: signing-main + variants: '["standard"]' + run_id: ${{ needs.authorize.outputs.run_id }} + label: ${{ needs.authorize.outputs.label }} + artifact: droiddeck-pr-signed + secrets: inherit + publish: - if: >- - github.event.workflow_run.conclusion == 'success' && - contains(fromJSON('["pull_request", "pull_request_target"]'), github.event.workflow_run.event) + needs: [authorize, sign] runs-on: ubuntu-24.04 permissions: actions: read - pull-requests: read - env: - SOURCE_REPO: ${{ github.repository }} - TARGET_REPO: Droid-Deck/DroidDeck-CI - RUN_ID: ${{ github.event.workflow_run.id }} - HEAD_REPO: ${{ github.event.workflow_run.head_repository.full_name }} - HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} - HEAD_SHA: ${{ github.event.workflow_run.head_sha }} - SIGNING_CERT_SHA256: a40da80a59d170caa950cf15c18c454d47a39b26989d8b640ecd745ba71bf5dc + contents: read steps: - - name: Resolve the pull request - id: scope - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - CI_TOKEN: ${{ secrets.DROIDDECK_CI_TOKEN }} - RUN_PR_NUMBER: ${{ github.event.workflow_run.pull_requests[0].number }} - run: | - set -euo pipefail - if [ -z "$CI_TOKEN" ]; then - echo "::warning::DROIDDECK_CI_TOKEN is not set; nothing is published to $TARGET_REPO" - echo "allowed=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - pr_number="$RUN_PR_NUMBER" - if [ -z "$pr_number" ]; then - head_owner=${HEAD_REPO%%/*} - pr_number=$(gh pr list --repo "$SOURCE_REPO" --state all --head "$HEAD_BRANCH" \ - --json number,headRefOid,headRepositoryOwner \ - --jq "map(select(.headRepositoryOwner.login == \"$head_owner\" and .headRefOid == \"$HEAD_SHA\")) | first | .number // \"\"") - fi - if [ -z "$pr_number" ]; then - echo "No pull request found for $HEAD_REPO $HEAD_BRANCH at $HEAD_SHA" - echo "allowed=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - gh api "repos/$SOURCE_REPO/pulls/$pr_number" > pr.json - base_repo=$(jq -r '.base.repo.full_name // ""' pr.json) - pr_head=$(jq -r '.head.sha' pr.json) - if [ "$base_repo" != "$SOURCE_REPO" ]; then - echo "PR #$pr_number targets $base_repo, not $SOURCE_REPO" - echo "allowed=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - if [ "$pr_head" != "$HEAD_SHA" ]; then - echo "PR #$pr_number has moved on to $pr_head; its own build publishes it" - echo "allowed=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - short_sha=$(printf '%s' "$HEAD_SHA" | cut -c1-5) - { - echo "allowed=true" - echo "pr_number=$pr_number" - echo "tag=pr-$pr_number" - echo "apk=DroidDeck-PR.$pr_number-$short_sha.apk" - } >> "$GITHUB_OUTPUT" - - - name: Download the APK - if: steps.scope.outputs.allowed == 'true' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - APK_NAME: ${{ steps.scope.outputs.apk }} - run: | - set -euo pipefail - gh run download "$RUN_ID" --repo "$SOURCE_REPO" --name droiddeck-apk --dir downloaded - found=$(find downloaded -name '*.apk') - test "$(printf '%s\n' "$found" | grep -c .)" = 1 - mkdir -p release-assets - mv "$found" "release-assets/$APK_NAME" - - - name: Check the signing key - if: steps.scope.outputs.allowed == 'true' - env: - APK_NAME: ${{ steps.scope.outputs.apk }} - run: | - set -euo pipefail - build_tools=$(find "$ANDROID_SDK_ROOT/build-tools" -mindepth 1 -maxdepth 1 -type d | sort -V | tail -1) - certs=$("$build_tools/apksigner" verify --print-certs "release-assets/$APK_NAME") - printf '%s\n' "$certs" - signers=$(printf '%s\n' "$certs" | grep -c 'certificate SHA-256 digest') - digest=$(printf '%s\n' "$certs" | sed -n 's/.*certificate SHA-256 digest: //p' | head -1) - if [ "$signers" != 1 ] || [ "$digest" != "$SIGNING_CERT_SHA256" ]; then - echo "::error::$APK_NAME is not signed with the CI key, so it would not install over earlier builds" - exit 1 - fi - - - name: Write the release notes - if: steps.scope.outputs.allowed == 'true' - id: notes - env: - PR_NUMBER: ${{ steps.scope.outputs.pr_number }} - APK_NAME: ${{ steps.scope.outputs.apk }} - run: | - set -euo pipefail - title=$(jq -r '.title' pr.json | tr '\r\n' ' ' | sed 's/[[:space:]]\+/ /g; s/ $//') - author=$(jq -r '.user.login // "unknown"' pr.json) - url=$(jq -r '.html_url' pr.json) - body=$(jq -r '.body // ""' pr.json) - head_ref=$(jq -r '.head.ref' pr.json) - echo "title=$title" >> "$GITHUB_OUTPUT" - { - printf '# PR #%s\n\n' "$PR_NUMBER" - printf 'Title: %s\n\n' "$title" - printf 'Author: @%s\n\n' "$author" - printf 'Source PR: %s\n' "$url" - printf 'Source branch: %s\n' "$head_ref" - printf 'Source commit: `%s`\n' "$HEAD_SHA" - printf 'Build workflow run: https://github.com/%s/actions/runs/%s\n\n' "$SOURCE_REPO" "$RUN_ID" - printf '## What This PR Is About\n\n' - if [ -n "$body" ]; then - printf '%s\n' "$body" - else - printf 'No PR description was provided.\n' - fi - printf '\n## Attached Artifacts\n\n' - printf -- '- %s\n' "$APK_NAME" - printf '\nBuilt from source commit `%s`, signed with the CI key (SHA-256 `%s`), so it installs over any other DroidDeck CI build.\n' \ - "$(printf '%s' "$HEAD_SHA" | cut -c1-5)" "$SIGNING_CERT_SHA256" - } > release-notes.md - - - name: Publish to DroidDeck-CI - if: steps.scope.outputs.allowed == 'true' + - uses: actions/download-artifact@v4 + with: + name: droiddeck-pr-signed-standard + path: signed + - name: Publish to DroidDeck-CI when configured env: GH_TOKEN: ${{ secrets.DROIDDECK_CI_TOKEN }} - PR_NUMBER: ${{ steps.scope.outputs.pr_number }} - TAG: ${{ steps.scope.outputs.tag }} - APK_NAME: ${{ steps.scope.outputs.apk }} - PR_TITLE: ${{ steps.notes.outputs.title }} + PR_NUMBER: ${{ inputs.pr_number }} + HEAD_SHA: ${{ needs.authorize.outputs.head_sha }} + LABEL: ${{ needs.authorize.outputs.label }} run: | set -euo pipefail - title="PR #$PR_NUMBER: $PR_TITLE" - if view_out=$(gh api "repos/$TARGET_REPO/releases/tags/$TAG" 2>&1); then - gh release edit "$TAG" --repo "$TARGET_REPO" --title "$title" --notes-file release-notes.md --prerelease - elif printf '%s' "$view_out" | grep -qi 'Not Found'; then - gh release create "$TAG" --repo "$TARGET_REPO" --target main --title "$title" --notes-file release-notes.md --prerelease \ - || gh release edit "$TAG" --repo "$TARGET_REPO" --title "$title" --notes-file release-notes.md --prerelease - else - printf '%s\n' "$view_out" - exit 1 + if [[ -z "$GH_TOKEN" ]]; then + echo '::warning::No CI token; download the signed APK from this run' + exit 0 fi - gh api "repos/$TARGET_REPO/releases/tags/$TAG" \ - --jq '.assets[] | select(.name | endswith(".apk")) | .id' | - while IFS= read -r asset_id; do - gh api --method DELETE "repos/$TARGET_REPO/releases/assets/$asset_id" >/dev/null + repo=Droid-Deck/DroidDeck-CI + tag="pr-$PR_NUMBER" + apk="DroidDeck-$LABEL.apk" + sha=$(sha256sum "signed/$apk" | cut -d ' ' -f 1) + notes="Source: https://github.com/$GITHUB_REPOSITORY/pull/$PR_NUMBER + Commit: $HEAD_SHA + APK SHA-256: $sha" + if ! gh release view "$tag" -R "$repo" >/dev/null 2>&1; then + gh release create "$tag" -R "$repo" --target main --title "PR #$PR_NUMBER" --notes "$notes" --prerelease + fi + gh release upload "$tag" "signed/$apk" -R "$repo" --clobber + gh release edit "$tag" -R "$repo" --title "PR #$PR_NUMBER" --notes "$notes" --prerelease + gh api "repos/$repo/releases/tags/$tag" | + jq -r --arg keep "$apk" '.assets[] | select(.name | endswith(".apk")) | select(.name != $keep) | .id' | + while IFS= read -r id; do + gh api --method DELETE "repos/$repo/releases/assets/$id" >/dev/null done - gh release upload "$TAG" "release-assets/$APK_NAME" --repo "$TARGET_REPO" --clobber - gh release view "$TAG" --repo "$TARGET_REPO" --json url --jq '"Published: \(.url)"' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2106659c8..2620b96df 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,12 +1,6 @@ name: Release APKs run-name: release ${{ inputs.version }} from main ${{ github.sha }} -# Every package of a release, signed with DroidDeck's own key: the standard app and the named ones -# in tools/release/variants.txt, one matrix job each. Nothing is rebuilt - the apk is the one the -# Build APK run of this exact main commit made (with its caches) and that was tested, so the -# release is those bytes. Waits for the owner's approval (the release environment); publishing the -# GitHub release stays a separate, manual step. - on: workflow_dispatch: inputs: @@ -41,7 +35,6 @@ jobs: run: | set -euo pipefail [[ $VERSION =~ ^[0-9A-Za-z._-]+$ ]] || { echo "::error::version '$VERSION': letters, digits, . _ - only"; exit 1; } - # This commit's Build APK run - a push or a dispatch on main, never a pull request's. runs=$(gh run list -R "$GITHUB_REPOSITORY" --workflow build.yml --branch main --commit "$GITHUB_SHA" \ --status success --json databaseId,event,headSha -L 20) id=$(jq -r --arg sha "$GITHUB_SHA" \ @@ -64,5 +57,4 @@ jobs: run_id: ${{ needs.find.outputs.run_id }} label: ${{ inputs.version }} artifact: droiddeck-release - # The key is an environment secret; a called workflow sees secrets only when they are passed on. secrets: inherit diff --git a/.github/workflows/sign.yml b/.github/workflows/sign.yml index e1e649869..cad16ca03 100644 --- a/.github/workflows/sign.yml +++ b/.github/workflows/sign.yml @@ -1,14 +1,5 @@ name: Sign APKs -# Called, never run by itself: turns a "Build APK" apk into packages signed with DroidDeck's own key, -# one matrix job per package in tools/release/variants.txt (tools/release/sign-apk.sh does the work -# and checks it). The key lives only in the GitHub environment named by the caller: -# signing-main builds of main -# signing-branch builds of any other branch - waits for the owner's approval -# release release.yml - main only, waits for the owner's approval -# Pull requests never get here (build.yml skips this for pull_request), so code from a pull -# request can never be signed with the key or come near it. - on: workflow_call: inputs: @@ -68,7 +59,6 @@ jobs: env: RELEASE_KEYSTORE_B64: ${{ secrets.RELEASE_KEYSTORE_B64 }} RELEASE_STORE_PASSWORD: ${{ secrets.RELEASE_STORE_PASSWORD }} - # The alias is not a secret (and as one, GitHub would mask 'droiddeck' all over the logs). RELEASE_KEY_ALIAS: droiddeck ENVIRONMENT: ${{ inputs.environment }} VARIANT: ${{ matrix.variant }} diff --git a/tools/release/make-release-key.sh b/tools/release/make-release-key.sh index 96d423ae1..1278ff800 100755 --- a/tools/release/make-release-key.sh +++ b/tools/release/make-release-key.sh @@ -1,19 +1,8 @@ #!/usr/bin/env bash -# make-release-key.sh - run ONCE, by the owner. Makes DroidDeck's signing key and gives it to GitHub. -# -# - The key is made on this device, in ~/droiddeck-release-key (outside the repo), with a long -# random password written beside it. Nothing secret is printed. -# - The key and password go to GitHub as secrets of the three signing environments -# (signing-main, signing-branch, release). GitHub never shows a secret again. -# - The key's certificate digest - public, not a secret - goes to keystore/release-signer.sha256, -# which CI checks every signed apk against. That file is the only thing to commit. -# -# BACK UP ~/droiddeck-release-key (both files) somewhere safe - a password manager, a USB stick. -# Without it no update can ever be signed for the people who installed a signed build. set -euo pipefail -REPO=The412Banner/DroidDeck -ENVIRONMENTS=(signing-main signing-branch release) +REPO=Droid-Deck/DroidDeck +ENVIRONMENTS=(signing-main release) ALIAS=droiddeck OUT=${DROIDDECK_KEY_DIR:-$HOME/droiddeck-release-key} here=$(cd "$(dirname "$0")" && pwd) @@ -32,7 +21,6 @@ done umask 077 mkdir -p "$OUT" KS="$OUT/droiddeck-release.p12" -# 48 random characters; PKCS12 uses one password for the store and the key. KS_PW=$(head -c 60 /dev/urandom | base64 -w0 | tr -d '/+=') KS_PW=${KS_PW:0:48} [ ${#KS_PW} -ge 40 ] || die "could not make a password"