-
Notifications
You must be signed in to change notification settings - Fork 0
180 lines (166 loc) · 8.62 KB
/
Copy pathrelease.yml
File metadata and controls
180 lines (166 loc) · 8.62 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
name: Release
# Cut a release by pushing a tag, e.g.: git tag v2.0 && git push origin v2.0
on:
push:
tags: [ 'v*' ]
workflow_dispatch:
inputs:
tag:
description: 'Release tag (e.g. v2.0)'
required: true
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
env:
# A release asset must be signed, so all four signing secrets are required.
# HAS_SIGNING_SECRETS is false if any one of them is unset or empty.
HAS_SIGNING_SECRETS: ${{ secrets.KEYSTORE_BASE64 != '' && secrets.KEYSTORE_PASSWORD != '' && secrets.KEY_ALIAS != '' && secrets.KEY_PASSWORD != '' }}
VERSION_TAG: ${{ github.event.inputs.tag || github.ref_name }}
steps:
# Fail before provisioning the toolchain, not after. A release asset MUST be
# signed: it is the reference binary F-Droid byte-compares against, and
# AllowedAPKSigningKeys pins our cert. With no keystore `assembleRelease`
# silently produces an UNSIGNED APK, which this workflow would then publish.
- name: Require a release tag and the signing secrets
run: |
# workflow_dispatch inputs are free-form strings; an empty one falls back
# to the dispatching branch. Publishing that would tag/release a branch.
case "${VERSION_TAG}" in
v?*) ;;
*)
echo "::error::'${VERSION_TAG}' is not a release tag — expected a v* tag (e.g. v2.0.3)."
exit 1
;;
esac
if [ "${HAS_SIGNING_SECRETS}" != "true" ]; then
echo "::error::One or more signing secrets are unset or empty — refusing to publish an unsigned release APK."
echo "::error::All of KEYSTORE_BASE64, KEYSTORE_PASSWORD, KEY_ALIAS and KEY_PASSWORD must be set."
exit 1
fi
# Always build the *tag*, never the branch the run was dispatched from.
# Without an explicit ref a workflow_dispatch checks out the dispatching
# branch, and AGP stamps that commit into META-INF/version-control-info
# .textproto — which breaks F-Droid's reproducible-build byte comparison
# against the commit pinned in fdroid/dev.debene.gopher.yml. The refs/tags/
# prefix matters: an unqualified name resolves a same-named *branch* first.
- uses: actions/checkout@v4
with:
ref: refs/tags/${{ github.event.inputs.tag || github.ref_name }}
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '21'
- name: Set up Android SDK
uses: android-actions/setup-android@v3
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4
- name: Decode & verify release keystore
run: |
# Strip any whitespace/newlines the secret may have picked up, then decode.
printf '%s' "${{ secrets.KEYSTORE_BASE64 }}" | tr -d '[:space:]' | base64 -d > "$RUNNER_TEMP/release.jks"
if ! keytool -list -keystore "$RUNNER_TEMP/release.jks" \
-storepass "${{ secrets.KEYSTORE_PASSWORD }}" \
-alias "${{ secrets.KEY_ALIAS }}" >/dev/null 2>&1; then
echo "::error::Keystore could not be opened with the provided secrets."
echo "::error::Check KEYSTORE_PASSWORD and KEY_ALIAS. For a keytool .jks (PKCS12), KEY_PASSWORD must equal KEYSTORE_PASSWORD, and secrets must have no trailing spaces/newlines."
exit 1
fi
echo "Keystore verified."
echo "DEBURROW_KEYSTORE_FILE=$RUNNER_TEMP/release.jks" >> "$GITHUB_ENV"
- name: Build release APK
env:
DEBURROW_KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
DEBURROW_KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
DEBURROW_KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
run: |
chmod +x ./gradlew
./gradlew testDebugUnitTest assembleRelease --stacktrace
- name: Stage APK
run: |
mkdir -p dist
apk=$(ls app/build/outputs/apk/release/app-release*.apk | head -1)
echo "Using $apk"
cp "$apk" "dist/DeBurrow-${VERSION_TAG}.apk"
# F-Droid byte-compares this APK against its own build of the commit pinned
# in fdroid/dev.debene.gopher.yml. AGP embeds the build's git SHA, so a
# mismatch here means the reproducible-build check will fail downstream.
- name: Verify APK was built from the tagged commit
shell: bash
run: |
set -o pipefail
# Resolve the *tag*, not HEAD: HEAD is the same workspace that produced
# the APK, so comparing against it would be true by construction and
# would catch nothing (including a force-moved tag).
if ! git rev-parse -q --verify "refs/tags/${VERSION_TAG}^{commit}" >/dev/null; then
git fetch --depth=1 origin "refs/tags/${VERSION_TAG}:refs/tags/${VERSION_TAG}" >/dev/null 2>&1 || true
fi
expected=$(git rev-parse -q --verify "refs/tags/${VERSION_TAG}^{commit}") || {
echo "::error::Tag ${VERSION_TAG} does not resolve in this checkout — cannot verify the build commit."
exit 1
}
actual=$(unzip -p "dist/DeBurrow-${VERSION_TAG}.apk" META-INF/version-control-info.textproto \
| grep -ao '[0-9a-f]\{40\}' | head -1) || actual=""
echo "tag ${VERSION_TAG}: $expected"
echo "APK revision: ${actual:-<none embedded>}"
# No VCS info at all is a failure, not a pass: F-Droid's build would
# embed one where ours does not, and the byte comparison would fail.
if [ -z "$actual" ]; then
echo "::error::No revision found in META-INF/version-control-info.textproto — cannot verify the build commit."
exit 1
fi
if [ "$actual" != "$expected" ]; then
echo "::error::APK was built from $actual but ${VERSION_TAG} is $expected — refusing to publish."
exit 1
fi
# AllowedAPKSigningKeys in the F-Droid recipe pins this exact cert; a
# mismatch (or a missing signature) breaks the reproducible-build opt-in.
- name: Verify APK is signed with the expected key
env:
# SHA-256 of the release signing cert — same value as AllowedAPKSigningKeys
# in fdroid/dev.debene.gopher.yml. Public information, not a secret.
EXPECTED_CERT_SHA256: 353db8b0a3c94c001eb71114b8640befa35e38760ee6c3ca4f301c7aedc5a1dd
shell: bash
run: |
set -o pipefail
# Guard against this literal drifting from the recipe on a key rotation.
# Tags cut before AllowedAPKSigningKeys was added don't declare it, so
# only cross-check when the checked-out recipe actually has the field.
recipe="fdroid/dev.debene.gopher.yml"
declared=$(awk '/^AllowedAPKSigningKeys:/ {print $2; exit}' "$recipe" 2>/dev/null || true)
if [ -n "$declared" ] && [ "$declared" != "$EXPECTED_CERT_SHA256" ]; then
echo "::error::EXPECTED_CERT_SHA256 ($EXPECTED_CERT_SHA256) does not match AllowedAPKSigningKeys in $recipe ($declared)."
echo "::error::Update both together — the recipe is the source of truth and is a one-way door on F-Droid."
exit 1
fi
apk="dist/DeBurrow-${VERSION_TAG}.apk"
apksigner=$(ls -d "$ANDROID_HOME"/build-tools/*/apksigner 2>/dev/null | sort -V | tail -1)
if [ -z "$apksigner" ]; then
echo "::error::apksigner not found under $ANDROID_HOME/build-tools."
exit 1
fi
if ! "$apksigner" verify --print-certs "$apk" > "$RUNNER_TEMP/certs.txt"; then
echo "::error::$apk is not a validly signed APK."
exit 1
fi
actual=$(grep -i 'certificate SHA-256 digest' "$RUNNER_TEMP/certs.txt" | head -1 | awk '{print $NF}' | tr 'A-Z' 'a-z')
echo "expected cert: $EXPECTED_CERT_SHA256"
echo "actual cert: ${actual:-<none>}"
if [ "$actual" != "$EXPECTED_CERT_SHA256" ]; then
echo "::error::APK signing cert does not match AllowedAPKSigningKeys — refusing to publish."
exit 1
fi
- name: Publish GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
asset="dist/DeBurrow-${VERSION_TAG}.apk"
if gh release view "${VERSION_TAG}" >/dev/null 2>&1; then
echo "Release ${VERSION_TAG} exists — replacing its APK asset."
gh release upload "${VERSION_TAG}" "$asset" --clobber
else
gh release create "${VERSION_TAG}" "$asset" \
--title "DeBurrow ${VERSION_TAG}" --generate-notes
fi