From cbb66ea1887f692b5e74b0ed686b64a0f6f28aab Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Thu, 24 Sep 2026 19:38:04 -0400 Subject: [PATCH 1/9] chore: harden release signing boundaries --- .github/workflows/build.yml | 11 +++++------ .github/workflows/release.yml | 4 ++-- .github/workflows/sign.yml | 6 ++---- tools/release/make-release-key.sh | 8 ++++---- 4 files changed, 13 insertions(+), 16 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b2377bf6d..607cd6d49 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -55,6 +55,7 @@ env: jobs: build: + name: Build APK if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} runs-on: ubuntu-24.04 timeout-minutes: 45 @@ -498,20 +499,18 @@ jobs: # 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. + # Only main is signed with DroidDeck's private key. Pull requests and other branches keep the + # public AOSP testkey build, so unreviewed branch code never gets access to the release key. sign: needs: build - if: github.event_name != 'pull_request' + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' # What sign.yml asks for; a called workflow never gets more than its caller gives it. 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/release.yml b/.github/workflows/release.yml index 2106659c8..f9d705aed 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,8 +4,8 @@ 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. +# release is those bytes. This workflow is manually dispatched from main; publishing the GitHub +# release stays a separate, manual step. on: workflow_dispatch: diff --git a/.github/workflows/sign.yml b/.github/workflows/sign.yml index e1e649869..8f98ecc1e 100644 --- a/.github/workflows/sign.yml +++ b/.github/workflows/sign.yml @@ -4,10 +4,8 @@ name: Sign APKs # 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. +# release manually dispatched release.yml from main +# Pull requests and other branches never call this workflow with the release key. on: workflow_call: diff --git a/tools/release/make-release-key.sh b/tools/release/make-release-key.sh index 96d423ae1..cc1c24123 100755 --- a/tools/release/make-release-key.sh +++ b/tools/release/make-release-key.sh @@ -3,8 +3,8 @@ # # - 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 and password go to GitHub as secrets of the two signing environments +# (signing-main and release). Branch builds and pull requests keep using the public test key. # - 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. # @@ -12,8 +12,8 @@ # 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) From 31cea4e0e355c07ee39f225316587a7c95ce6c45 Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Thu, 24 Sep 2026 20:08:33 -0400 Subject: [PATCH 2/9] Gate release-signed PR APKs on maintainer approval --- .github/workflows/build.yml | 2 +- .github/workflows/pr-release-publish.yml | 363 ++++++++++++++--------- .github/workflows/sign.yml | 3 +- 3 files changed, 229 insertions(+), 139 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 607cd6d49..228ef26f7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -453,7 +453,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 unprivileged apk is the \`droiddeck-apk\` artifact on [this run]($RUN_URL), signed with the public test key. After a maintainer approves this exact commit and CI is green, a maintainer can run **PR Release Publish** from \`main\` with this PR number. That workflow signs the artifact with the release identity, checks that its versionCode matches main's stable baseline, and publishes it for in-place update testing. The release key is never available to this PR build." ;; failure) echo "### ERROR: Build failed" diff --git a/.github/workflows/pr-release-publish.yml b/.github/workflows/pr-release-publish.yml index 7c327015b..1b9e7f04a 100644 --- a/.github/workflows/pr-release-publish.yml +++ b/.github/workflows/pr-release-publish.yml @@ -1,169 +1,258 @@ name: PR Release Publish +run-name: sign PR #${{ inputs.pr_number }} with the release key +# This is deliberately manual and exists on main. PR CI builds without secrets. A maintainer +# dispatches this workflow after reviewing the PR; it checks that the current head has a fresh +# maintainer approval and a successful Build APK run before it calls the release signer. on: - workflow_run: - workflows: - - Build APK - types: - - completed + workflow_dispatch: + inputs: + pr_number: + description: Open, approved pull request to sign and publish + 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: - publish: - if: >- - github.event.workflow_run.conclusion == 'success' && - contains(fromJSON('["pull_request", "pull_request_target"]'), github.event.workflow_run.event) + authorize: + name: verify maintainer approval and PR build + if: github.ref == 'refs/heads/main' runs-on: ubuntu-24.04 + timeout-minutes: 10 + outputs: + run_id: ${{ steps.authorize.outputs.run_id }} + pr_number: ${{ steps.authorize.outputs.pr_number }} + head_sha: ${{ steps.authorize.outputs.head_sha }} + label: ${{ steps.authorize.outputs.label }} + apk_name: ${{ steps.authorize.outputs.apk_name }} + steps: + - uses: actions/checkout@v4 + with: + ref: main + persist-credentials: false + + - name: Authorize this exact PR revision + id: authorize + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ inputs.pr_number }} + run: | + set -euo pipefail + [[ "$GITHUB_REF" == refs/heads/main ]] || { echo "::error::Run this workflow from main"; exit 1; } + [[ "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]] || { echo "::error::PR number must be a positive integer"; exit 1; } + + pr=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER") + state=$(jq -r '.state' <<<"$pr") + draft=$(jq -r '.draft' <<<"$pr") + base_ref=$(jq -r '.base.ref' <<<"$pr") + base_sha=$(jq -r '.base.sha' <<<"$pr") + head_sha=$(jq -r '.head.sha' <<<"$pr") + [[ "$state" == open && "$draft" == false && "$base_ref" == main ]] || { + echo "::error::PR #$PR_NUMBER must be open, ready for review, and target main"; exit 1; + } + + # Branch protection requires an approval. Repeat the check here so manually dispatching + # this workflow cannot bypass it. The review must approve this exact head commit, and the + # reviewer must be a repository owner, member, or collaborator with review access. + decision=$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json reviewDecision --jq '.reviewDecision') + [[ "$decision" == APPROVED ]] || { + echo "::error::PR #$PR_NUMBER does not currently have its required approving review"; exit 1; + } + reviews=$(gh api --paginate --slurp "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/reviews?per_page=100" | jq 'add') + maintainer_approvals=$(jq --arg sha "$head_sha" ' + [ .[] + | select(.commit_id == $sha) + | select(.author_association == "OWNER" or .author_association == "MEMBER" or .author_association == "COLLABORATOR") + | { login: .user.login, state: .state, submitted_at: .submitted_at } + ] + | sort_by(.submitted_at) + | group_by(.login) + | map(last) + | map(select(.state == "APPROVED")) + | length + ' <<<"$reviews") + (( maintainer_approvals > 0 )) || { + echo "::error::No maintainer has approved the current PR commit $head_sha"; exit 1; + } + + # Find the newest PR CI run for this commit. A newer failed or in-progress run prevents + # signing even if an older attempt for the same commit passed. + runs=$(gh run list --repo "$GITHUB_REPOSITORY" --workflow build.yml \ + --event pull_request --commit "$head_sha" \ + --json databaseId,headSha,status,conclusion,attempt,createdAt -L 100) + latest=$(jq -ce --arg sha "$head_sha" ' + [ .[] | select(.headSha == $sha) ] + | sort_by([.createdAt, .attempt]) + | last + ' <<<"$runs") + [[ $(jq -r '.status' <<<"$latest") == completed && $(jq -r '.conclusion' <<<"$latest") == success ]] || { + echo "::error::The latest Build APK run for this PR commit is not green"; exit 1; + } + run_id=$(jq -r '.databaseId' <<<"$latest") + run=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$run_id") + run_pr=$(jq -ce --argjson number "$PR_NUMBER" ' + [ .pull_requests[]? | select(.number == $number) ] | first + ' <<<"$run") + [[ $(jq -r '.event' <<<"$run") == pull_request && \ + $(jq -r '.head_sha' <<<"$run") == "$head_sha" && \ + $(jq -r '.status' <<<"$run") == completed && \ + $(jq -r '.conclusion' <<<"$run") == success && \ + $(jq -r '.head.sha' <<<"$run_pr") == "$head_sha" && \ + $(jq -r '.base.ref' <<<"$run_pr") == main && \ + $(jq -r '.base.sha' <<<"$run_pr") == "$base_sha" ]] || { + echo "::error::The successful CI artifact is not for the current PR head and base"; exit 1; + } + + # Keep test builds at main's current stable versionCode so stable and PR builds can be + # installed over one another. A code bump belongs in a release, not in this test APK. + sdk=${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}} + [[ -n "$sdk" && -d "$sdk/build-tools" ]] || { echo "::error::Android build-tools are unavailable"; exit 1; } + build_tools=$(find "$sdk/build-tools" -mindepth 1 -maxdepth 1 -type d | sort -V | tail -1) + expected_code=$(sed -nE 's/^[[:space:]]*versionCode[[:space:]]+([0-9]+).*$/\1/p' app/build.gradle | head -1) + [[ "$expected_code" =~ ^[0-9]+$ ]] || { echo "::error::Could not read main's versionCode"; exit 1; } + mkdir -p "$RUNNER_TEMP/pr-apk" + gh run download "$run_id" --repo "$GITHUB_REPOSITORY" --name droiddeck-apk --dir "$RUNNER_TEMP/pr-apk" + mapfile -t apks < <(find "$RUNNER_TEMP/pr-apk" -type f -name '*.apk' -print) + [[ ${#apks[@]} -eq 1 ]] || { echo "::error::Expected exactly one APK in droiddeck-apk"; exit 1; } + badging=$("$build_tools/aapt" dump badging "${apks[0]}") + apk_package=$(sed -n "s/^package: name='\\([^']*\\)'.*/\\1/p" <<<"$badging") + apk_code=$(sed -n "s/^package:.*versionCode='\\([^']*\\)'.*/\\1/p" <<<"$badging") + [[ "$apk_package" == com.droiddeck.launcher ]] || { + echo "::error::Expected the standard package, got '$apk_package'"; exit 1; + } + [[ "$apk_code" == "$expected_code" ]] || { + echo "::error::PR APK versionCode $apk_code differs from main's stable baseline $expected_code; keep PR builds at the stable code so testers can switch back in place"; exit 1; + } + + # Close the race with a new push/base update while approval and CI were being checked. + latest_pr=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER") + [[ $(jq -r '.state' <<<"$latest_pr") == open && \ + $(jq -r '.head.sha' <<<"$latest_pr") == "$head_sha" && \ + $(jq -r '.base.sha' <<<"$latest_pr") == "$base_sha" ]] || { + echo "::error::The PR changed while authorization was being checked; wait for fresh approval and CI"; exit 1; + } + + short_sha=${head_sha:0:7} + label="PR-$PR_NUMBER-$short_sha" + apk_name="DroidDeck-$label.apk" + { + echo "run_id=$run_id" + echo "pr_number=$PR_NUMBER" + echo "head_sha=$head_sha" + echo "label=$label" + echo "apk_name=$apk_name" + } >> "$GITHUB_OUTPUT" + { + echo "### Authorized PR signing" + echo + printf 'PR #%s at %s has an approving maintainer review and a green Build APK run (%s).\n' "$PR_NUMBER" "$short_sha" "$run_id" + printf 'The APK versionCode matches the main stable baseline (%s).\n' "$expected_code" + } >> "$GITHUB_STEP_SUMMARY" + + sign: + name: sign with the release identity + needs: authorize + if: needs.authorize.result == 'success' + 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: + name: publish the approved PR APK + needs: [authorize, sign] + if: needs.authorize.result == 'success' && needs.sign.result == 'success' + runs-on: ubuntu-24.04 + timeout-minutes: 10 permissions: actions: read + contents: 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 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" + - uses: actions/checkout@v4 + with: + ref: main + persist-credentials: false - - 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" + - uses: actions/download-artifact@v4 + with: + name: droiddeck-pr-signed-standard + path: signed - - name: Check the signing key - if: steps.scope.outputs.allowed == 'true' + - name: Verify the release signer and checksum env: - APK_NAME: ${{ steps.scope.outputs.apk }} + EXPECTED_SHA256: b241ea7d7e8180e2e58a8a2cc6e819eedc36eff5a0a04f382a34cabba4b5d3f0 + APK_NAME: ${{ needs.authorize.outputs.apk_name }} 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 + apk="signed/$APK_NAME" + test -s "$apk" || { echo "::error::Signed APK is missing: $APK_NAME"; exit 1; } + sdk=${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}} + [[ -n "$sdk" && -d "$sdk/build-tools" ]] || { echo "::error::Android build-tools are unavailable"; exit 1; } + tools=$(find "$sdk/build-tools" -mindepth 1 -maxdepth 1 -type d | sort -V | tail -1) + certs=$("$tools/apksigner" verify --min-sdk-version 28 --print-certs "$apk") + digest=$(sed -n 's/.*certificate SHA-256 digest: //p' <<<"$certs" | sort -u) + [[ "$digest" == "$EXPECTED_SHA256" ]] || { + printf '%s\n' "$certs" >&2 + echo "::error::PR APK is not signed with the release certificate"; exit 1; + } + (cd signed && sha256sum -- "$APK_NAME" > SHA256SUMS.txt) + cat signed/SHA256SUMS.txt + + - name: Publish to DroidDeck-CI when its token is configured + env: + GH_TOKEN: ${{ secrets.DROIDDECK_CI_TOKEN }} + PR_NUMBER: ${{ needs.authorize.outputs.pr_number }} + HEAD_SHA: ${{ needs.authorize.outputs.head_sha }} + APK_NAME: ${{ needs.authorize.outputs.apk_name }} + SOURCE_REPO: ${{ github.repository }} + TARGET_REPO: Droid-Deck/DroidDeck-CI + SIGNING_CERT_SHA256: b241ea7d7e8180e2e58a8a2cc6e819eedc36eff5a0a04f382a34cabba4b5d3f0 + run: | + set -euo pipefail + if [ -z "$GH_TOKEN" ]; then + echo "::warning::DROIDDECK_CI_TOKEN is not configured; the release-signed APK remains available as this workflow run's artifact" + exit 0 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" + pr=$(gh api "repos/$SOURCE_REPO/pulls/$PR_NUMBER") + title=$(jq -r '.title' <<<"$pr" | tr '\r\n' ' ' | sed 's/[[:space:]]\+/ /g; s/ $//') + url=$(jq -r '.html_url' <<<"$pr") + tag="pr-$PR_NUMBER" + notes=$(mktemp) + trap 'rm -f "$notes"' EXIT { 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' - 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 }} - 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 + printf "Source commit: \`%s\`\n" "$HEAD_SHA" + printf "Signed with DroidDeck release certificate SHA-256 \`%s\`.\n" "$SIGNING_CERT_SHA256" + printf 'This is a review-approved test build. Back up app data before installing test builds.\n' + } > "$notes" + if view_out=$(gh api "repos/$TARGET_REPO/releases/tags/$tag" 2>&1); then + gh release edit "$tag" --repo "$TARGET_REPO" --title "PR #$PR_NUMBER: $title" --notes-file "$notes" --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 + gh release create "$tag" --repo "$TARGET_REPO" --target main \ + --title "PR #$PR_NUMBER: $title" --notes-file "$notes" --prerelease else printf '%s\n' "$view_out" exit 1 fi - gh api "repos/$TARGET_REPO/releases/tags/$TAG" \ + 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 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)"' + gh release upload "$tag" "signed/$APK_NAME" "signed/SHA256SUMS.txt" --repo "$TARGET_REPO" --clobber + gh release view "$tag" --repo "$TARGET_REPO" --json url --jq '"Published: \(.url)"' diff --git a/.github/workflows/sign.yml b/.github/workflows/sign.yml index 8f98ecc1e..01d72c111 100644 --- a/.github/workflows/sign.yml +++ b/.github/workflows/sign.yml @@ -5,7 +5,8 @@ name: Sign APKs # and checks it). The key lives only in the GitHub environment named by the caller: # signing-main builds of main # release manually dispatched release.yml from main -# Pull requests and other branches never call this workflow with the release key. +# PR CI stays test-key-only. The main-only PR Release Publish workflow may call this after it checks +# for an approving maintainer review, green CI, the exact current PR commit, and the stable versionCode. on: workflow_call: From 7aa56118aecf0d2df167a85bbb7818554ac8d647 Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Thu, 24 Sep 2026 20:13:40 -0400 Subject: [PATCH 3/9] Require repo write access for signing approvals --- .github/workflows/pr-release-publish.yml | 55 +++++++++++++----------- 1 file changed, 31 insertions(+), 24 deletions(-) diff --git a/.github/workflows/pr-release-publish.yml b/.github/workflows/pr-release-publish.yml index 1b9e7f04a..165cec0cd 100644 --- a/.github/workflows/pr-release-publish.yml +++ b/.github/workflows/pr-release-publish.yml @@ -59,30 +59,6 @@ jobs: echo "::error::PR #$PR_NUMBER must be open, ready for review, and target main"; exit 1; } - # Branch protection requires an approval. Repeat the check here so manually dispatching - # this workflow cannot bypass it. The review must approve this exact head commit, and the - # reviewer must be a repository owner, member, or collaborator with review access. - decision=$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json reviewDecision --jq '.reviewDecision') - [[ "$decision" == APPROVED ]] || { - echo "::error::PR #$PR_NUMBER does not currently have its required approving review"; exit 1; - } - reviews=$(gh api --paginate --slurp "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/reviews?per_page=100" | jq 'add') - maintainer_approvals=$(jq --arg sha "$head_sha" ' - [ .[] - | select(.commit_id == $sha) - | select(.author_association == "OWNER" or .author_association == "MEMBER" or .author_association == "COLLABORATOR") - | { login: .user.login, state: .state, submitted_at: .submitted_at } - ] - | sort_by(.submitted_at) - | group_by(.login) - | map(last) - | map(select(.state == "APPROVED")) - | length - ' <<<"$reviews") - (( maintainer_approvals > 0 )) || { - echo "::error::No maintainer has approved the current PR commit $head_sha"; exit 1; - } - # Find the newest PR CI run for this commit. A newer failed or in-progress run prevents # signing even if an older attempt for the same commit passed. runs=$(gh run list --repo "$GITHUB_REPOSITORY" --workflow build.yml \ @@ -140,6 +116,37 @@ jobs: echo "::error::The PR changed while authorization was being checked; wait for fresh approval and CI"; exit 1; } + # Check approval after the build and artifact checks, as close as possible to signing. + # The reviewer must approve this exact head commit and have write or admin access. + decision=$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json reviewDecision --jq '.reviewDecision') + [[ "$decision" == APPROVED ]] || { + echo "::error::PR #$PR_NUMBER does not currently have its required approving review"; exit 1; + } + reviews=$(gh api --paginate --slurp "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/reviews?per_page=100" | jq 'add') + approved_reviewers=$(jq -r --arg sha "$head_sha" ' + [ .[] + | select(.commit_id == $sha) + | { login: .user.login, state: .state, submitted_at: .submitted_at } + ] + | sort_by(.submitted_at) + | group_by(.login) + | map(last) + | map(select(.state == "APPROVED")) + | .[].login + ' <<<"$reviews") + maintainer_approved=false + while IFS= read -r reviewer; do + [[ "$reviewer" =~ ^[A-Za-z0-9-]{1,39}$ ]] || continue + if permission=$(gh api "repos/$GITHUB_REPOSITORY/collaborators/$reviewer/permission" --jq '.permission' 2>/dev/null); then + case "$permission" in + write|admin) maintainer_approved=true ;; + esac + fi + done <<<"$approved_reviewers" + [[ "$maintainer_approved" == true ]] || { + echo "::error::No reviewer with write or admin access has approved the current PR commit $head_sha"; exit 1; + } + short_sha=${head_sha:0:7} label="PR-$PR_NUMBER-$short_sha" apk_name="DroidDeck-$label.apk" From 2a0f85970183b87c9c1dff4a707fe429a2332a39 Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Thu, 24 Sep 2026 20:16:28 -0400 Subject: [PATCH 4/9] Use the pinned release signer for PR APKs --- .github/workflows/build.yml | 5 +++-- .github/workflows/pr-release-publish.yml | 11 +++++++---- .github/workflows/sign.yml | 6 +++--- tools/release/make-release-key.sh | 3 ++- 4 files changed, 15 insertions(+), 10 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 228ef26f7..b2bebb92a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -499,8 +499,9 @@ jobs: # An apk is a zip already; compressing it again buys nothing and costs seconds. compression-level: 0 - # Only main is signed with DroidDeck's private key. Pull requests and other branches keep the - # public AOSP testkey build, so unreviewed branch code never gets access to the release key. + # This workflow signs only main. PR build jobs keep using the public AOSP test key; the separate + # main-only approval workflow can later sign an approved PR artifact without running PR code with + # the release key. sign: needs: build if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' diff --git a/.github/workflows/pr-release-publish.yml b/.github/workflows/pr-release-publish.yml index 165cec0cd..439cbb1b2 100644 --- a/.github/workflows/pr-release-publish.yml +++ b/.github/workflows/pr-release-publish.yml @@ -200,18 +200,21 @@ jobs: - name: Verify the release signer and checksum env: - EXPECTED_SHA256: b241ea7d7e8180e2e58a8a2cc6e819eedc36eff5a0a04f382a34cabba4b5d3f0 APK_NAME: ${{ needs.authorize.outputs.apk_name }} run: | set -euo pipefail apk="signed/$APK_NAME" test -s "$apk" || { echo "::error::Signed APK is missing: $APK_NAME"; exit 1; } + expected_sha256=$(tr -d '[:space:]' < keystore/release-signer.sha256) + [[ "$expected_sha256" =~ ^[[:xdigit:]]{64}$ ]] || { + echo "::error::keystore/release-signer.sha256 is not a SHA-256 digest"; exit 1; + } sdk=${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}} [[ -n "$sdk" && -d "$sdk/build-tools" ]] || { echo "::error::Android build-tools are unavailable"; exit 1; } tools=$(find "$sdk/build-tools" -mindepth 1 -maxdepth 1 -type d | sort -V | tail -1) certs=$("$tools/apksigner" verify --min-sdk-version 28 --print-certs "$apk") digest=$(sed -n 's/.*certificate SHA-256 digest: //p' <<<"$certs" | sort -u) - [[ "$digest" == "$EXPECTED_SHA256" ]] || { + [[ "$digest" == "$expected_sha256" ]] || { printf '%s\n' "$certs" >&2 echo "::error::PR APK is not signed with the release certificate"; exit 1; } @@ -226,7 +229,6 @@ jobs: APK_NAME: ${{ needs.authorize.outputs.apk_name }} SOURCE_REPO: ${{ github.repository }} TARGET_REPO: Droid-Deck/DroidDeck-CI - SIGNING_CERT_SHA256: b241ea7d7e8180e2e58a8a2cc6e819eedc36eff5a0a04f382a34cabba4b5d3f0 run: | set -euo pipefail if [ -z "$GH_TOKEN" ]; then @@ -236,6 +238,7 @@ jobs: pr=$(gh api "repos/$SOURCE_REPO/pulls/$PR_NUMBER") title=$(jq -r '.title' <<<"$pr" | tr '\r\n' ' ' | sed 's/[[:space:]]\+/ /g; s/ $//') url=$(jq -r '.html_url' <<<"$pr") + signing_cert_sha256=$(tr -d '[:space:]' < keystore/release-signer.sha256) tag="pr-$PR_NUMBER" notes=$(mktemp) trap 'rm -f "$notes"' EXIT @@ -244,7 +247,7 @@ jobs: printf 'Title: %s\n\n' "$title" printf 'Source PR: %s\n' "$url" printf "Source commit: \`%s\`\n" "$HEAD_SHA" - printf "Signed with DroidDeck release certificate SHA-256 \`%s\`.\n" "$SIGNING_CERT_SHA256" + printf "Signed with DroidDeck release certificate SHA-256 \`%s\`.\n" "$signing_cert_sha256" printf 'This is a review-approved test build. Back up app data before installing test builds.\n' } > "$notes" if view_out=$(gh api "repos/$TARGET_REPO/releases/tags/$tag" 2>&1); then diff --git a/.github/workflows/sign.yml b/.github/workflows/sign.yml index 01d72c111..7445e2adf 100644 --- a/.github/workflows/sign.yml +++ b/.github/workflows/sign.yml @@ -3,10 +3,10 @@ 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-main main builds and maintainer-approved PR artifacts # release manually dispatched release.yml from main -# PR CI stays test-key-only. The main-only PR Release Publish workflow may call this after it checks -# for an approving maintainer review, green CI, the exact current PR commit, and the stable versionCode. +# PR Build APK jobs stay test-key-only. PR Release Publish calls this from main only after approval, +# green CI, the exact current commit, and the stable versionCode have been checked. on: workflow_call: diff --git a/tools/release/make-release-key.sh b/tools/release/make-release-key.sh index cc1c24123..b707d833b 100755 --- a/tools/release/make-release-key.sh +++ b/tools/release/make-release-key.sh @@ -4,7 +4,8 @@ # - 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 two signing environments -# (signing-main and release). Branch builds and pull requests keep using the public test key. +# (signing-main and release). PR build jobs use the public test key; a separate workflow signs +# an approved PR artifact from main. # - 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. # From 097f56cd6c60a39d9b0d6263025dcc2811edc505 Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Thu, 24 Sep 2026 20:19:14 -0400 Subject: [PATCH 5/9] Match PR APK version to latest stable --- .github/workflows/build.yml | 2 +- .github/workflows/pr-release-publish.yml | 45 ++++++++++++++---------- 2 files changed, 28 insertions(+), 19 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b2bebb92a..87621846a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -453,7 +453,7 @@ jobs: success) echo "### Build passed" echo - echo "The unprivileged apk is the \`droiddeck-apk\` artifact on [this run]($RUN_URL), signed with the public test key. After a maintainer approves this exact commit and CI is green, a maintainer can run **PR Release Publish** from \`main\` with this PR number. That workflow signs the artifact with the release identity, checks that its versionCode matches main's stable baseline, and publishes it for in-place update testing. The release key is never available to this PR build." + echo "The unprivileged apk is the \`droiddeck-apk\` artifact on [this run]($RUN_URL), signed with the public test key. After a maintainer approves this exact commit and CI is green, a maintainer can run **PR Release Publish** from \`main\` with this PR number. That workflow signs the artifact with the release identity, checks that its versionCode matches the latest stable release, and publishes it for in-place update testing. The release key is never available to this PR build." ;; failure) echo "### ERROR: Build failed" diff --git a/.github/workflows/pr-release-publish.yml b/.github/workflows/pr-release-publish.yml index 439cbb1b2..db1b1e5bb 100644 --- a/.github/workflows/pr-release-publish.yml +++ b/.github/workflows/pr-release-publish.yml @@ -87,13 +87,25 @@ jobs: echo "::error::The successful CI artifact is not for the current PR head and base"; exit 1; } - # Keep test builds at main's current stable versionCode so stable and PR builds can be - # installed over one another. A code bump belongs in a release, not in this test APK. + # Use the last public stable APK as the versionCode baseline. Main can be ahead of the + # shipped release, but testers still need to switch back to that release in place. sdk=${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}} [[ -n "$sdk" && -d "$sdk/build-tools" ]] || { echo "::error::Android build-tools are unavailable"; exit 1; } build_tools=$(find "$sdk/build-tools" -mindepth 1 -maxdepth 1 -type d | sort -V | tail -1) - expected_code=$(sed -nE 's/^[[:space:]]*versionCode[[:space:]]+([0-9]+).*$/\1/p' app/build.gradle | head -1) - [[ "$expected_code" =~ ^[0-9]+$ ]] || { echo "::error::Could not read main's versionCode"; exit 1; } + stable_repo=The412Banner/DroidDeck + stable_tag=$(gh api "repos/$stable_repo/releases/latest" --jq '.tag_name') + [[ "$stable_tag" =~ ^[0-9A-Za-z._-]+$ ]] || { echo "::error::Could not identify the latest stable release"; exit 1; } + mkdir -p "$RUNNER_TEMP/stable-apk" + gh release download "$stable_tag" --repo "$stable_repo" \ + --pattern "DroidDeck-$stable_tag.apk" --dir "$RUNNER_TEMP/stable-apk" + stable_apk="$RUNNER_TEMP/stable-apk/DroidDeck-$stable_tag.apk" + test -s "$stable_apk" || { echo "::error::The latest stable release has no standard APK"; exit 1; } + stable_badging=$("$build_tools/aapt" dump badging "$stable_apk") + stable_package=$(sed -n "s/^package: name='\\([^']*\\)'.*/\\1/p" <<<"$stable_badging") + expected_code=$(sed -n "s/^package:.*versionCode='\\([^']*\\)'.*/\\1/p" <<<"$stable_badging") + [[ "$stable_package" == com.droiddeck.launcher && "$expected_code" =~ ^[0-9]+$ ]] || { + echo "::error::Could not read the standard package and versionCode from stable release $stable_tag"; exit 1; + } mkdir -p "$RUNNER_TEMP/pr-apk" gh run download "$run_id" --repo "$GITHUB_REPOSITORY" --name droiddeck-apk --dir "$RUNNER_TEMP/pr-apk" mapfile -t apks < <(find "$RUNNER_TEMP/pr-apk" -type f -name '*.apk' -print) @@ -105,23 +117,11 @@ jobs: echo "::error::Expected the standard package, got '$apk_package'"; exit 1; } [[ "$apk_code" == "$expected_code" ]] || { - echo "::error::PR APK versionCode $apk_code differs from main's stable baseline $expected_code; keep PR builds at the stable code so testers can switch back in place"; exit 1; - } - - # Close the race with a new push/base update while approval and CI were being checked. - latest_pr=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER") - [[ $(jq -r '.state' <<<"$latest_pr") == open && \ - $(jq -r '.head.sha' <<<"$latest_pr") == "$head_sha" && \ - $(jq -r '.base.sha' <<<"$latest_pr") == "$base_sha" ]] || { - echo "::error::The PR changed while authorization was being checked; wait for fresh approval and CI"; exit 1; + echo "::error::PR APK versionCode $apk_code differs from stable release $stable_tag versionCode $expected_code; keep PR builds at the stable code so testers can switch back in place"; exit 1; } # Check approval after the build and artifact checks, as close as possible to signing. # The reviewer must approve this exact head commit and have write or admin access. - decision=$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json reviewDecision --jq '.reviewDecision') - [[ "$decision" == APPROVED ]] || { - echo "::error::PR #$PR_NUMBER does not currently have its required approving review"; exit 1; - } reviews=$(gh api --paginate --slurp "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/reviews?per_page=100" | jq 'add') approved_reviewers=$(jq -r --arg sha "$head_sha" ' [ .[] @@ -146,6 +146,15 @@ jobs: [[ "$maintainer_approved" == true ]] || { echo "::error::No reviewer with write or admin access has approved the current PR commit $head_sha"; exit 1; } + # Re-read PR head, base, and review decision after the permission lookups. + latest_pr=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER") + decision=$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json reviewDecision --jq '.reviewDecision') + [[ $(jq -r '.state' <<<"$latest_pr") == open && \ + $(jq -r '.head.sha' <<<"$latest_pr") == "$head_sha" && \ + $(jq -r '.base.sha' <<<"$latest_pr") == "$base_sha" && \ + "$decision" == APPROVED ]] || { + echo "::error::The PR or its approval changed while authorization was being checked"; exit 1; + } short_sha=${head_sha:0:7} label="PR-$PR_NUMBER-$short_sha" @@ -161,7 +170,7 @@ jobs: echo "### Authorized PR signing" echo printf 'PR #%s at %s has an approving maintainer review and a green Build APK run (%s).\n' "$PR_NUMBER" "$short_sha" "$run_id" - printf 'The APK versionCode matches the main stable baseline (%s).\n' "$expected_code" + printf 'The APK versionCode matches stable release %s (%s).\n' "$stable_tag" "$expected_code" } >> "$GITHUB_STEP_SUMMARY" sign: From 6d4fc6775c52a0f3b57f60a92966269b93a9d0ed Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Thu, 24 Sep 2026 20:22:36 -0400 Subject: [PATCH 6/9] Preserve the current PR APK until upload succeeds --- .github/workflows/pr-release-publish.yml | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/.github/workflows/pr-release-publish.yml b/.github/workflows/pr-release-publish.yml index db1b1e5bb..be35d690c 100644 --- a/.github/workflows/pr-release-publish.yml +++ b/.github/workflows/pr-release-publish.yml @@ -157,7 +157,7 @@ jobs: } short_sha=${head_sha:0:7} - label="PR-$PR_NUMBER-$short_sha" + label="PR.$PR_NUMBER-$short_sha" apk_name="DroidDeck-$label.apk" { echo "run_id=$run_id" @@ -248,6 +248,7 @@ jobs: title=$(jq -r '.title' <<<"$pr" | tr '\r\n' ' ' | sed 's/[[:space:]]\+/ /g; s/ $//') url=$(jq -r '.html_url' <<<"$pr") signing_cert_sha256=$(tr -d '[:space:]' < keystore/release-signer.sha256) + apk_sha256=$(awk 'NR == 1 { print $1 }' signed/SHA256SUMS.txt) tag="pr-$PR_NUMBER" notes=$(mktemp) trap 'rm -f "$notes"' EXIT @@ -256,11 +257,12 @@ jobs: printf 'Title: %s\n\n' "$title" printf 'Source PR: %s\n' "$url" printf "Source commit: \`%s\`\n" "$HEAD_SHA" + printf "APK SHA-256: \`%s\`\n" "$apk_sha256" printf "Signed with DroidDeck release certificate SHA-256 \`%s\`.\n" "$signing_cert_sha256" printf 'This is a review-approved test build. Back up app data before installing test builds.\n' } > "$notes" if view_out=$(gh api "repos/$TARGET_REPO/releases/tags/$tag" 2>&1); then - gh release edit "$tag" --repo "$TARGET_REPO" --title "PR #$PR_NUMBER: $title" --notes-file "$notes" --prerelease + : elif printf '%s' "$view_out" | grep -qi 'Not Found'; then gh release create "$tag" --repo "$TARGET_REPO" --target main \ --title "PR #$PR_NUMBER: $title" --notes-file "$notes" --prerelease @@ -268,10 +270,11 @@ jobs: printf '%s\n' "$view_out" exit 1 fi - gh api "repos/$TARGET_REPO/releases/tags/$tag" \ - --jq '.assets[] | select(.name | endswith(".apk")) | .id' | + gh release upload "$tag" "signed/$APK_NAME" --repo "$TARGET_REPO" --clobber + gh release edit "$tag" --repo "$TARGET_REPO" --title "PR #$PR_NUMBER: $title" --notes-file "$notes" --prerelease + gh api "repos/$TARGET_REPO/releases/tags/$tag" | + jq -r --arg keep "$APK_NAME" '.assets[] | select(.name | endswith(".apk")) | select(.name != $keep) | .id' | while IFS= read -r asset_id; do gh api --method DELETE "repos/$TARGET_REPO/releases/assets/$asset_id" >/dev/null done - gh release upload "$tag" "signed/$APK_NAME" "signed/SHA256SUMS.txt" --repo "$TARGET_REPO" --clobber gh release view "$tag" --repo "$TARGET_REPO" --json url --jq '"Published: \(.url)"' From 98066f85ef6f3445a3b4ccc1cd14b0b072be71ba Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Thu, 24 Sep 2026 20:45:25 -0400 Subject: [PATCH 7/9] Simplify approved PR signing workflow --- .github/workflows/build.yml | 6 +- .github/workflows/pr-release-publish.yml | 282 +++++++---------------- .github/workflows/sign.yml | 9 +- tools/release/make-release-key.sh | 5 +- 4 files changed, 83 insertions(+), 219 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 87621846a..b1af54089 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -453,7 +453,7 @@ jobs: success) echo "### Build passed" echo - echo "The unprivileged apk is the \`droiddeck-apk\` artifact on [this run]($RUN_URL), signed with the public test key. After a maintainer approves this exact commit and CI is green, a maintainer can run **PR Release Publish** from \`main\` with this PR number. That workflow signs the artifact with the release identity, checks that its versionCode matches the latest stable release, and publishes it for in-place update testing. The release key is never available to this PR build." + 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" @@ -499,9 +499,7 @@ jobs: # An apk is a zip already; compressing it again buys nothing and costs seconds. compression-level: 0 - # This workflow signs only main. PR build jobs keep using the public AOSP test key; the separate - # main-only approval workflow can later sign an approved PR artifact without running PR code with - # the release key. + # PR builds use the test key; only main calls the release signer. sign: needs: build if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' diff --git a/.github/workflows/pr-release-publish.yml b/.github/workflows/pr-release-publish.yml index be35d690c..2b74c6495 100644 --- a/.github/workflows/pr-release-publish.yml +++ b/.github/workflows/pr-release-publish.yml @@ -1,14 +1,11 @@ name: PR Release Publish run-name: sign PR #${{ inputs.pr_number }} with the release key -# This is deliberately manual and exists on main. PR CI builds without secrets. A maintainer -# dispatches this workflow after reviewing the PR; it checks that the current head has a fresh -# maintainer approval and a successful Build APK run before it calls the release signer. on: workflow_dispatch: inputs: pr_number: - description: Open, approved pull request to sign and publish + description: Open, approved pull request required: true type: number @@ -23,160 +20,87 @@ concurrency: jobs: authorize: - name: verify maintainer approval and PR build if: github.ref == 'refs/heads/main' runs-on: ubuntu-24.04 - timeout-minutes: 10 outputs: - run_id: ${{ steps.authorize.outputs.run_id }} - pr_number: ${{ steps.authorize.outputs.pr_number }} - head_sha: ${{ steps.authorize.outputs.head_sha }} - label: ${{ steps.authorize.outputs.label }} - apk_name: ${{ steps.authorize.outputs.apk_name }} + run_id: ${{ steps.gate.outputs.run_id }} + head_sha: ${{ steps.gate.outputs.head_sha }} + label: ${{ steps.gate.outputs.label }} steps: - - uses: actions/checkout@v4 - with: - ref: main - persist-credentials: false - - - name: Authorize this exact PR revision - id: authorize + - name: Check the PR, review, and build + id: gate env: GH_TOKEN: ${{ github.token }} PR_NUMBER: ${{ inputs.pr_number }} run: | set -euo pipefail - [[ "$GITHUB_REF" == refs/heads/main ]] || { echo "::error::Run this workflow from main"; exit 1; } - [[ "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]] || { echo "::error::PR number must be a positive integer"; exit 1; } - pr=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER") - state=$(jq -r '.state' <<<"$pr") - draft=$(jq -r '.draft' <<<"$pr") - base_ref=$(jq -r '.base.ref' <<<"$pr") - base_sha=$(jq -r '.base.sha' <<<"$pr") - head_sha=$(jq -r '.head.sha' <<<"$pr") - [[ "$state" == open && "$draft" == false && "$base_ref" == main ]] || { - echo "::error::PR #$PR_NUMBER must be open, ready for review, and target main"; exit 1; - } + 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") - # Find the newest PR CI run for this commit. A newer failed or in-progress run prevents - # signing even if an older attempt for the same commit passed. - runs=$(gh run list --repo "$GITHUB_REPOSITORY" --workflow build.yml \ - --event pull_request --commit "$head_sha" \ - --json databaseId,headSha,status,conclusion,attempt,createdAt -L 100) - latest=$(jq -ce --arg sha "$head_sha" ' - [ .[] | select(.headSha == $sha) ] - | sort_by([.createdAt, .attempt]) - | last - ' <<<"$runs") - [[ $(jq -r '.status' <<<"$latest") == completed && $(jq -r '.conclusion' <<<"$latest") == success ]] || { - echo "::error::The latest Build APK run for this PR commit is not green"; exit 1; - } - run_id=$(jq -r '.databaseId' <<<"$latest") + 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") - run_pr=$(jq -ce --argjson number "$PR_NUMBER" ' - [ .pull_requests[]? | select(.number == $number) ] | first - ' <<<"$run") - [[ $(jq -r '.event' <<<"$run") == pull_request && \ - $(jq -r '.head_sha' <<<"$run") == "$head_sha" && \ - $(jq -r '.status' <<<"$run") == completed && \ - $(jq -r '.conclusion' <<<"$run") == success && \ - $(jq -r '.head.sha' <<<"$run_pr") == "$head_sha" && \ - $(jq -r '.base.ref' <<<"$run_pr") == main && \ - $(jq -r '.base.sha' <<<"$run_pr") == "$base_sha" ]] || { - echo "::error::The successful CI artifact is not for the current PR head and base"; exit 1; - } + 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 - # Use the last public stable APK as the versionCode baseline. Main can be ahead of the - # shipped release, but testers still need to switch back to that release in place. - sdk=${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}} - [[ -n "$sdk" && -d "$sdk/build-tools" ]] || { echo "::error::Android build-tools are unavailable"; exit 1; } - build_tools=$(find "$sdk/build-tools" -mindepth 1 -maxdepth 1 -type d | sort -V | tail -1) - stable_repo=The412Banner/DroidDeck - stable_tag=$(gh api "repos/$stable_repo/releases/latest" --jq '.tag_name') - [[ "$stable_tag" =~ ^[0-9A-Za-z._-]+$ ]] || { echo "::error::Could not identify the latest stable release"; exit 1; } - mkdir -p "$RUNNER_TEMP/stable-apk" - gh release download "$stable_tag" --repo "$stable_repo" \ - --pattern "DroidDeck-$stable_tag.apk" --dir "$RUNNER_TEMP/stable-apk" - stable_apk="$RUNNER_TEMP/stable-apk/DroidDeck-$stable_tag.apk" - test -s "$stable_apk" || { echo "::error::The latest stable release has no standard APK"; exit 1; } - stable_badging=$("$build_tools/aapt" dump badging "$stable_apk") - stable_package=$(sed -n "s/^package: name='\\([^']*\\)'.*/\\1/p" <<<"$stable_badging") - expected_code=$(sed -n "s/^package:.*versionCode='\\([^']*\\)'.*/\\1/p" <<<"$stable_badging") - [[ "$stable_package" == com.droiddeck.launcher && "$expected_code" =~ ^[0-9]+$ ]] || { - echo "::error::Could not read the standard package and versionCode from stable release $stable_tag"; exit 1; - } - mkdir -p "$RUNNER_TEMP/pr-apk" - gh run download "$run_id" --repo "$GITHUB_REPOSITORY" --name droiddeck-apk --dir "$RUNNER_TEMP/pr-apk" - mapfile -t apks < <(find "$RUNNER_TEMP/pr-apk" -type f -name '*.apk' -print) - [[ ${#apks[@]} -eq 1 ]] || { echo "::error::Expected exactly one APK in droiddeck-apk"; exit 1; } - badging=$("$build_tools/aapt" dump badging "${apks[0]}") - apk_package=$(sed -n "s/^package: name='\\([^']*\\)'.*/\\1/p" <<<"$badging") - apk_code=$(sed -n "s/^package:.*versionCode='\\([^']*\\)'.*/\\1/p" <<<"$badging") - [[ "$apk_package" == com.droiddeck.launcher ]] || { - echo "::error::Expected the standard package, got '$apk_package'"; exit 1; - } - [[ "$apk_code" == "$expected_code" ]] || { - echo "::error::PR APK versionCode $apk_code differs from stable release $stable_tag versionCode $expected_code; keep PR builds at the stable code so testers can switch back in place"; exit 1; - } - - # Check approval after the build and artifact checks, as close as possible to signing. - # The reviewer must approve this exact head commit and have write or admin access. - reviews=$(gh api --paginate --slurp "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/reviews?per_page=100" | jq 'add') - approved_reviewers=$(jq -r --arg sha "$head_sha" ' - [ .[] - | select(.commit_id == $sha) - | { login: .user.login, state: .state, submitted_at: .submitted_at } - ] - | sort_by(.submitted_at) - | group_by(.login) - | map(last) - | map(select(.state == "APPROVED")) - | .[].login - ' <<<"$reviews") + 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 - [[ "$reviewer" =~ ^[A-Za-z0-9-]{1,39}$ ]] || continue - if permission=$(gh api "repos/$GITHUB_REPOSITORY/collaborators/$reviewer/permission" --jq '.permission' 2>/dev/null); then - case "$permission" in - write|admin) maintainer_approved=true ;; - esac - fi - done <<<"$approved_reviewers" - [[ "$maintainer_approved" == true ]] || { - echo "::error::No reviewer with write or admin access has approved the current PR commit $head_sha"; exit 1; + [[ -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; } + + # PR builds use the public test key. Keep their versionCode at the shipped release + # so testers can install the stable APK over a signed PR build. + 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" } - # Re-read PR head, base, and review decision after the permission lookups. - latest_pr=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER") - decision=$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json reviewDecision --jq '.reviewDecision') - [[ $(jq -r '.state' <<<"$latest_pr") == open && \ - $(jq -r '.head.sha' <<<"$latest_pr") == "$head_sha" && \ - $(jq -r '.base.sha' <<<"$latest_pr") == "$base_sha" && \ - "$decision" == APPROVED ]] || { - echo "::error::The PR or its approval changed while authorization was being checked"; exit 1; + 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; } - short_sha=${head_sha:0:7} - label="PR.$PR_NUMBER-$short_sha" - apk_name="DroidDeck-$label.apk" + 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 "pr_number=$PR_NUMBER" - echo "head_sha=$head_sha" - echo "label=$label" - echo "apk_name=$apk_name" + echo "head_sha=$head" + echo "label=PR.$PR_NUMBER-${head:0:7}" } >> "$GITHUB_OUTPUT" - { - echo "### Authorized PR signing" - echo - printf 'PR #%s at %s has an approving maintainer review and a green Build APK run (%s).\n' "$PR_NUMBER" "$short_sha" "$run_id" - printf 'The APK versionCode matches stable release %s (%s).\n' "$stable_tag" "$expected_code" - } >> "$GITHUB_STEP_SUMMARY" sign: - name: sign with the release identity needs: authorize - if: needs.authorize.result == 'success' uses: ./.github/workflows/sign.yml with: environment: signing-main @@ -187,94 +111,42 @@ jobs: secrets: inherit publish: - name: publish the approved PR APK needs: [authorize, sign] - if: needs.authorize.result == 'success' && needs.sign.result == 'success' runs-on: ubuntu-24.04 - timeout-minutes: 10 permissions: actions: read contents: read - pull-requests: read steps: - - uses: actions/checkout@v4 - with: - ref: main - persist-credentials: false - - uses: actions/download-artifact@v4 with: name: droiddeck-pr-signed-standard path: signed - - - name: Verify the release signer and checksum - env: - APK_NAME: ${{ needs.authorize.outputs.apk_name }} - run: | - set -euo pipefail - apk="signed/$APK_NAME" - test -s "$apk" || { echo "::error::Signed APK is missing: $APK_NAME"; exit 1; } - expected_sha256=$(tr -d '[:space:]' < keystore/release-signer.sha256) - [[ "$expected_sha256" =~ ^[[:xdigit:]]{64}$ ]] || { - echo "::error::keystore/release-signer.sha256 is not a SHA-256 digest"; exit 1; - } - sdk=${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}} - [[ -n "$sdk" && -d "$sdk/build-tools" ]] || { echo "::error::Android build-tools are unavailable"; exit 1; } - tools=$(find "$sdk/build-tools" -mindepth 1 -maxdepth 1 -type d | sort -V | tail -1) - certs=$("$tools/apksigner" verify --min-sdk-version 28 --print-certs "$apk") - digest=$(sed -n 's/.*certificate SHA-256 digest: //p' <<<"$certs" | sort -u) - [[ "$digest" == "$expected_sha256" ]] || { - printf '%s\n' "$certs" >&2 - echo "::error::PR APK is not signed with the release certificate"; exit 1; - } - (cd signed && sha256sum -- "$APK_NAME" > SHA256SUMS.txt) - cat signed/SHA256SUMS.txt - - - name: Publish to DroidDeck-CI when its token is configured + - name: Publish to DroidDeck-CI when configured env: GH_TOKEN: ${{ secrets.DROIDDECK_CI_TOKEN }} - PR_NUMBER: ${{ needs.authorize.outputs.pr_number }} + PR_NUMBER: ${{ inputs.pr_number }} HEAD_SHA: ${{ needs.authorize.outputs.head_sha }} - APK_NAME: ${{ needs.authorize.outputs.apk_name }} - SOURCE_REPO: ${{ github.repository }} - TARGET_REPO: Droid-Deck/DroidDeck-CI + LABEL: ${{ needs.authorize.outputs.label }} run: | set -euo pipefail - if [ -z "$GH_TOKEN" ]; then - echo "::warning::DROIDDECK_CI_TOKEN is not configured; the release-signed APK remains available as this workflow run's artifact" + if [[ -z "$GH_TOKEN" ]]; then + echo '::warning::No CI token; download the signed APK from this run' exit 0 fi - pr=$(gh api "repos/$SOURCE_REPO/pulls/$PR_NUMBER") - title=$(jq -r '.title' <<<"$pr" | tr '\r\n' ' ' | sed 's/[[:space:]]\+/ /g; s/ $//') - url=$(jq -r '.html_url' <<<"$pr") - signing_cert_sha256=$(tr -d '[:space:]' < keystore/release-signer.sha256) - apk_sha256=$(awk 'NR == 1 { print $1 }' signed/SHA256SUMS.txt) + repo=Droid-Deck/DroidDeck-CI tag="pr-$PR_NUMBER" - notes=$(mktemp) - trap 'rm -f "$notes"' EXIT - { - printf '# PR #%s\n\n' "$PR_NUMBER" - printf 'Title: %s\n\n' "$title" - printf 'Source PR: %s\n' "$url" - printf "Source commit: \`%s\`\n" "$HEAD_SHA" - printf "APK SHA-256: \`%s\`\n" "$apk_sha256" - printf "Signed with DroidDeck release certificate SHA-256 \`%s\`.\n" "$signing_cert_sha256" - printf 'This is a review-approved test build. Back up app data before installing test builds.\n' - } > "$notes" - if view_out=$(gh api "repos/$TARGET_REPO/releases/tags/$tag" 2>&1); then - : - elif printf '%s' "$view_out" | grep -qi 'Not Found'; then - gh release create "$tag" --repo "$TARGET_REPO" --target main \ - --title "PR #$PR_NUMBER: $title" --notes-file "$notes" --prerelease - else - printf '%s\n' "$view_out" - exit 1 + 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_NAME" --repo "$TARGET_REPO" --clobber - gh release edit "$tag" --repo "$TARGET_REPO" --title "PR #$PR_NUMBER: $title" --notes-file "$notes" --prerelease - gh api "repos/$TARGET_REPO/releases/tags/$tag" | - jq -r --arg keep "$APK_NAME" '.assets[] | select(.name | endswith(".apk")) | select(.name != $keep) | .id' | - while IFS= read -r asset_id; do - gh api --method DELETE "repos/$TARGET_REPO/releases/assets/$asset_id" >/dev/null + 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 view "$tag" --repo "$TARGET_REPO" --json url --jq '"Published: \(.url)"' diff --git a/.github/workflows/sign.yml b/.github/workflows/sign.yml index 7445e2adf..9d630c529 100644 --- a/.github/workflows/sign.yml +++ b/.github/workflows/sign.yml @@ -1,12 +1,7 @@ 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 main builds and maintainer-approved PR artifacts -# release manually dispatched release.yml from main -# PR Build APK jobs stay test-key-only. PR Release Publish calls this from main only after approval, -# green CI, the exact current commit, and the stable versionCode have been checked. +# Called from main to sign builds, approved PR artifacts, and releases. The key lives in +# the caller's protected environment; PR build jobs use only the public test key. on: workflow_call: diff --git a/tools/release/make-release-key.sh b/tools/release/make-release-key.sh index b707d833b..680fcab91 100755 --- a/tools/release/make-release-key.sh +++ b/tools/release/make-release-key.sh @@ -3,9 +3,8 @@ # # - 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 two signing environments -# (signing-main and release). PR build jobs use the public test key; a separate workflow signs -# an approved PR artifact from main. +# - The key and password are secrets in the signing-main and release environments. +# PR build jobs use only the public test key. # - 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. # From 67b675066f0ae68863ea64d40b17472e3a068e57 Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Thu, 24 Sep 2026 21:01:35 -0400 Subject: [PATCH 8/9] Trim workflow commentary and redundant build output --- .github/workflows/build.yml | 108 ----------------------- .github/workflows/pr-release-publish.yml | 2 - .github/workflows/release.yml | 8 -- .github/workflows/sign.yml | 4 - 4 files changed, 122 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b1af54089..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,16 +19,13 @@ 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: @@ -60,21 +35,11 @@ jobs: 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 @@ -104,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 @@ -146,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 @@ -156,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 @@ -181,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 @@ -203,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 @@ -213,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 @@ -241,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 @@ -260,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" @@ -270,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 @@ -301,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; } @@ -326,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: @@ -342,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: @@ -353,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 @@ -372,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" @@ -381,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 @@ -407,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" \ @@ -428,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 @@ -480,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) @@ -496,14 +392,11 @@ 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 - # PR builds use the test key; only main calls the release signer. sign: needs: build if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' - # What sign.yml asks for; a called workflow never gets more than its caller gives it. permissions: contents: read actions: read @@ -511,5 +404,4 @@ jobs: with: 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 2b74c6495..72ef506be 100644 --- a/.github/workflows/pr-release-publish.yml +++ b/.github/workflows/pr-release-publish.yml @@ -65,8 +65,6 @@ jobs: done <<<"$approved" [[ "$maintainer_approved" == true ]] || { echo '::error::A maintainer must approve this commit'; exit 1; } - # PR builds use the public test key. Keep their versionCode at the shipped release - # so testers can install the stable APK over a signed PR build. 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) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f9d705aed..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. This workflow is manually dispatched from main; 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 9d630c529..cad16ca03 100644 --- a/.github/workflows/sign.yml +++ b/.github/workflows/sign.yml @@ -1,8 +1,5 @@ name: Sign APKs -# Called from main to sign builds, approved PR artifacts, and releases. The key lives in -# the caller's protected environment; PR build jobs use only the public test key. - on: workflow_call: inputs: @@ -62,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 }} From 4bc7c8dc99996535b813a0c1b41fb16ef804064f Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Thu, 24 Sep 2026 21:02:13 -0400 Subject: [PATCH 9/9] Trim signing key script comments --- tools/release/make-release-key.sh | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/tools/release/make-release-key.sh b/tools/release/make-release-key.sh index 680fcab91..1278ff800 100755 --- a/tools/release/make-release-key.sh +++ b/tools/release/make-release-key.sh @@ -1,15 +1,4 @@ #!/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 are secrets in the signing-main and release environments. -# PR build jobs use only the public test key. -# - 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=Droid-Deck/DroidDeck @@ -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"