Merge pull request #5 from Droid-Deck/chore/protect-main-signing

Gate release-signed PR APKs behind review
This commit is contained in:
Kurt Himebauch authored and GitHub committed 2026-09-24 21:15:27 -04:00
commit c50e58caba
5 files changed
+137 -296

No files matched your search

+4 -114
View File
@@ -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
+131 -150
View File
@@ -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)"'
-8
View File
@@ -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
-10
View File
@@ -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 }}
+2 -14
View File
@@ -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"