liseur/hack/release
Chmouel Boudjnah bef378281f
feat: deploy test releases to Google Play testing tracks
Test releases now automatically upload to Google Play's internal and
closed testing tracks by default, matching what regular releases do.
An opt-out flag (--no-play) remains available when Google Play should be
skipped.
2026-08-30 11:56:54 +02:00

1189 lines
47 KiB
Bash
Executable file

#!/usr/bin/env bash
set -euo pipefail
readonly GH_REPO="chmouel/liseur"
readonly APP_ID="com.chmouel.liseur"
readonly FDROID_PROJECT="fdroid%2Ffdroiddata"
readonly FDROID_FORK_PROJECT="chmouel%2Ffdroiddata"
readonly FDROID_TAG_POLICY_SCRIPT="hack/verify-fdroid-tags"
readonly BUILD_FILE="app/build.gradle.kts"
readonly RELEASE_WAIT_SECONDS=1800
readonly RELEASE_POLL_SECONDS=20
# Long enough for the metadata checks, which take a couple of minutes,
# and deliberately far short of the build they run alongside.
readonly FDROID_PIPELINE_WAIT_SECONDS=420
readonly FDROID_PIPELINE_POLL_SECONDS=15
readonly CHANGELOG_DIR="fastlane/metadata/android/en-US/changelogs"
readonly PASS_KEYSTORE="android/liseur.keystore.p12"
readonly PASS_KEYSTORE_PASSWORD="android/liseur.keystore-password"
readonly PASS_KEY_ALIAS="android/liseur.keystore-alias"
readonly PASS_GEMINI_KEY="google/gemini-api"
readonly PASS_PLAY_SERVICE_ACCOUNT="android/google.play.service.serviceaccount"
readonly NOTES_MODEL="gemini-3.6-flash"
readonly REQUIRED_SECRETS=(
LISEUR_KEYSTORE_BASE64
LISEUR_KEYSTORE_PASSWORD
LISEUR_KEY_ALIAS
LISEUR_KEY_PASSWORD
GEMINI_API_KEY
LISEUR_PLAY_SERVICE_ACCOUNT_JSON
)
# Set by --no-play. The release workflow skips Google Play entirely when
# it finds no service account, so leaving the credential off the release
# environment is the whole of the opt-out — but only if this script does
# not put it back, which it otherwise does the moment it notices it
# missing.
skip_play=false
usage() {
cat <<'EOF'
Usage:
hack/release [--no-fdroid] [--no-play]
hack/release [-n] [--no-fdroid] [--no-play] VERSION RELEASE NOTES...
hack/release [-n] [--no-play] --test [VERSION]
hack/release --fdroid-only VERSION
hack/release [--no-play] --sync-secrets
With no version it shows what has landed since the last release, offers
the next major, minor and patch, and opens an editor on a changelog
drafted from the commits.
Examples:
hack/release 0.2.0 "Add in-book search and a Wiktionary lookup card."
hack/release -n 0.2.1 "Fix page fitting on tall screens."
hack/release --no-fdroid 0.1.0 "The first release."
hack/release --no-play 0.2.2 "Leave Google Play out of this one."
hack/release --test
hack/release -n --test 0.3.0
hack/release --no-play --test 0.3.0
hack/release --fdroid-only 0.2.1
hack/release --sync-secrets
-n (--non-interactive, --yes) answers every confirmation prompt with
yes, for a release run from a script or a CI job with nothing on the
other end of stdin. Without it the run stops at "Continue?" and, reading
end-of-file as no, exits having done nothing. It does not conjure up an
answer that has no default: picking a version and writing a changelog
still need a terminal, so a run with no VERSION refuses either way.
--test builds the same signed APK and publishes it as a GitHub
prerelease and uploads it to the Google Play testing tracks (unless
--no-play is given), without telling F-Droid about it. Because it
carries the release signature, it installs over a copy that came from
F-Droid or Google Play, and F-Droid updates over it once a real release
goes out above its versionCode. VERSION is the version being worked towards
(the next patch by default) and the tag numbers the attempts: v0.3.0-test.1,
then v0.3.0-test.2. The version bump is committed but never pushed to a
branch — only the tag that points at it — so main keeps a history of
real releases. There is no changelog to write and no local test, lint or
reproducibility run: the point of a test release is to be quick.
Before pushing the tag, the live F-Droid tag filter is checked and the
release stops if it would discover this non-final tag.
The F-Droid step opens (or updates) a fdroiddata merge request carrying
the new build entry plus the signature extracted from the released APK:
F-Droid publishes both its own build and the developer-signed one, and
their bot cannot automate that yet. It needs fdroidserver installed
(pipx install fdroidserver, or set FDROID_BIN); when it is missing the
release still goes out and hack/release --fdroid-only VERSION finishes
the F-Droid part later.
The signing key lives in pass, under android/liseur.keystore.p12,
android/liseur.keystore-password and android/liseur.keystore-alias, and the
Play upload credential under android/google.play.service.serviceaccount. Any
release secret missing from the GitHub release environment is uploaded
from there, so a fresh clone needs nothing but an unlocked password store.
--no-play leaves the Play credential alone: it is neither required nor
uploaded, and the release workflow skips Google Play whenever it finds
none. Use it while the Play listing is not ready to receive builds. It
changes nothing else — the GitHub release and the F-Droid submission go
out as usual.
EOF
}
die() {
printf 'error: %s\n' "$*" >&2
exit 1
}
require_command() {
command -v "$1" >/dev/null || die "missing required command: $1"
}
# fdroiddata is a public project, so every read against it works
# unauthenticated. Only the commit that updates the metadata needs a
# logged-in glab, so that is the one call left going through it.
gitlab_api_get() {
curl -fsS "https://gitlab.com/api/v4/$1"
}
read_version_name() {
sed -nE 's/^[[:space:]]*versionName = "([^"]+)"$/\1/p' "$BUILD_FILE"
}
read_version_code() {
sed -nE 's/^[[:space:]]*versionCode = ([0-9]+)$/\1/p' "$BUILD_FILE"
}
# The highest versionCode anything has ever been released under: this
# branch, and every tag. The tags matter because a test release is
# tagged off main and takes a versionCode with it without bumping
# anything on main. A real release that then reused that code would be
# one F-Droid could not offer as an update to whoever installed the
# test build, so the next code is counted from here rather than from
# the file.
highest_version_code() {
local highest tag code
highest=$(read_version_code)
[[ -n "$highest" ]] || return 1
while IFS= read -r tag; do
[[ -n "$tag" ]] || continue
code=$(git show "$tag:$BUILD_FILE" 2>/dev/null |
sed -nE 's/^[[:space:]]*versionCode = ([0-9]+)$/\1/p')
[[ "$code" =~ ^[0-9]+$ ]] || continue
(( code > highest )) && highest=$code
done < <(git tag --list 'v*')
printf '%s' "$highest"
}
# Test releases of one version are numbered: v0.3.0-test.1, then .2.
next_test_iteration() {
local base=$1 highest=0 tag iteration
while IFS= read -r tag; do
iteration=${tag##*-test.}
[[ "$iteration" =~ ^[0-9]+$ ]] || continue
(( iteration > highest )) && highest=$iteration
done < <(git tag --list "v$base-test.*")
printf '%s' $((highest + 1))
}
confirm() {
if [[ "$assume_yes" == true ]]; then
return
fi
read -r -p "Continue? [y/N] " answer
[[ "$answer" =~ ^[Yy]$ ]] || die "release cancelled"
}
pass_show() {
local entry=$1 value
value=$(pass show "$entry" 2>/dev/null) ||
die "missing pass entry: $entry"
[[ -n "$value" ]] || die "pass entry is empty: $entry"
printf '%s' "$value"
}
# Secrets are pushed through stdin rather than --body so that they never
# appear in the process list.
set_release_secret() {
local name=$1 value=$2
printf '%s' "$value" |
gh secret set "$name" --repo "$GH_REPO" --env release >/dev/null ||
die "could not set release secret $name"
}
sync_release_secrets() {
local force=${1:-false}
local available missing=() secret wanted=()
gh api --silent --method PUT "repos/$GH_REPO/environments/release" ||
die "could not create the release environment on $GH_REPO"
for secret in "${REQUIRED_SECRETS[@]}"; do
[[ "$skip_play" == true &&
"$secret" == LISEUR_PLAY_SERVICE_ACCOUNT_JSON ]] && continue
wanted+=("$secret")
done
available=$(gh secret list --repo "$GH_REPO" --env release |
cut -f1) || available=""
for secret in "${wanted[@]}"; do
grep -qx "$secret" <<<"$available" || missing+=("$secret")
done
if [[ ${#missing[@]} -eq 0 && "$force" == false ]]; then
return
fi
printf 'Uploading the release secrets from pass.\n'
set_release_secret LISEUR_KEYSTORE_BASE64 "$(pass_show "$PASS_KEYSTORE")"
set_release_secret LISEUR_KEYSTORE_PASSWORD \
"$(pass_show "$PASS_KEYSTORE_PASSWORD")"
set_release_secret LISEUR_KEY_ALIAS "$(pass_show "$PASS_KEY_ALIAS")"
set_release_secret LISEUR_KEY_PASSWORD \
"$(pass_show "$PASS_KEYSTORE_PASSWORD")"
set_release_secret GEMINI_API_KEY "$(pass_show "$PASS_GEMINI_KEY")"
if [[ "$skip_play" == false ]]; then
set_release_secret LISEUR_PLAY_SERVICE_ACCOUNT_JSON \
"$(pass_show "$PASS_PLAY_SERVICE_ACCOUNT")"
fi
}
github_release_is_published() {
local release is_draft published_at
release=$(gh release view "$1" --repo "$GH_REPO" \
--json isDraft,publishedAt 2>/dev/null) || return 1
is_draft=$(printf '%s' "$release" | jq -r .isDraft)
published_at=$(printf '%s' "$release" | jq -r .publishedAt)
[[ "$is_draft" == false && "$published_at" != null ]]
}
# Everything below drives `hack/release` with no arguments: show what
# has landed since the last release, pick the next version from it, and
# write the changelog F-Droid will show.
# gum makes this pleasant, but the script must still work without it.
pick_one() {
local header=$1
shift
if command -v gum >/dev/null; then
gum choose --header="$header" "$@"
return
fi
local option index=1
printf '%s\n' "$header" >&2
for option in "$@"; do
printf ' %s) %s\n' "$index" "$option" >&2
index=$((index + 1))
done
local answer
read -r -p "Choice: " answer
[[ "$answer" =~ ^[0-9]+$ ]] && (( answer >= 1 && answer <= $# )) || return 1
printf '%s' "${!answer}"
}
confirm_prompt() {
if [[ "$assume_yes" == true ]]; then
return
fi
if command -v gum >/dev/null; then
gum confirm "$1"
return
fi
local answer
read -r -p "$1 [y/N] " answer
[[ "$answer" =~ ^[Yy]$ ]]
}
show_changes_since() {
local range=$1 kind subjects
local -a kinds=(feat fix perf refactor docs build chore ci)
for kind in "${kinds[@]}"; do
subjects=$(git log "$range" --no-decorate --pretty=' %s' \
--grep="^${kind}[(:]" 2>/dev/null)
[[ -n "$subjects" ]] || continue
printf '%s\n%s\n\n' "$kind" "$subjects"
done
local -a excludes=()
for kind in "${kinds[@]}"; do
excludes+=(--grep="^${kind}[(:]")
done
subjects=$(git log "$range" --no-decorate --pretty=' %s' \
"${excludes[@]}" --invert-grep 2>/dev/null)
[[ -z "$subjects" ]] || printf 'other\n%s\n\n' "$subjects"
}
bump_version() {
local version=$1 part=$2
local major minor patch
IFS=. read -r major minor patch <<<"$version"
case $part in
major) printf '%s.0.0' $((major + 1)) ;;
minor) printf '%s.%s.0' "$major" $((minor + 1)) ;;
patch) printf '%s.%s.%s' "$major" "$minor" $((patch + 1)) ;;
esac
}
# The changelog is what F-Droid shows, so it is worth a moment of the
# maintainer's attention. Gemini drafts it from the commits; the editor
# is where it gets fixed.
draft_release_notes() {
local range=$1 draft=$2
local key request response
key=$(pass show "$PASS_GEMINI_KEY" 2>/dev/null) || return 1
[[ -n "$key" ]] || return 1
request=$(mktemp)
response=$(mktemp)
jq -n --rawfile log <(git log "$range" --no-decorate --pretty='- %s%n%b') \
'{contents: [{parts: [{text: ("Write the F-Droid changelog for the next release of Liseur, an Android EPUB books reader and calibre-web client.\n\nIt is shown in a store listing to people who read books, so say what they can now do, in plain unexcited prose. No headings, no bullets, no marketing, no emoji. At most 480 characters. Leave out anything they would not notice: dependency bumps, CI, refactoring, documentation.\n\nThe commits:\n\n" + $log)}]}]}' \
> "$request"
curl -fsS --max-time 120 \
"https://generativelanguage.googleapis.com/v1beta/models/$NOTES_MODEL:generateContent" \
-H "Content-Type: application/json" \
-H "x-goog-api-key: $key" \
-X POST -d @"$request" > "$response" 2>/dev/null &&
jq -er '.candidates[0].content.parts | map(.text // "") | join("")
| select(length > 0)' "$response" > "$draft"
local status=$?
rm -f "$request" "$response"
return $status
}
edit_release_notes() {
local range=$1
local file editor
file=$(mktemp --suffix=.txt)
if ! draft_release_notes "$range" "$file"; then
printf '' > "$file"
fi
{
printf '\n'
printf '# The changelog F-Droid will show, at most 500 characters.\n'
printf '# Lines starting with # are dropped. An empty file aborts.\n'
} >> "$file"
editor=${VISUAL:-${EDITOR:-vi}}
"$editor" "$file" || die "the editor exited without saving"
release_notes=$(grep -v '^#' "$file" | tr '\n' ' ' |
sed -E 's/[[:space:]]+/ /g; s/^ //; s/ $//')
rm -f "$file"
[[ -n "$release_notes" ]] || die "release cancelled: no changelog"
[[ ${#release_notes} -le 500 ]] ||
die "the changelog is ${#release_notes} characters; 500 is the limit"
}
# The store listing is pictures of screens that may since have been
# rearranged. This does not block a release — sometimes nothing visible
# changed — it just refuses to let the pictures go stale unnoticed.
warn_about_stale_screenshots() {
local shots screens
shots=$(git log -1 --format=%ct -- docs/screenshots 2>/dev/null || true)
screens=$(git log -1 --format=%ct -- \
app/src/main/kotlin/com/chmouel/liseur/ui \
app/src/main/kotlin/com/chmouel/liseur/reader 2>/dev/null || true)
[[ -n "$shots" && -n "$screens" ]] || return 0
((screens > shots)) || return 0
printf 'The screens have changed since the screenshots were taken.\n'
printf 'Take them again before releasing, with a device attached:\n\n'
printf ' hack/screenshots --setup\n\n'
}
choose_release() {
local current last_tag range choice
current=$(read_version_name)
[[ -n "$current" ]] || die "could not read the current app version"
last_tag=$(git describe --tags --abbrev=0 \
--match 'v[0-9]*.[0-9]*.[0-9]*' --exclude '*-test.*' 2>/dev/null || true)
if [[ -n "$last_tag" ]]; then
range="$last_tag..HEAD"
printf '\nSince %s, %s ago:\n\n' "$last_tag" \
"$(git log -1 --format=%ar "$last_tag")"
else
range=HEAD
printf '\nNothing released yet. Everything so far:\n\n'
fi
show_changes_since "$range"
[[ -n $(git log "$range" --oneline) ]] ||
die "nothing has landed since $last_tag"
warn_about_stale_screenshots
choice=$(pick_one "Bump from $current" \
"patch ($(bump_version "$current" patch))" \
"minor ($(bump_version "$current" minor))" \
"major ($(bump_version "$current" major))") ||
die "release cancelled"
[[ -n "$choice" ]] || die "release cancelled"
version=$(bump_version "$current" "${choice%% *}")
printf 'Releasing %s.\n' "$version"
edit_release_notes "$range"
printf '\n%s\n\n' "$release_notes"
confirm_prompt "Release $version with this changelog?" ||
die "release cancelled"
}
# The version bump a test release is built from, committed without
# moving whatever branch you are on: a detached worktree makes the edit,
# the tag ends up pointing at what comes out of it, and the tree you
# were working in is never touched. Prints the commit.
write_test_commit() {
local version=$1 version_code=$2
local workdir worktree sha
workdir=$(mktemp -d)
worktree="$workdir/tree"
git worktree add --quiet --detach "$worktree" HEAD ||
die "could not create a worktree for the test release commit"
if ! (
cd "$worktree"
sed -i -E \
"s/^([[:space:]]*)versionCode = [0-9]+$/\\1versionCode = $version_code/" \
"$BUILD_FILE"
sed -i -E \
"s/^([[:space:]]*)versionName = \"[^\"]+\"$/\\1versionName = \"$version\"/" \
"$BUILD_FILE"
[[ $(read_version_name) == "$version" ]] || exit 1
[[ $(read_version_code) == "$version_code" ]] || exit 1
git add "$BUILD_FILE"
git commit --quiet -m "chore: test release v$version"
) >&2; then
git worktree remove --force "$worktree" >/dev/null 2>&1 || true
rm -rf "$workdir"
die "could not commit the test release version"
fi
sha=$(git -C "$worktree" rev-parse HEAD)
git worktree remove --force "$worktree" >/dev/null 2>&1 || true
rm -rf "$workdir"
[[ -n "$sha" ]] || die "could not read the test release commit"
printf '%s' "$sha"
}
# A test release is the real APK — same build, same release key — put
# somewhere a handful of people can install it from, and nowhere else:
# a GitHub prerelease, no F-Droid merge request, no Play upload.
#
# The signature is the whole point. It is the one F-Droid publishes
# under (the dual-signing flow), so this installs over a copy that came
# from F-Droid without uninstalling anything, and F-Droid offers the
# next real release over it afterwards — provided that release lands on
# a higher versionCode, which is what highest_version_code is for.
#
# Nothing is pushed to main. The commit carrying the bump is reachable
# only through its tag, so main's history stays a list of real releases
# and the next one still bumps from the last real version.
create_test_release() {
local base=${1:-}
local current version version_code iteration tag last_tag range commit_sha
current=$(read_version_name)
[[ -n "$current" ]] || die "could not read the current app version"
[[ -n "$base" ]] || base=$(bump_version "$current" patch)
[[ "$base" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] ||
die "VERSION must use X.Y.Z format"
[[ "$base" != "$current" &&
$(printf '%s\n%s\n' "$current" "$base" | sort -V | tail -n 1) == "$base" ]] ||
die "version $base must be newer than the released $current"
[[ -z $(git status --porcelain) ]] || die "the working tree is not clean"
iteration=$(next_test_iteration "$base")
version="$base-test.$iteration"
tag="v$version"
version_code=$(highest_version_code)
[[ "$version_code" =~ ^[0-9]+$ ]] ||
die "could not work out the versionCode to release under"
version_code=$((version_code + 1))
! git rev-parse -q --verify "refs/tags/$tag" >/dev/null ||
die "tag $tag already exists"
# F-Droid scans public tags independently of this script. Fail closed
# before asking for confirmation or syncing secrets if its live filter
# would discover the test tag.
"./$FDROID_TAG_POLICY_SCRIPT" "v$base" "$tag"
last_tag=$(git describe --tags --abbrev=0 \
--match 'v[0-9]*.[0-9]*.[0-9]*' --exclude '*-test.*' 2>/dev/null || true)
range=HEAD
[[ -z "$last_tag" ]] || range="$last_tag..HEAD"
printf '\nIn this build%s:\n\n' "${last_tag:+, since $last_tag}"
show_changes_since "$range"
printf 'Test release %s as versionCode %s, from %s (%s).\n' \
"$version" "$version_code" "$(git rev-parse --short HEAD)" \
"$(git branch --show-current || printf 'detached')"
printf 'It is signed with the release key and published as a GitHub\n'
if [[ "$skip_play" == false ]]; then
printf 'prerelease, and uploaded to the Google Play testing tracks.\n'
printf 'F-Droid is not told about it.\n'
else
printf 'prerelease. F-Droid and Google Play are not told about it.\n'
fi
confirm
sync_release_secrets
commit_sha=$(write_test_commit "$version" "$version_code")
git tag "$tag" "$commit_sha"
if ! git push origin "refs/tags/$tag"; then
git tag -d "$tag" >/dev/null
die "could not push $tag"
fi
wait_for_github_release "$tag"
printf '\nTest release %s is ready:\n %s\n' "$tag" \
"https://github.com/$GH_REPO/releases/tag/$tag"
printf 'It installs over an F-Droid copy, and F-Droid will offer an update\n'
printf 'once a release above versionCode %s reaches it.\n' "$version_code"
}
# Pushing the tag starts the release workflow, which builds and signs the
# APK and only then creates the release, because a published release is
# immutable and cannot be given an APK afterwards. So there is nothing to
# do here but wait for it, and say what it is waiting on.
wait_for_github_release() {
local tag=$1
local waited=0 announced=false
if github_release_is_published "$tag"; then
printf 'GitHub: release %s already exists.\n' "$tag"
return
fi
printf 'GitHub: %s is building. This takes a few minutes.\n' "$tag"
while (( waited < RELEASE_WAIT_SECONDS )); do
if github_release_is_published "$tag"; then
printf 'GitHub: published %s.\n' "$tag"
return
fi
if [[ "$announced" == false ]] && release_run_is_waiting "$tag"; then
printf 'GitHub: the release environment needs approving:\n %s\n' \
"https://github.com/$GH_REPO/actions"
announced=true
fi
sleep "$RELEASE_POLL_SECONDS"
waited=$((waited + RELEASE_POLL_SECONDS))
done
die "timed out waiting for the $tag release; see https://github.com/$GH_REPO/actions"
}
release_run_is_waiting() {
local status
status=$(gh api "repos/$GH_REPO/actions/runs?event=push&per_page=5" \
--jq ".workflow_runs[] | select(.head_branch == \"$1\") | .status" \
2>/dev/null | head -n 1) || return 1
[[ "$status" == waiting || "$status" == action_required ]]
}
# Says how F-Droid took the submission.
#
# Nothing used to, and a broken metadata file went unnoticed for a month
# of releases: the commit was made, the merge request was updated, the
# script said it was done, and the pipeline quietly went red every time.
#
# Only the checks that read the metadata are waited for. `fdroid build`
# compiles the app from source and takes far longer than anyone will sit
# and watch, and `check apk` cannot start until it has, so both are left
# to run and named in the link rather than blocked on. A failure in
# either is a real problem, but it is not one this script could have
# caused between one release and the next.
#
# Should F-Droid add a check, it is waited for by default, which is the
# right way round: a new metadata rule is exactly what this is for.
report_fdroid_pipeline() {
local project_id=$1 sha=$2
local waited=0 pipeline url
while (( waited < FDROID_PIPELINE_WAIT_SECONDS )); do
pipeline=$(gitlab_api_get \
"projects/$project_id/pipelines?sha=$sha&per_page=1" 2>/dev/null |
jq -r '.[0] // empty')
[[ -n "$pipeline" ]] && break
sleep "$FDROID_PIPELINE_POLL_SECONDS"
waited=$((waited + FDROID_PIPELINE_POLL_SECONDS))
done
if [[ -z "$pipeline" ]]; then
printf 'F-Droid: no pipeline started yet; check it yourself:\n %s\n' \
"$merge_request_url"
return
fi
url=$(printf '%s' "$pipeline" | jq -r .web_url)
local pipeline_id
pipeline_id=$(printf '%s' "$pipeline" | jq -r .id)
printf 'F-Droid: checking the submission:\n %s\n' "$url"
# Everything the build has to finish before it can even begin.
local slow_jobs='.[] | select(.name != "fdroid build" and .name != "check apk")'
local jobs failed pending
while (( waited < FDROID_PIPELINE_WAIT_SECONDS )); do
jobs=$(gitlab_api_get \
"projects/$project_id/pipelines/$pipeline_id/jobs?per_page=100" 2>/dev/null) || jobs=''
if [[ -n "$jobs" ]]; then
failed=$(printf '%s' "$jobs" | jq -r "$slow_jobs"'
| select(.status == "failed") | .name')
if [[ -n "$failed" ]]; then
printf 'F-Droid: these checks failed:\n%s\n' \
"$(printf '%s' "$failed" | sed 's/^/ /')"
die "the F-Droid submission was rejected; see $url"
fi
pending=$(printf '%s' "$jobs" | jq -r "$slow_jobs"'
| select(.status != "success" and .status != "manual" and
.status != "skipped") | .name')
if [[ -z "$pending" ]]; then
printf 'F-Droid: the metadata checks passed. The build is still running.\n'
return
fi
fi
sleep "$FDROID_PIPELINE_POLL_SECONDS"
waited=$((waited + FDROID_PIPELINE_POLL_SECONDS))
done
printf 'F-Droid: the checks are taking a while. Look before assuming it landed:\n %s\n' \
"$url"
}
# F-Droid publishes two APKs of every version: one it signs itself and
# one carrying the developer signature, grafted onto F-Droid's own
# rebuild once that rebuild comes out byte-identical (the dual-signing
# flow of https://f-droid.org/docs/Reproducible_Builds/). Their bot
# cannot drive that yet, so the metadata says `AutoUpdateMode: None`
# and every release comes back through here: extract the signature from
# the APK just published on GitHub, append the build entry, and open
# (or update) the fdroiddata merge request that carries both.
update_fdroid_submission() (
local version=$1
local version_code=$2
local commit_sha=$3
local metadata_path="metadata/$APP_ID.yml"
local encoded_path="metadata%2F$APP_ID.yml"
local apk_name="liseur-v$version.apk"
# fdroidserver does the signature extraction, and nothing else can.
# Missing is not fatal this late in a release: everything else has
# already shipped, and --fdroid-only picks this step back up.
local fdroid_bin=${FDROID_BIN:-fdroid}
if ! command -v "$fdroid_bin" >/dev/null; then
printf 'F-Droid: fdroidserver is not installed; the signature MR was not opened.\n'
printf 'Install it (pipx install fdroidserver, or set FDROID_BIN) and run:\n'
printf ' hack/release --fdroid-only %s\n' "$version"
return
fi
# The merge request to update, when one is already open (a release
# while the previous one is still under review); otherwise a fresh
# branch on the fork, from which a new merge request is opened at
# the end. The metadata is read from whichever of those is current.
local merge_request
merge_request=$(gitlab_api_get \
"projects/$FDROID_PROJECT/merge_requests?state=opened&author_username=chmouel&per_page=100" |
jq -c '[.[] | select(.title | test("Liseur"))][0] // empty')
local source_project_id source_branch merge_request_url
local file_project file_ref start_branch="" start_project=""
if [[ -n "$merge_request" ]]; then
source_project_id=$(printf '%s' "$merge_request" | jq -r .source_project_id)
source_branch=$(printf '%s' "$merge_request" | jq -r .source_branch)
merge_request_url=$(printf '%s' "$merge_request" | jq -r .web_url)
file_project=$source_project_id
file_ref=$source_branch
else
source_project_id=$(gitlab_api_get "projects/$FDROID_FORK_PROJECT" | jq -r .id)
source_branch="liseur-$version"
merge_request_url=''
local encoded_branch
encoded_branch=$(jq -rn --arg value "$source_branch" '$value | @uri')
if gitlab_api_get \
"projects/$source_project_id/repository/branches/$encoded_branch" \
>/dev/null 2>&1; then
# A branch without a merge request is an earlier run that
# stopped between the commit and the MR; pick it back up.
file_project=$source_project_id
file_ref=$source_branch
else
# The branch is cut from fdroiddata's master rather than the
# fork's, which is a snapshot of whenever the fork was last
# synchronised and may well predate Liseur's own metadata
# file: a commit starting there updates a file that is not
# there yet, and GitLab answers "a file with this name
# doesn't exist". The file is read from upstream just below,
# so this is also the only ref the edit was ever against.
file_project=$FDROID_PROJECT
file_ref=master
start_branch=master
start_project=$(gitlab_api_get "projects/$FDROID_PROJECT" | jq -r .id)
fi
fi
local file_response
file_response=$(gitlab_api_get \
"projects/$file_project/repository/files/$encoded_path?ref=$file_ref") ||
die "no F-Droid metadata found at $file_ref; has the inclusion MR merged?"
local workdir
workdir=$(mktemp -d)
trap 'rm -rf "$workdir"' EXIT
local metadata_file="$workdir/base.yml"
printf '%s' "$file_response" | jq -r .content | base64 --decode > "$metadata_file"
local metadata_current_code
metadata_current_code=$(sed -nE 's/^CurrentVersionCode: ([0-9]+)$/\1/p' "$metadata_file")
[[ -n "$metadata_current_code" ]] ||
die "could not read the current F-Droid versionCode"
(( version_code >= metadata_current_code )) ||
die "refusing to lower F-Droid from versionCode $metadata_current_code to $version_code"
# `fdroid` refuses to run outside a repository; an empty config and
# a metadata directory are the whole of what it checks for.
: > "$workdir/config.yml"
mkdir -p "$workdir/metadata"
printf 'F-Droid: downloading %s to extract its signature.\n' "$apk_name"
gh release download "v$version" --repo "$GH_REPO" \
--pattern "$apk_name" --dir "$workdir" >/dev/null ||
die "could not download $apk_name from the GitHub release"
(cd "$workdir" && "$fdroid_bin" signatures "$apk_name" >/dev/null 2>&1) ||
die "fdroid signatures failed on $apk_name"
local signatures_dir="metadata/$APP_ID/signatures/$version_code"
[[ -n $(ls -A "$workdir/$signatures_dir" 2>/dev/null) ]] ||
die "no signature came out of $apk_name for versionCode $version_code"
# A v2/v3-only APK (ours) has no v1 signature file to read the
# developer certificate from, so publishing needs it cached as
# signer-certificate.der next to the signing block. fdroid
# signatures only writes it on fdroidserver master
# (fdroidserver#1065); with an older release, produce it here with
# the same function the fix uses, run by the interpreter behind
# the fdroid command so the import resolves to the same install.
if [[ ! -e "$workdir/$signatures_dir/signer-certificate.der" ]]; then
local fdroid_python
fdroid_python=$(sed -n '1s/^#!//p' "$(command -v "$fdroid_bin")")
[[ -x "$fdroid_python" ]] || fdroid_python=python3
(cd "$workdir" && "$fdroid_python" - "$apk_name" "$signatures_dir" <<'PY'
import sys
from pathlib import Path
from fdroidserver import common
common.config = common.read_config()
cert = common.get_first_signer_certificate(sys.argv[1])
if cert is None:
sys.exit("no v2/v3 certificate found")
Path(sys.argv[2], "signer-certificate.der").write_bytes(cert)
PY
) || die "could not write signer-certificate.der"
fi
# The new entry is appended: under dual signing the history stays,
# because each published version keeps needing its recipe. A resumed
# run finds its entry already present and only settles the rest.
local skip_entry=0
if grep -qE "^ versionCode: $version_code\$" "$metadata_file"; then
skip_entry=1
fi
awk -v version="$version" -v code="$version_code" -v commit="$commit_sha" \
-v skip_entry="$skip_entry" '
function entry() {
if (!skip_entry) {
print " - versionName: " version
print " versionCode: " code
print " commit: " commit
print " subdir: app"
print " gradle:"
print " - yes"
}
print ""
inb = 0
}
/^Builds:/ { print; inb = 1; next }
inb && /^$/ { next }
inb && /^[^[:space:]]/ { entry() }
/^CurrentVersion:/ { print "CurrentVersion: " version; next }
/^CurrentVersionCode:/ { print "CurrentVersionCode: " code; next }
{ print }
END { if (inb) entry() }
' "$metadata_file" > "$workdir/$metadata_path" ||
die "could not update the F-Droid metadata"
# rewritemeta is what their pipeline holds the file against, so run
# it here and send its idea of the formatting, not ours. lint is
# advisory: it flags things a human put there on purpose.
(cd "$workdir" && "$fdroid_bin" rewritemeta "$APP_ID" >/dev/null 2>&1) ||
die "fdroid rewritemeta rejected the updated metadata"
(cd "$workdir" && "$fdroid_bin" lint "$APP_ID") || true
local actions
actions=$(jq -n --arg path "$metadata_path" \
--rawfile content "$workdir/$metadata_path" \
'[{action: "update", file_path: $path, content: $content}]')
local signature_file signature_path signature_action encoded_signature
for signature_file in "$workdir/$signatures_dir"/*; do
signature_path="$signatures_dir/$(basename "$signature_file")"
encoded_signature=$(jq -rn --arg value "$signature_path" '$value | @uri')
signature_action=create
if gitlab_api_get \
"projects/$file_project/repository/files/$encoded_signature?ref=$file_ref" \
>/dev/null 2>&1; then
signature_action=update
fi
actions=$(printf '%s' "$actions" | jq \
--arg action "$signature_action" \
--arg path "$signature_path" \
--arg content "$(base64 -w0 "$signature_file")" \
'. + [{action: $action, file_path: $path, content: $content,
encoding: "base64"}]')
done
local payload
# --rawfile rather than --arg "$(cat ...)": command substitution
# strips every trailing newline, and the file that reached GitLab
# therefore ended without one. `fdroid rewritemeta` reformats the
# metadata and fails the merge request when the result differs, and a
# missing final newline is a difference.
payload=$(jq -n \
--arg branch "$source_branch" \
--arg start_branch "$start_branch" \
--arg start_project "$start_project" \
--arg message "Liseur: add $version ($version_code) with extracted signatures" \
--argjson actions "$actions" \
'{branch: $branch, commit_message: $message, actions: $actions}
+ (if $start_branch != "" then {start_branch: $start_branch} else {} end)
+ (if $start_project != "" then {start_project: $start_project} else {} end)')
# Checked on the payload rather than on the file, because the file is
# not what goes over the wire and the bug this guards against was
# entirely in the step between the two. Every release for a month
# failed rewritemeta this way, and nothing here noticed: the commit
# was made, the merge request was updated, and the script reported
# success. Cheap to check, and it fails before anything is pushed.
printf '%s' "$payload" | jq -e '.actions[0].content | endswith("\n")' >/dev/null ||
die "refusing to send F-Droid metadata that does not end in a newline"
local commit_response new_sha
# glab sends --input bodies without a Content-Type, and GitLab
# rejects those with 415 on some endpoints, so say what this is.
commit_response=$(printf '%s' "$payload" | glab api --hostname gitlab.com --method POST \
--header "Content-Type: application/json" \
"projects/$source_project_id/repository/commits" --input -)
new_sha=$(printf '%s' "$commit_response" | jq -r .id)
if [[ -z "$merge_request_url" ]]; then
local target_project_id mr_payload mr_response
target_project_id=$(gitlab_api_get "projects/$FDROID_PROJECT" | jq -r .id)
mr_payload=$(jq -n \
--arg source_branch "$source_branch" \
--arg title "Liseur: add $version ($version_code) with extracted signatures" \
--argjson target_project_id "$target_project_id" \
--arg description "Liseur $version (versionCode $version_code), commit https://github.com/chmouel/liseur/commit/$commit_sha.
The build entry plus the signature extracted from the developer-signed release APK (https://github.com/chmouel/liseur/releases/tag/v$version), following the dual-signing flow: F-Droid rebuilds from source and publishes its own build next to the developer-signed one. AutoUpdateMode is None, so each release arrives as one of these merge requests." \
'{source_branch: $source_branch, target_branch: "master",
target_project_id: $target_project_id, title: $title,
description: $description, remove_source_branch: true}')
mr_response=$(printf '%s' "$mr_payload" | glab api --hostname gitlab.com --method POST \
--header "Content-Type: application/json" \
"projects/$source_project_id/merge_requests" --input -)
merge_request_url=$(printf '%s' "$mr_response" | jq -r .web_url)
printf 'F-Droid: opened %s for version %s.\n' "$merge_request_url" "$version"
else
printf 'F-Droid: updated %s for version %s.\n' "$merge_request_url" "$version"
fi
report_fdroid_pipeline "$source_project_id" "$new_sha"
)
assume_yes=false
fdroid_only=false
skip_fdroid=false
sync_secrets_only=false
test_release=false
while [[ $# -gt 0 ]]; do
case "$1" in
-n|--non-interactive|--yes)
assume_yes=true
shift
;;
--test)
test_release=true
shift
;;
--fdroid-only)
fdroid_only=true
shift
;;
--no-fdroid)
skip_fdroid=true
shift
;;
--no-play)
skip_play=true
shift
;;
--sync-secrets)
sync_secrets_only=true
shift
;;
-h|--help)
usage
exit 0
;;
--)
shift
break
;;
-*)
die "unknown option: $1"
;;
*)
break
;;
esac
done
if [[ "$sync_secrets_only" == true ]]; then
[[ "$fdroid_only" == false ]] ||
die "--sync-secrets and --fdroid-only are mutually exclusive"
[[ "$skip_fdroid" == false ]] ||
die "--sync-secrets and --no-fdroid are mutually exclusive"
[[ "$test_release" == false ]] ||
die "--sync-secrets and --test are mutually exclusive"
[[ $# -eq 0 ]] || die "--sync-secrets does not take any argument"
for command in gh pass; do
require_command "$command"
done
sync_release_secrets true
printf 'Release secrets are up to date on %s.\n' "$GH_REPO"
exit 0
fi
# A test release goes nowhere near F-Droid, so it needs none of its
# tooling or credentials, and none of the checks that keep main a
# straight line of releases apply to a tag that is not on it.
if [[ "$test_release" == true ]]; then
[[ "$fdroid_only" == false ]] ||
die "--test and --fdroid-only are mutually exclusive"
[[ $# -le 1 ]] || die "--test takes at most a version"
for command in git gh jq sed awk pass curl python3; do
require_command "$command"
done
repo_root=$(git rev-parse --show-toplevel)
cd "$repo_root"
[[ -f "$BUILD_FILE" ]] || die "run this script from the Liseur repository"
git fetch --quiet origin --tags
create_test_release "${1:-}"
exit 0
fi
interactive=false
version=""
release_notes=""
if [[ $# -eq 0 ]]; then
[[ "$fdroid_only" == false ]] ||
die "--fdroid-only needs a version"
[[ -t 0 && -t 1 ]] || {
usage >&2
exit 2
}
interactive=true
else
version=$1
shift
release_notes="$*"
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] ||
die "VERSION must use X.Y.Z format"
fi
for command in git gh glab jq base64 sed awk pass curl python3; do
require_command "$command"
done
repo_root=$(git rev-parse --show-toplevel)
cd "$repo_root"
[[ -f "$BUILD_FILE" ]] || die "run this script from the Liseur repository"
git fetch --quiet origin main --tags
if [[ "$interactive" == true ]]; then
choose_release
fi
tag="v$version"
if [[ "$fdroid_only" == true ]]; then
[[ "$skip_fdroid" == false ]] ||
die "--fdroid-only and --no-fdroid are mutually exclusive"
[[ -z "$release_notes" ]] || die "--fdroid-only does not accept release notes"
remote_tag_refs=$(git ls-remote --tags origin "refs/tags/$tag" "refs/tags/$tag^{}")
[[ -n "$remote_tag_refs" ]] || die "tag $tag does not exist on origin"
remote_commit=$(printf '%s\n' "$remote_tag_refs" |
awk '$2 ~ /\^\{\}$/ { print $1 }')
if [[ -z "$remote_commit" ]]; then
remote_commit=$(printf '%s\n' "$remote_tag_refs" | awk 'NR == 1 { print $1 }')
fi
commit_sha=$(git rev-parse "$tag^{commit}" 2>/dev/null) ||
die "tag $tag does not exist"
[[ "$commit_sha" == "$remote_commit" ]] ||
die "local and origin tags differ for $tag"
github_release_is_published "$tag" ||
die "GitHub release $tag is not published"
tag_build_file=$(git show "$tag:$BUILD_FILE")
tag_version=$(sed -nE 's/^[[:space:]]*versionName = "([^"]+)"$/\1/p' \
<<<"$tag_build_file")
tag_version_code=$(sed -nE 's/^[[:space:]]*versionCode = ([0-9]+)$/\1/p' \
<<<"$tag_build_file")
[[ "$tag_version" == "$version" && -n "$tag_version_code" ]] ||
die "$tag does not contain app version $version"
"./$FDROID_TAG_POLICY_SCRIPT" "$tag" "$tag-test.1"
update_fdroid_submission "$version" "$tag_version_code" "$commit_sha"
exit
fi
[[ $(git branch --show-current) == main ]] || die "releases must be created from main"
[[ -z $(git status --porcelain) ]] || die "the working tree is not clean"
current_version=$(read_version_name)
current_version_code=$(read_version_code)
[[ -n "$current_version" && -n "$current_version_code" ]] ||
die "could not read the current app version"
local_head=$(git rev-parse HEAD)
remote_head=$(git rev-parse origin/main)
if [[ "$current_version" != "$version" ]]; then
[[ "$local_head" == "$remote_head" ]] ||
die "local main is not synchronized with origin/main"
elif [[ "$local_head" != "$remote_head" ]]; then
git merge-base --is-ancestor "$remote_head" "$local_head" ||
die "local main has diverged from origin/main"
[[ $(git rev-list --count "$remote_head..$local_head") == 1 ]] ||
die "resume requires exactly one unpushed release commit"
[[ $(git log -1 --format=%s) == "chore: release $tag" ]] ||
die "the unpushed commit is not the $tag release commit"
fi
# F-Droid scans public tags independently of the submission step. Check
# both sides of the policy before changing secrets or pushing a release.
"./$FDROID_TAG_POLICY_SCRIPT" "$tag" "$tag-test.1"
sync_release_secrets
changelog_path="$CHANGELOG_DIR/$current_version_code.txt"
new_release=false
if [[ "$current_version" != "$version" ]]; then
[[ -n "$release_notes" ]] || die "release notes are required"
[[ ${#release_notes} -le 500 ]] ||
die "release notes must be at most 500 characters for F-Droid"
[[ $(printf '%s\n%s\n' "$current_version" "$version" | sort -V | tail -n 1) == "$version" ]] ||
die "version $version must be newer than $current_version"
git rev-parse "$tag" >/dev/null 2>&1 && die "tag $tag already exists"
new_version_code=$(highest_version_code)
[[ "$new_version_code" =~ ^[0-9]+$ ]] ||
die "could not work out the versionCode to release under"
new_version_code=$((new_version_code + 1))
changelog_path="$CHANGELOG_DIR/$new_version_code.txt"
[[ ! -e "$changelog_path" ]] || die "$changelog_path already exists"
printf 'Release %s as versionCode %s.\n' "$version" "$new_version_code"
printf 'Release notes: %s\n' "$release_notes"
confirm
rollback=true
rollback_changes() {
if [[ "$rollback" == true ]]; then
git restore --staged "$BUILD_FILE" "$changelog_path" 2>/dev/null || true
git restore "$BUILD_FILE" 2>/dev/null || true
rm -f "$changelog_path"
fi
}
trap rollback_changes ERR INT TERM
sed -i -E \
"s/^([[:space:]]*)versionCode = [0-9]+$/\\1versionCode = $new_version_code/" \
"$BUILD_FILE"
sed -i -E \
"s/^([[:space:]]*)versionName = \"[^\"]+\"$/\\1versionName = \"$version\"/" \
"$BUILD_FILE"
printf '%s\n' "$release_notes" > "$changelog_path"
[[ $(read_version_name) == "$version" ]] || die "versionName update failed"
[[ $(read_version_code) == "$new_version_code" ]] || die "versionCode update failed"
./gradlew testDebugUnitTest lintDebug assembleRelease --console=plain
git add "$BUILD_FILE" "$changelog_path"
git commit -m "chore: release $tag"
rollback=false
trap - ERR INT TERM
# F-Droid publishes our own signed APK only after reproducing the
# build (the dual-signing flow grafts our signature onto their
# rebuild), so a tag that does not double-build identically is a
# tag that never ships there. Verify the release commit itself
# before it becomes one.
if ! hack/verify-reproducible; then
git reset --hard HEAD^
die "the release build is not reproducible; the release commit was undone"
fi
git tag "$tag"
current_version_code=$new_version_code
new_release=true
else
changelog_path="$CHANGELOG_DIR/$current_version_code.txt"
[[ -s "$changelog_path" ]] || die "missing changelog: $changelog_path"
if ! git rev-parse "$tag" >/dev/null 2>&1; then
# A release normally bumps the version and tags the commit that
# did it. The very first one has nothing to bump: the tree
# already declares the version being shipped, so the tag belongs
# on HEAD. Anything later than that must come from its own
# release commit.
if [[ $(git log -1 --format=%s) != "chore: release $tag" ]]; then
[[ -z $(git ls-remote --tags origin) ]] ||
die "version is already $version but HEAD is not its release commit"
printf 'First release: tagging HEAD, which already declares %s.\n' \
"$version"
fi
git tag "$tag"
fi
tag_commit=$(git rev-parse "$tag^{commit}")
if ! github_release_is_published "$tag"; then
[[ "$tag_commit" == $(git rev-parse HEAD) ]] ||
die "$tag does not point to HEAD and has no published GitHub release"
fi
printf 'Resuming release %s.\n' "$tag"
confirm
fi
git push --atomic origin main "$tag"
commit_sha=$(git rev-parse "$tag^{commit}")
tag_build_file=$(git show "$tag:$BUILD_FILE")
release_version=$(sed -nE 's/^[[:space:]]*versionName = "([^"]+)"$/\1/p' \
<<<"$tag_build_file")
release_version_code=$(sed -nE 's/^[[:space:]]*versionCode = ([0-9]+)$/\1/p' \
<<<"$tag_build_file")
[[ "$release_version" == "$version" && -n "$release_version_code" ]] ||
die "$tag does not contain app version $version"
wait_for_github_release "$tag"
if [[ "$skip_fdroid" == true ]]; then
printf 'F-Droid: skipped; run --fdroid-only %s once the metadata is up.\n' \
"$version"
else
update_fdroid_submission "$version" "$release_version_code" "$commit_sha"
fi
if [[ "$new_release" == true ]]; then
printf 'Release %s is ready.\n' "$tag"
fi