#!/usr/bin/env bash # Releases Halkyon Learn. It works out the next version from the commits since the # last release, writes it into the app's pubspec in a signed commit with a signed tag, # builds 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, checks # them, signs a list of their checksums with GPG, pushes, and publishes on every # channel: a release on code.halkyon.app (APK, snap, checksums), the APK in the # F-Droid repository at halkyon.app (tool/fdroid_repo.sh), and, once switched on # below, the snap in the Snap Store. Run on a developer's laptop, never in CI: the # app's key never leaves it. # # tool/release.sh the version the commits call for # tool/release.sh minor or major, or patch: that one instead # # Versions follow semantic versioning, .., and a build number # (+N) that rises by one with every release: Android installs only a higher one over # an older, and the server tells apps below the build it supports to update. Since # the last release, a commit typed feat raises the minor number, fix or perf the # patch number, and a breaking one (feat!: or a BREAKING CHANGE: line) the major # number from 1.0 on, the minor one before. Other commits (docs, test, chore, ...) # release nothing by themselves. The first release is the version the pubspec holds. # Raising minimumAppBuild in the server, for changes older apps can't follow, stays a # decision of its own. # # The script shows the version and its commits and asks before it changes anything. # It needs main clean and as pushed, CI green on its newest commit, and asks for the # app key's password, the F-Droid repository key's password and a Forgejo token # (scope write:repository) first; then it runs on its own until GPG asks for its # passphrase (and ssh for the upload key's, unless an agent holds them). None of # them is ever written down. Nothing is pushed or published before both builds have # passed their checks; a run that stopped after making the release commit and tag # carries on with them when started again. # # The app's key is a PKCS #12 keystore at $HALKYON_ANDROID_KEYSTORE (default below), # 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' readonly fdroid_certificate_file='tool/release/fdroid-index-certificate.sha256' # The Snap Store channel the snap goes to, e.g. edge; empty: not to the Store yet, # only attached to the release on code.halkyon.app. readonly snap_store_channel='' 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 "▸ $*"; } [ $# -le 1 ] || fail 'Usage: tool/release.sh [major | minor | patch]' chosen="${1:-}" case "$chosen" in '' | major | minor | patch) ;; *) fail "$chosen is none of major, minor, patch." ;; esac step 'Checking the repository' [ -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 declared=$(sed -n 's/^version: *//p' "$pubspec") current="${declared%%+*}" current_build="${declared#*+}" [[ "$current" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ && "$current_build" =~ ^[0-9]+$ ]] || fail "$pubspec's version isn't ..+: $declared" # A run that stopped after its release commit and tag: HEAD is that tag, as pushed # or one commit ahead of origin/main. resumed=$(git describe --exact-match --tags --match 'v[0-9]*' HEAD 2>/dev/null || true) if [ -n "$resumed" ]; then git merge-base --is-ancestor origin/main HEAD || fail 'main and origin/main have gone apart.' [ "$(git rev-list --count origin/main..HEAD)" -le 1 ] || fail "main is more than the release commit ahead of origin/main." [ -z "$chosen" ] || fail "HEAD is $resumed already; carrying on with it takes no argument." [ "$resumed" = "v$current" ] || fail "HEAD is $resumed, but $pubspec says $current." readonly tag="$resumed" version="$current" build="$current_build" previous=$(git describe --tags --abbrev=0 --match 'v[0-9]*' "$tag^" 2>/dev/null || true) echo " Carrying on with $tag (build $build)." else [ "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)" ] || fail 'main differs from origin/main: pull or push first.' previous=$(git describe --tags --abbrev=0 --match 'v[0-9]*' 2>/dev/null || true) if [ -z "$previous" ]; then # The first release: the version the pubspec holds. [ -z "$chosen" ] || fail 'The first release is the version the pubspec holds.' version="$current" build="$current_build" kind='the first release' else [ "$previous" = "v$current" ] || fail "$pubspec says $current, but the last release is $previous." subjects=$(git log --format='%s' "$previous..HEAD") bodies=$(git log --format='%B' "$previous..HEAD") typed='^[a-z]+(\([^)]*\))?' if [ -n "$chosen" ]; then bump="$chosen" elif grep -qE "$typed!:" <<<"$subjects" || grep -q '^BREAKING CHANGE:' <<<"$bodies"; then bump='breaking' elif grep -qE '^feat(\([^)]*\))?:' <<<"$subjects"; then bump='minor' elif grep -qE '^(fix|perf)(\([^)]*\))?:' <<<"$subjects"; then bump='patch' else echo "Nothing to release: no feat, fix or perf commit since $previous." exit 0 fi IFS=. read -r major minor patch <<<"$current" if [ "$bump" = breaking ]; then # Before 1.0, a breaking change raises the minor number. if [ "$major" -ge 1 ]; then bump='major'; else bump='minor'; fi fi case "$bump" in major) version="$((major + 1)).0.0" ;; minor) version="$major.$((minor + 1)).0" ;; patch) version="$major.$minor.$((patch + 1))" ;; esac build=$((current_build + 1)) kind="a $bump release" fi readonly tag="v$version" version build git rev-parse --quiet --verify "refs/tags/$tag" >/dev/null && fail "The tag $tag exists already." echo " Release $tag (build $build), $kind${previous:+, since $previous}." if [ -n "$previous" ]; then git log --format=' %s' "$previous..HEAD" | grep -E '^ [a-z]+(\([^)]*\))?!?:' | grep -vE '^ chore\(release\):' || true fi read -rp 'Go on? [y/N] ' answer [ "$answer" = y ] || [ "$answer" = Y ] || fail 'Stopped; nothing changed.' 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" if [ -n "$snap_store_channel" ]; then account=$(snapcraft whoami 2>/dev/null | sed -n 's/^username: *//p' || true) [ -n "$account" ] || fail 'Not logged in to the Snap Store: snapcraft login' fi [ -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 "Password of the F-Droid repository's key: " HALKYON_FDROID_KEYSTORE_PASSWORD echo export HALKYON_FDROID_KEYSTORE_PASSWORD tool/fdroid_repo.sh --check fdroid_fingerprint=$(grep -v '^#' "$fdroid_certificate_file" | tr -d ' :\n' | tr 'a-f' 'A-F') readonly fdroid_fingerprint 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") "$@" } # The token's scope, write:repository, covers the repository, not the account. forgejo_api --output /dev/null "$forgejo/api/v1/repos/$repository" || fail "$forgejo doesn't take the token for $repository." [ "$(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. If only publishing it in the" \ "F-Droid repository failed: tool/fdroid_repo.sh build/release/$tag/halkyon-learn-$version.apk" # CI checked the newest commit with code in it: release commits change only the # version. checked=$(git log -1 --format=%H --invert-grep --grep='^chore(release):' origin/main) ci=$(forgejo_api "$forgejo/api/v1/repos/$repository/commits/$checked/status" | jq -r .state) [ "$ci" = success ] || fail "CI isn't green on ${checked:0:7} ($ci)." if [ -z "$resumed" ]; then if [ -n "$previous" ]; then step "Writing $version+$build into the pubspec" sed -i "s/^version: .*/version: $version+$build/" "$pubspec" git commit --quiet -S -m "chore(release): $tag" -m "Build $build." -- "$pubspec" fi git tag -s "$tag" -m "Halkyon Learn $version" fi # 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 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. 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 "Pushing $tag" # The release commit goes through the pre-push check like any other; the tag, on the # same commit, then needs none. if ! git merge-base --is-ancestor HEAD origin/main; then git push origin main fi git push --no-verify origin "$tag" step "Publishing the release on $forgejo" # What it brings: the commits since the last release, by their type. changes() { [ -n "$previous" ] || return 0 git log --reverse --format='%s' "$previous..$tag" | sed -nE "s/^($1)(\([^)]*\))?!?: (.*)/- \3/p" } features=$(changes feat) fixes=$(changes 'fix|perf') if [ -z "$previous" ]; then summary='The first release.' elif [ -z "$features$fixes" ]; then summary="No features or fixes since $previous." else summary="${features:+### Features $features }${fixes:+### Fixes $fixes}" fi if [ -n "$snap_store_channel" ]; then linux="\`$snap\`, also in the Snap Store: \`snap install halkyon-learn --$snap_store_channel\`." else linux="\`$snap\`: \`sudo snap install --dangerous $snap\` (not in the Snap Store yet)." fi notes="Halkyon Learn $version for Android and Linux. Both connect to learn.halkyon.eu, \ the project's instance. $summary ### Installing - **Android:** \`$apk\`, one APK for every phone, signed with the app's key; the \ SHA-256 of its certificate is \`$certificate\`. - **F-Droid:** the same APK in the repository \ https://halkyon.app/fdroid/repo?fingerprint=$fdroid_fingerprint - **Linux:** $linux \`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" step 'Publishing the APK in the F-Droid repository' tool/fdroid_repo.sh "$out/$apk" unset HALKYON_FDROID_KEYSTORE_PASSWORD # Last, as the Store may hold a revision for review; the releases above stand either way. if [ -n "$snap_store_channel" ]; then step "Uploading the snap to the Snap Store's $snap_store_channel channel (as $account)" snapcraft upload --release="$snap_store_channel" "$out/$snap" || fail "The upload failed; once fixed: snapcraft upload --release=$snap_store_channel $out/$snap" fi echo "✓ Released $tag."