-
-
Notifications
You must be signed in to change notification settings - Fork 9
Expand file tree
/
Copy pathrelease
More file actions
executable file
·303 lines (272 loc) · 10.6 KB
/
Copy pathrelease
File metadata and controls
executable file
·303 lines (272 loc) · 10.6 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
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
#!/bin/bash
# Usage: ./release vX.Y.Z
set -euo pipefail
die() {
echo "error: $*" >&2
exit 1
}
normalize_fp() {
local fp="${1//[[:space:]:]/}"
echo "${fp,,}"
}
cd "$(dirname "$0")"
readonly CERT_FILE="CERT.sha256"
readonly METADATA_DIR="metadata/en-US"
readonly GRADLE_FILE="app/build.gradle.kts"
APP_NAME=""
readonly PKCS11_LIB="/usr/lib/x86_64-linux-gnu/opensc-pkcs11.so"
EXPECTED_CERT=""
[[ -f ${CERT_FILE} ]] && EXPECTED_CERT="$(normalize_fp "$(<"${CERT_FILE}")")"
TAG="${1:-}"
[[ -n ${TAG} ]] || die "usage: $0 <vX.Y.Z>"
SEMVER='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)'
SEMVER+='(-(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)'
SEMVER+='(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*)?'
SEMVER+='(\+([0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*))?$'
[[ ${TAG} =~ ${SEMVER} ]] || die "invalid version tag"
VERSION_NAME="${TAG#v}"
VERSION_MAJOR="${BASH_REMATCH[1]}"
VERSION_MINOR="${BASH_REMATCH[2]}"
VERSION_PATCH="${BASH_REMATCH[3]}"
# Build metadata and bare channels alias another tag's versionCode.
[[ ${VERSION_NAME} == *+* ]] \
&& die "build metadata (+) is not allowed in release tags"
if [[ ${VERSION_NAME} == *-* ]]; then
PRERELEASE="${VERSION_NAME#*-}"
else
PRERELEASE=""
fi
if [[ -z ${PRERELEASE} ]]; then
PRE_ORDINAL=999
else
[[ ${PRERELEASE} == *.* ]] \
|| die "prerelease channel needs an explicit number (use -${PRERELEASE}.0)"
PRE_CHANNEL="${PRERELEASE%%.*}"
PRE_NUM="${PRERELEASE#*.}"
[[ ${PRE_NUM} =~ ^[0-9]+$ && ${PRE_NUM} -lt 100 ]] \
|| die "prerelease number must be numeric and < 100 (got '${PRE_NUM}')"
case "${PRE_CHANNEL}" in
alpha) PRE_ORDINAL=$((PRE_NUM)) ;;
beta) PRE_ORDINAL=$((100 + PRE_NUM)) ;;
rc) PRE_ORDINAL=$((200 + PRE_NUM)) ;;
*) die "unknown prerelease channel '${PRE_CHANNEL}' (use alpha|beta|rc)" ;;
esac
fi
# Bounds keep versionCode inside a signed 32-bit int.
((VERSION_MAJOR <= 210 && VERSION_MINOR < 100 && VERSION_PATCH < 100)) \
|| die "version out of range for versionCode (major <= 210, minor/patch < 100)"
VERSION_CODE=$((VERSION_MAJOR * 10000000 + VERSION_MINOR * 100000))
VERSION_CODE=$((VERSION_CODE + VERSION_PATCH * 1000 + PRE_ORDINAL))
# F-Droid reads changelogs/<versionCode>.txt from the tag on stable releases.
CHANGELOG="${METADATA_DIR}/changelogs/${VERSION_CODE}.txt"
if ((PRE_ORDINAL == 999)); then
[[ -s ${CHANGELOG} ]] \
|| die "missing ${CHANGELOG}, write and commit it first"
(($(wc -c <"${CHANGELOG}") <= 500)) \
|| die "${CHANGELOG} exceeds F-Droid's 500-character limit"
fi
command -v gh >/dev/null || die "gh not found"
gh auth status >/dev/null 2>&1 \
|| die "gh not authenticated, run: gh auth login"
command -v java >/dev/null || die "java not found"
[[ -f ${PKCS11_LIB} ]] \
|| die "PKCS#11 module not found at ${PKCS11_LIB} (apt install opensc-pkcs11)"
[[ -z "$(git status --porcelain)" ]] || die "working tree not clean"
git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null \
&& die "tag ${TAG} already exists"
# gh release create silently reuses an existing remote tag, ignoring --target.
git ls-remote --exit-code --tags origin "refs/tags/${TAG}" >/dev/null 2>&1 \
&& die "tag ${TAG} already exists on origin"
gh release view "${TAG}" >/dev/null 2>&1 \
&& die "release ${TAG} already exists"
git fetch -q origin main
[[ "$(git rev-parse HEAD)" == "$(git rev-parse origin/main)" ]] \
|| die "HEAD is not the tip of origin/main, sync first"
SDK="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-${HOME}/Android/Sdk}}"
[[ -d "${SDK}/build-tools" ]] || die "Android build-tools not found in ${SDK}"
BT=$(printf '%s\n' "${SDK}"/build-tools/[0-9]* | sort -V | tail -1)
[[ -d ${BT} ]] || die "no versioned build-tools dir under ${SDK}/build-tools"
[[ -x "${BT}/zipalign" && -f "${BT}/lib/apksigner.jar" ]] \
|| die "zipalign/apksigner missing under ${BT}"
BT_VERSION="$(basename "${BT}")"
((${BT_VERSION%%.*} >= 35)) \
|| die "build-tools >= 35 required (apksigner --alignment-preserved)"
# bundletool cannot sign via PKCS#11, so the YubiKey signs the splits after.
BUNDLETOOL=()
command -v bundletool >/dev/null && BUNDLETOOL=(bundletool)
OUT="build/release"
mkdir -p "${OUT}"
NOTES="${OUT}/release-notes.md"
PKCS11_CFG=""
APKS_TMP=""
# A failed last command in an EXIT trap becomes the exit status, hence || true.
cleanup() {
rm -rf "${PKCS11_CFG}" "${APKS_TMP}"
git checkout -q -- "${GRADLE_FILE}" || true
}
trap cleanup EXIT
PKCS11_CFG="$(mktemp)"
printf 'name = OpenSC\nlibrary = %s\n' "${PKCS11_LIB}" >"${PKCS11_CFG}"
# Detect the token through the same PKCS#11 stack apksigner uses. A USB-level
# check (ykman) can pass while this path is broken.
pkcs11_token_present() {
local out
out=$(echo | LC_ALL=C keytool -keystore NONE -storetype PKCS11 \
-providerClass sun.security.pkcs11.SunPKCS11 \
-providerArg "${PKCS11_CFG}" -list 2>&1 || true)
grep -q 'Enter keystore password' <<<"${out}"
}
echo ">> Checking YubiKey access"
# One retry absorbs pcscd's cold start after a replug.
pkcs11_token_present || {
sleep 2
pkcs11_token_present
} \
|| die "YubiKey not visible via PKCS#11, replug it and retry"
echo ">> Setting version ${VERSION_NAME} (code ${VERSION_CODE})"
sed -i -E \
-e "s/(versionCode = )[0-9]+/\1${VERSION_CODE}/" \
-e "s/(versionName = )\"[^\"]*\"/\1\"${VERSION_NAME}\"/" \
"${GRADLE_FILE}"
grep -q "versionCode = ${VERSION_CODE}$" "${GRADLE_FILE}" \
&& grep -q "versionName = \"${VERSION_NAME}\"$" "${GRADLE_FILE}" \
|| die "failed to write version into ${GRADLE_FILE}"
GRADLE_TASKS=(:app:assembleRelease)
((${#BUNDLETOOL[@]})) && GRADLE_TASKS+=(:app:bundleRelease)
echo ">> Building release artifacts"
./gradlew "${GRADLE_TASKS[@]}"
shopt -s nullglob
RELEASE_APKS=(app/build/outputs/apk/release/*-release-unsigned.apk)
shopt -u nullglob
((${#RELEASE_APKS[@]} == 1)) \
|| die "expected exactly one *-release-unsigned.apk, found ${#RELEASE_APKS[@]}"
UNSIGNED="${RELEASE_APKS[0]}"
APK_BASE="$(basename "${UNSIGNED}" -release-unsigned.apk)"
SIGNED="${OUT}/${APK_BASE}-${TAG}.apk"
AAB="app/build/outputs/bundle/release/${APK_BASE}-release.aab"
# A failed run must not leave a previous run's artifacts looking current.
rm -f "${SIGNED}" "${SIGNED%.apk}.apks"
if [[ -z ${APP_NAME} ]]; then
APP_NAME="$("${BT}/aapt2" dump badging "${UNSIGNED}" 2>/dev/null \
| sed -n "s/.*application-label:'\([^']*\)'.*/\1/p" | head -1)"
fi
[[ -n ${APP_NAME} ]] || APP_NAME="${APK_BASE}"
# Realigning would change the bytes F-Droid verifies, so check only.
echo ">> Checking APK alignment"
"${BT}/zipalign" -c -p 4 "${UNSIGNED}" >/dev/null \
|| die "unsigned APK is not aligned"
# The PIN must never reach argv or disk.
read -rsp '>> Enter YubiKey PIN: ' YUBIKEY_PIN
echo
# --add-exports unlocks SunPKCS11 for apksigner. minSdk >= 24 needs no v1/v4.
sign_apk() {
local input="$1" output="$2" rc
shift 2
set +e
printf '%s\n' "${YUBIKEY_PIN}" \
| java --enable-native-access=ALL-UNNAMED \
--add-exports jdk.crypto.cryptoki/sun.security.pkcs11=ALL-UNNAMED \
-jar "${BT}/lib/apksigner.jar" sign \
--ks NONE --ks-type PKCS11 \
--provider-class sun.security.pkcs11.SunPKCS11 \
--provider-arg "${PKCS11_CFG}" \
--ks-key-alias "Certificate for Digital Signature" \
--ks-pass stdin \
--v1-signing-enabled false \
--v4-signing-enabled false \
"$@" --out "${output}" "${input}" \
| grep -v '^Keystore password for signer #'
rc=${PIPESTATUS[1]}
set -e
return "${rc}"
}
# apksigner would otherwise repad the archive and break F-Droid verification.
echo ">> Signing the APK with the YubiKey, please touch your device"
sign_apk "${UNSIGNED}" "${SIGNED}" --alignment-preserved \
|| die "apksigner signing failed"
verify_cert() {
local fp
fp=$(
java --enable-native-access=ALL-UNNAMED \
-jar "${BT}/lib/apksigner.jar" verify --print-certs "$1" \
| awk '/SHA-256 digest:/ {print $NF; exit}'
)
normalize_fp "${fp}"
}
echo ">> Verifying signature"
GOT_NORM=$(verify_cert "${SIGNED}")
CERT_FMT=$(sed -E 's/(..)/\1:/g; s/:$//' <<<"${GOT_NORM^^}")
if [[ -n ${EXPECTED_CERT} ]]; then
[[ ${GOT_NORM} == "${EXPECTED_CERT}" ]] \
|| die "signing-cert ${CERT_FMT} does not match ${CERT_FILE}"
echo " Cert SHA-256 OK"
else
echo " Cert SHA-256 ${CERT_FMT} (${CERT_FILE} absent, not pinned)"
fi
APKSET=""
if ((${#BUNDLETOOL[@]})); then
echo ">> Building Accrescent APK set"
command -v zip >/dev/null && command -v unzip >/dev/null \
|| die "zip/unzip not found"
[[ -f ${AAB} ]] || die "bundle not found at ${AAB}"
APKS_TMP="$(mktemp -d)"
# Hide any debug keystore so the splits come out unsigned.
ANDROID_SDK_HOME="${APKS_TMP}" HOME="${APKS_TMP}" \
JAVA_TOOL_OPTIONS="-Duser.home=${APKS_TMP}" \
"${BUNDLETOOL[@]}" build-apks \
--bundle "${AAB}" --output "${APKS_TMP}/unsigned.apks" >/dev/null \
2> >(grep -vE '^(Picked up JAVA_TOOL_OPTIONS|WARNING: )' >&2) \
|| die "bundletool build-apks failed"
mkdir "${APKS_TMP}/set"
unzip -q "${APKS_TMP}/unsigned.apks" -d "${APKS_TMP}/set"
shopt -s nullglob
SPLITS=("${APKS_TMP}/set"/splits/*.apk)
shopt -u nullglob
((${#SPLITS[@]})) || die "no split APKs in the APK set"
echo ">> Signing ${#SPLITS[@]} split APKs"
for SPLIT in "${SPLITS[@]}"; do
sign_apk "${SPLIT}" "${SPLIT}.signed" \
|| die "apksigner failed on $(basename "${SPLIT}")"
mv -f "${SPLIT}.signed" "${SPLIT}"
[[ "$(verify_cert "${SPLIT}")" == "${GOT_NORM}" ]] \
|| die "signature mismatch on $(basename "${SPLIT}")"
done
echo " Split signatures OK"
APKSET="${OUT}/${APK_BASE}-${TAG}.apks"
(cd "${APKS_TMP}/set" && zip -q -X -r - .) >"${APKSET}"
else
echo ">> bundletool not on PATH, skipping the Accrescent APK set"
fi
unset YUBIKEY_PIN
{
if [[ -s ${CHANGELOG} ]]; then
cat "${CHANGELOG}"
else
echo "<!-- changelog -->"
fi
echo
echo "SHA-256 signing certificate fingerprint:"
echo "\`${CERT_FMT}\`"
} >"${NOTES}"
echo ">> Committing and pushing the release commit"
git add "${GRADLE_FILE}"
# Re-releasing a yanked version stages nothing, and an empty commit would die.
if ! git diff --cached --quiet; then
git commit -q -m "Release ${VERSION_NAME}"
fi
git push -q origin HEAD:main || die "push rejected, nothing published"
echo ">> Creating draft GitHub release for ${TAG}"
PRERELEASE_FLAG=()
((PRE_ORDINAL != 999)) && PRERELEASE_FLAG+=(--prerelease)
# No tag is pushed here: GitHub creates it at the target commit when the draft
# is published, so the tag and its APK appear together for F-Droid.
gh release create "${TAG}" --target "$(git rev-parse HEAD)" \
--title "${APP_NAME} ${TAG}" \
--notes-file "${NOTES}" --draft "${PRERELEASE_FLAG[@]}" \
"${SIGNED}"
echo
echo "Done: draft ${TAG}"
echo "Review the notes, then publish: gh release edit ${TAG} --draft=false"
if [[ -n ${APKSET} ]]; then
echo "Accrescent APK set (upload via console.accrescent.app): ${APKSET}"
fi