tool/release.sh builds a release on the maintainer's laptop: one APK for every phone, from a checkout of the signed tag and signed with the app's own key, and the snap, both connecting to learn.halkyon.eu, the project's instance; their checksums signed with GPG; the release on code.halkyon.app through Forgejo's API, and the snap in the Store's edge channel. Before building it checks that main is clean and pushed, that the tag is signed by a key deploys accept, the version and a raised build number, the pinned Flutter, and that the keystore holds the key whose certificate tool/release/android-certificate.sha256 names. After building it checks the APK's signer, package, version, that it isn't debuggable and that it names the server, and the snap's version. The key's password and the Forgejo token are asked for at each run and never stored. Release builds take the app's key from the environment and otherwise fall back to the debug key, so `flutter run --release` works anywhere. The native libraries go into the APK compressed: an APK for three architectures was 110 MB with them stored as they are, 48 MB now. The list of dependencies Gradle adds for Google Play is left out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
209 lines
10 KiB
Shell
Executable file
209 lines
10 KiB
Shell
Executable file
#!/usr/bin/env bash
|
|
# Builds a release and publishes it: the Android APK (one for every phone, signed with
|
|
# the app's key) and the Linux snap, both connecting to learn.halkyon.eu, the project's
|
|
# instance; a list of their checksums signed with GPG; all of it as a release on
|
|
# code.halkyon.app, and the snap in the Snap Store's edge channel. Run on a developer's
|
|
# laptop, never in CI: the app's key never leaves it.
|
|
#
|
|
# tool/release.sh v0.1.0
|
|
#
|
|
# Before: the version, with a build number above the last release's, in
|
|
# halkyon_learn_flutter/pubspec.yaml on main, pushed; the tag signed on that commit and
|
|
# pushed (git tag -s v0.1.0 -m 'Halkyon Learn 0.1.0' && git push origin v0.1.0);
|
|
# `snapcraft login` with the project's account. The script asks for the key's password
|
|
# and a Forgejo token (scope write:repository) first, checks both, and then runs
|
|
# unattended until GPG asks for its passphrase; neither is ever written down.
|
|
#
|
|
# The app's key is a PKCS #12 keystore at $HALKYON_ANDROID_KEYSTORE (default below), its
|
|
# alias $HALKYON_ANDROID_KEY_ALIAS. Its certificate's SHA-256 is committed in
|
|
# tool/release/android-certificate.sha256, and nothing signed otherwise is released.
|
|
set -euo pipefail
|
|
cd "$(git rev-parse --show-toplevel)"
|
|
|
|
readonly server_url='https://learn.halkyon.eu/'
|
|
readonly forgejo='https://code.halkyon.app'
|
|
readonly repository='project-halkyon/halkyon_learn'
|
|
readonly pubspec='halkyon_learn_flutter/pubspec.yaml'
|
|
readonly certificate_file='tool/release/android-certificate.sha256'
|
|
keystore="${HALKYON_ANDROID_KEYSTORE:-$HOME/.local/share/halkyon-learn/android-release.p12}"
|
|
readonly key_alias="${HALKYON_ANDROID_KEY_ALIAS:-halkyon-learn}"
|
|
|
|
fail() {
|
|
echo "✗ $*" >&2
|
|
exit 1
|
|
}
|
|
step() { echo "▸ $*"; }
|
|
|
|
[ $# -eq 1 ] || fail 'Usage: tool/release.sh v<major>.<minor>.<patch>'
|
|
readonly tag="$1"
|
|
[[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || fail "$tag is no version tag such as v1.2.3."
|
|
readonly version="${tag#v}"
|
|
|
|
step "Checking what $tag releases"
|
|
[ -z "$(git status --porcelain)" ] || fail 'The working tree has changes.'
|
|
[ "$(git symbolic-ref --quiet --short HEAD)" = main ] || fail 'Not on main.'
|
|
git fetch --quiet origin main
|
|
[ "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)" ] ||
|
|
fail 'main differs from origin/main: pull or push first.'
|
|
[ "$(git cat-file -t "$tag" 2>/dev/null)" = tag ] ||
|
|
fail "No annotated tag $tag: git tag -s $tag -m 'Halkyon Learn $version'"
|
|
[ "$(git rev-parse "$tag^{commit}")" = "$(git rev-parse HEAD)" ] ||
|
|
fail "$tag isn't on HEAD."
|
|
[ "$(git ls-remote origin "refs/tags/$tag" | cut -f1)" = "$(git rev-parse "$tag")" ] ||
|
|
fail "origin doesn't have $tag as it is here: git push origin $tag"
|
|
# Signed by a key deploys accept too: the primary key's fingerprint ends GnuPG's
|
|
# VALIDSIG line, as %GP gives it for the commits deploy/redeploy.sh checks.
|
|
validsig=$(git verify-tag --raw "$tag" 2>&1 | grep '^\[GNUPG:\] VALIDSIG ' || true)
|
|
readonly signer="${validsig##* }"
|
|
if [ -z "$validsig" ] || ! grep -qx "$signer" deploy/allowed-signers; then
|
|
fail "$tag isn't signed by a key in deploy/allowed-signers."
|
|
fi
|
|
|
|
declared=$(sed -n 's/^version: *//p' "$pubspec")
|
|
[ "${declared%%+*}" = "$version" ] || fail "$pubspec says ${declared%%+*}, not $version."
|
|
readonly build="${declared#*+}"
|
|
[[ "$build" =~ ^[0-9]+$ ]] || fail "$pubspec's version lacks its build number (+N)."
|
|
# Every release raises the build number: Android installs only higher ones, and the
|
|
# server tells apps below its minimum build to update.
|
|
if previous=$(git describe --tags --abbrev=0 --match 'v*' "$tag^" 2>/dev/null); then
|
|
previous_build=$(git show "$previous:$pubspec" | sed -n 's/^version: *[^+]*+//p')
|
|
[ "$build" -gt "$previous_build" ] ||
|
|
fail "Build $build isn't above $previous's build $previous_build."
|
|
fi
|
|
|
|
pinned=$(sed -n 's/^ *FLUTTER_VERSION: *//p' .forgejo/workflows/ci.yaml)
|
|
running=$(flutter --version --machine | sed -n 's/^ *"frameworkVersion": *"\([^"]*\)".*/\1/p')
|
|
[ "$running" = "$pinned" ] || fail "Flutter is $running here, CI pins $pinned."
|
|
|
|
step 'Checking the tools'
|
|
for tool in snapcraft unsquashfs gpg curl jq sha256sum unzip; do
|
|
command -v "$tool" >/dev/null || fail "$tool is missing."
|
|
done
|
|
sdk="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$HOME/Android/Sdk}}"
|
|
build_tools=$(find "$sdk/build-tools" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | sort -V | tail -n 1)
|
|
[ -n "$build_tools" ] || fail "No Android build tools in $sdk (ANDROID_HOME)."
|
|
readonly apksigner="$build_tools/apksigner" aapt2="$build_tools/aapt2"
|
|
# keytool, and the java apksigner runs on, come with a JDK: the one on the PATH, else
|
|
# the one Android Studio brings.
|
|
jdk_bin=$(dirname "$(command -v java || echo /snap/android-studio/current/jbr/bin/java)")
|
|
[ -x "$jdk_bin/java" ] && [ -x "$jdk_bin/keytool" ] || fail 'No JDK found (java, keytool).'
|
|
export PATH="$jdk_bin:$PATH"
|
|
readonly keytool="$jdk_bin/keytool"
|
|
account=$(snapcraft whoami 2>/dev/null | sed -n 's/^username: *//p' || true)
|
|
[ -n "$account" ] || fail 'Not logged in to the Snap Store: snapcraft login'
|
|
|
|
[ -f "$certificate_file" ] || fail "$certificate_file is missing."
|
|
certificate=$(grep -v '^#' "$certificate_file" | tr -d ' :\n' | tr 'A-F' 'a-f' || true)
|
|
readonly certificate
|
|
[[ "$certificate" =~ ^[0-9a-f]{64}$ ]] || fail "$certificate_file holds no SHA-256."
|
|
[ -f "$keystore" ] || fail "No keystore at $keystore (HALKYON_ANDROID_KEYSTORE)."
|
|
keystore=$(realpath "$keystore")
|
|
readonly keystore
|
|
|
|
step 'Asking for the secrets'
|
|
read -rsp "Password of $keystore: " password
|
|
echo
|
|
key_certificate=$(HALKYON_ANDROID_KEYSTORE_PASSWORD="$password" "$keytool" -list -v \
|
|
-keystore "$keystore" -alias "$key_alias" \
|
|
-storepass:env HALKYON_ANDROID_KEYSTORE_PASSWORD 2>/dev/null |
|
|
sed -n 's/^.*SHA256: *//p' | head -n 1 | tr -d ':' | tr 'A-F' 'a-f' || true)
|
|
[ -n "$key_certificate" ] || fail "Wrong password, or no key $key_alias in $keystore."
|
|
[ "$key_certificate" = "$certificate" ] ||
|
|
fail "The key $key_alias in $keystore isn't the one in $certificate_file."
|
|
|
|
read -rsp "Forgejo token for $forgejo (write:repository): " token
|
|
echo
|
|
# The token goes to curl in a configuration read from a file descriptor, so it never
|
|
# shows in the list of processes as an argument would.
|
|
forgejo_api() {
|
|
curl --fail-with-body --silent --show-error \
|
|
--config <(printf 'header = "Authorization: token %s"\n' "$token") "$@"
|
|
}
|
|
forgejo_api --output /dev/null "$forgejo/api/v1/user" ||
|
|
fail "$forgejo doesn't take the token."
|
|
[ "$(forgejo_api --output /dev/null --write-out '%{http_code}' \
|
|
"$forgejo/api/v1/repos/$repository/releases/tags/$tag" 2>/dev/null || true)" = 404 ] ||
|
|
fail "A release of $tag exists already on $forgejo."
|
|
|
|
readonly out="build/release/$tag"
|
|
readonly source="$out/source"
|
|
readonly apk="halkyon-learn-$version.apk"
|
|
readonly snap="halkyon-learn_${version}_amd64.snap"
|
|
rm -rf "$out"
|
|
git worktree prune
|
|
mkdir -p "$out"
|
|
cleanup() { git worktree remove --force "$source" 2>/dev/null || true; }
|
|
trap cleanup EXIT
|
|
|
|
# The APK is built from a checkout of the tag, so nothing in the working tree (build
|
|
# caches, files git ignores) can get into it.
|
|
step "Building the APK from $tag"
|
|
git worktree add --quiet --detach "$source" "$tag"
|
|
(
|
|
cd "$source/halkyon_learn_flutter"
|
|
flutter pub get --enforce-lockfile
|
|
HALKYON_ANDROID_KEYSTORE="$keystore" \
|
|
HALKYON_ANDROID_KEY_ALIAS="$key_alias" \
|
|
HALKYON_ANDROID_KEYSTORE_PASSWORD="$password" \
|
|
flutter build apk --release --dart-define=SERVER_URL="$server_url"
|
|
# The Gradle daemon would otherwise live on with the password in its environment.
|
|
(cd android && ./gradlew --stop >/dev/null) || true
|
|
)
|
|
cp "$source/halkyon_learn_flutter/build/app/outputs/flutter-apk/app-release.apk" "$out/$apk"
|
|
unset password
|
|
|
|
step 'Checking the APK'
|
|
signers=$("$apksigner" verify --print-certs "$out/$apk" |
|
|
sed -n 's/^Signer #[0-9]* certificate SHA-256 digest: //p' || true)
|
|
[ "$signers" = "$certificate" ] ||
|
|
fail "The APK isn't signed by the app's key alone, but by: $signers"
|
|
badging=$("$aapt2" dump badging "$out/$apk")
|
|
grep -q "^package: name='app.halkyon.learn' versionCode='$build' versionName='$version'" \
|
|
<<<"$badging" || fail "The APK isn't app.halkyon.learn $version, build $build."
|
|
! grep -q '^application-debuggable' <<<"$badging" || fail 'The APK is debuggable.'
|
|
# grep's count is kept, not sent to /dev/null: there grep stops at the first match, which
|
|
# ends unzip with SIGPIPE, and pipefail counts that as a failure.
|
|
named=$(unzip -p "$out/$apk" lib/arm64-v8a/libapp.so | grep -caF "$server_url" || true)
|
|
[ "${named:-0}" -gt 0 ] || fail "The APK doesn't name $server_url."
|
|
|
|
# The snap builds the repository's committed HEAD, which is the tag (checked above). Its
|
|
# app part is cleaned so that it is built anew from this commit; the Flutter part stays.
|
|
step "Building the snap from $tag (in LXD)"
|
|
snapcraft clean halkyon-learn
|
|
snapcraft pack --output "$out"
|
|
[ -f "$out/$snap" ] || fail "snapcraft made no $snap."
|
|
snap_yaml=$(unsquashfs -cat "$out/$snap" meta/snap.yaml)
|
|
grep -qx "version: '\{0,1\}$version'\{0,1\}" <<<"$snap_yaml" ||
|
|
fail "The snap isn't version $version."
|
|
|
|
step 'Signing the checksums'
|
|
(cd "$out" && sha256sum "$apk" "$snap" >SHA256SUMS)
|
|
gpg --local-user "$signer" --armor --detach-sign --output "$out/SHA256SUMS.asc" \
|
|
"$out/SHA256SUMS"
|
|
|
|
step "Publishing the release on $forgejo"
|
|
notes="Halkyon Learn $version for Android and Linux. Both connect to learn.halkyon.eu, \
|
|
the project's instance.
|
|
|
|
- **Android:** \`$apk\`, one APK for every phone, signed with the app's key; the \
|
|
SHA-256 of its certificate is \`$certificate\`.
|
|
- **Linux:** \`$snap\`, also in the Snap Store: \`snap install halkyon-learn --edge\`.
|
|
|
|
\`SHA256SUMS\` lists the files' checksums, signed with the GPG key \`$signer\` \
|
|
(\`SHA256SUMS.asc\`)."
|
|
release=$(jq -n --arg tag "$tag" --arg name "Halkyon Learn $version" --arg body "$notes" \
|
|
'{tag_name: $tag, name: $name, body: $body}' |
|
|
forgejo_api --header 'Content-Type: application/json' --data @- \
|
|
"$forgejo/api/v1/repos/$repository/releases" | jq -r .id)
|
|
for file in "$apk" "$snap" SHA256SUMS SHA256SUMS.asc; do
|
|
forgejo_api --output /dev/null --form "attachment=@$out/$file" \
|
|
"$forgejo/api/v1/repos/$repository/releases/$release/assets?name=$file"
|
|
done
|
|
echo " $forgejo/$repository/releases/tag/$tag"
|
|
|
|
# Last, as the Store may hold a revision for review; the release above stands either way.
|
|
step "Uploading the snap to the Snap Store's edge channel (as $account)"
|
|
snapcraft upload --release=edge "$out/$snap" ||
|
|
fail "The upload failed; once fixed: snapcraft upload --release=edge $out/$snap"
|
|
|
|
echo "✓ Released $tag."
|