209 lines
10 KiB
Shell
209 lines
10 KiB
Shell
|
|
#!/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."
|