ci: add Android release workflow

This commit is contained in:
tapframe 2026-07-20 18:57:42 +05:30
parent 3fd4b2bfc2
commit 15ea5d857f
3 changed files with 382 additions and 0 deletions

192
.github/workflows/android-release.yml vendored Normal file
View file

@ -0,0 +1,192 @@
name: Build Android Release
on:
workflow_dispatch:
inputs:
mode:
description: Build only, create a draft, or publish the release
required: true
type: choice
options:
- dry-run
- draft
- publish
default: dry-run
exclude_commits:
description: Optional comma-separated commit hashes to omit from the notes
required: false
type: string
permissions:
contents: write
concurrency:
group: android-release-${{ github.ref }}
cancel-in-progress: false
jobs:
release:
name: ${{ inputs.mode }} Android release
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ github.token }}
RELEASE_PROPERTIES_BASE64: ${{ secrets.NUVIO_LOCAL_PROPERTIES_BASE64 }}
RELEASE_KEYSTORE_BASE64: ${{ secrets.NUVIO_RELEASE_KEYSTORE_BASE64 }}
steps:
- name: Check out full history
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Read release metadata
id: release
run: ./scripts/release-metadata.sh "${GITHUB_SHA}" >> "${GITHUB_OUTPUT}"
- name: Validate release state
env:
RELEASE_TAG: ${{ steps.release.outputs.tag }}
CURRENT_BUMP: ${{ steps.release.outputs.current_bump }}
RELEASE_COMMIT: ${{ steps.release.outputs.release_commit }}
CURRENT_BRANCH: ${{ github.ref_name }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: |
if [[ "${CURRENT_BRANCH}" != "${DEFAULT_BRANCH}" ]]; then
echo "Releases must run from the default branch (${DEFAULT_BRANCH}), not ${CURRENT_BRANCH}." >&2
exit 1
fi
if git rev-parse --verify --quiet "refs/tags/${RELEASE_TAG}"; then
echo "Tag ${RELEASE_TAG} already exists." >&2
exit 1
fi
if gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${RELEASE_TAG}" >/dev/null 2>&1; then
echo "A GitHub release for ${RELEASE_TAG} already exists." >&2
exit 1
fi
unexpected_changes="$(
git diff --name-only "${CURRENT_BUMP}..${RELEASE_COMMIT}" \
| grep -Ev '^(\.github/workflows/android-release\.yml|scripts/(generate-release-notes|release-metadata)\.sh)$' \
|| true
)"
if [[ -n "${unexpected_changes}" ]]; then
echo "The version bump must be the final application change before release." >&2
echo "Unexpected files changed after the bump:" >&2
echo "${unexpected_changes}" >&2
exit 1
fi
required_secrets=(
RELEASE_PROPERTIES_BASE64
RELEASE_KEYSTORE_BASE64
)
missing=()
for secret_name in "${required_secrets[@]}"; do
if [[ -z "${!secret_name}" ]]; then
missing+=("${secret_name}")
fi
done
if (( ${#missing[@]} > 0 )); then
printf 'Missing required release secrets: %s\n' "${missing[*]}" >&2
exit 1
fi
- name: Generate release notes
env:
PREVIOUS_BUMP: ${{ steps.release.outputs.previous_bump }}
CURRENT_BUMP: ${{ steps.release.outputs.current_bump }}
EXCLUDE_COMMITS: ${{ inputs.exclude_commits }}
run: |
./scripts/generate-release-notes.sh \
--from "${PREVIOUS_BUMP}" \
--to "${CURRENT_BUMP}" \
--repository "${GITHUB_REPOSITORY}" \
--exclude "${EXCLUDE_COMMITS}" \
> release-notes.md
if [[ ! -s release-notes.md ]]; then
echo "No release-note commits were found." >&2
exit 1
fi
{
echo "## ${{ steps.release.outputs.version }} release notes"
echo
cat release-notes.md
} >> "${GITHUB_STEP_SUMMARY}"
- name: Set up Java
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 17
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v6
- name: Configure release properties
env:
RELEASE_MODE: ${{ inputs.mode }}
run: |
keystore_path="${RUNNER_TEMP}/nuvio-release.jks"
printf '%s' "${RELEASE_KEYSTORE_BASE64}" | base64 --decode > "${keystore_path}"
printf '%s' "${RELEASE_PROPERTIES_BASE64}" | base64 --decode > local.properties
# These paths only apply to the machine where local.properties was encoded.
sed -i -E '/^[[:space:]]*(sdk\.dir|NUVIO_RELEASE_STORE_FILE)[[:space:]]*=/d' local.properties
printf '\nNUVIO_RELEASE_STORE_FILE=%s\n' "${keystore_path}" >> local.properties
# A dry run must not upload ProGuard mappings or mutate release services.
if [[ "${RELEASE_MODE}" == "dry-run" ]]; then
sed -i -E '/^[[:space:]]*SENTRY_(AUTH_TOKEN|ORG|PROJECT)[[:space:]]*=/d' local.properties
fi
required_properties=(
NUVIO_RELEASE_STORE_PASSWORD
NUVIO_RELEASE_KEY_ALIAS
NUVIO_RELEASE_KEY_PASSWORD
TRAKT_CLIENT_ID
TRAKT_CLIENT_SECRET
)
for property_name in "${required_properties[@]}"; do
if ! grep -Eq "^${property_name}=.+" local.properties; then
echo "Missing required property: ${property_name}" >&2
exit 1
fi
done
- name: Build full release APKs
run: ./gradlew :androidApp:assembleFullRelease --stacktrace
- name: Upload build artifacts
uses: actions/upload-artifact@v6
with:
name: nuvio-${{ steps.release.outputs.version }}-full-release
path: androidApp/build/outputs/apk/full/release/*.apk
if-no-files-found: error
- name: Create GitHub release
if: inputs.mode != 'dry-run'
env:
RELEASE_MODE: ${{ inputs.mode }}
RELEASE_TAG: ${{ steps.release.outputs.tag }}
RELEASE_VERSION: ${{ steps.release.outputs.version }}
RELEASE_COMMIT: ${{ steps.release.outputs.release_commit }}
run: |
mapfile -t release_apks < <(find androidApp/build/outputs/apk/full/release -maxdepth 1 -type f -name '*.apk' -print | sort)
if (( ${#release_apks[@]} == 0 )); then
echo "No release APKs were produced." >&2
exit 1
fi
release_args=(
"${RELEASE_TAG}"
--target "${RELEASE_COMMIT}"
--title "${RELEASE_VERSION}"
--notes-file release-notes.md
)
if [[ "${RELEASE_MODE}" == "draft" ]]; then
release_args+=(--draft)
fi
gh release create "${release_args[@]}" "${release_apks[@]}"

136
scripts/generate-release-notes.sh Executable file
View file

@ -0,0 +1,136 @@
#!/usr/bin/env bash
set -euo pipefail
from_ref=""
to_ref="HEAD"
repository="${GITHUB_REPOSITORY:-}"
offline=false
exclude_commits="${RELEASE_NOTES_EXCLUDE_COMMITS:-}"
usage() {
echo "Usage: $0 --from <commit> [--to <commit>] [--repository <owner/repo>] [--exclude <hashes>] [--offline]" >&2
}
while [[ $# -gt 0 ]]; do
case "$1" in
--from)
from_ref="${2:-}"
shift 2
;;
--to)
to_ref="${2:-}"
shift 2
;;
--repository)
repository="${2:-}"
shift 2
;;
--exclude)
exclude_commits="${exclude_commits} ${2:-}"
shift 2
;;
--offline)
offline=true
shift
;;
-h|--help)
usage
exit 0
;;
*)
usage
exit 1
;;
esac
done
if [[ -z "$from_ref" ]]; then
usage
exit 1
fi
git cat-file -e "${from_ref}^{commit}" 2>/dev/null || {
echo "Unknown starting commit: ${from_ref}" >&2
exit 1
}
git cat-file -e "${to_ref}^{commit}" 2>/dev/null || {
echo "Unknown ending commit: ${to_ref}" >&2
exit 1
}
is_excluded_hash() {
local commit="$1"
local excluded
for excluded in ${exclude_commits//,/ }; do
[[ -n "$excluded" ]] || continue
if [[ "$commit" == "$excluded"* ]]; then
return 0
fi
done
return 1
}
is_release_note() {
local subject_lower
local version_bump_pattern='^(bump([[:space:]].*)?version|version[[:space:]]+bump)([[:space:]].*)?$'
local cleanup_pattern='^cleanup([[:space:][:punct:]].*)?$'
local conventional_noise_pattern='^(build|chore|ci|docs|style|test)(\([^)]*\))?:'
subject_lower="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')"
[[ "$subject_lower" != *"[skip release notes]"* ]] || return 1
[[ ! "$subject_lower" =~ $version_bump_pattern ]] || return 1
[[ ! "$subject_lower" =~ $cleanup_pattern ]] || return 1
[[ ! "$subject_lower" =~ $conventional_noise_pattern ]] || return 1
return 0
}
resolve_username() {
local commit="$1"
local author_name="$2"
local author_email="$3"
local username=""
if [[ "$author_email" =~ ^[0-9]+\+([^@]+)@users\.noreply\.github\.com$ ]]; then
username="${BASH_REMATCH[1]}"
elif [[ "$author_email" =~ ^([^@]+)@users\.noreply\.github\.com$ ]]; then
username="${BASH_REMATCH[1]}"
elif [[ "$offline" == false && -n "$repository" && -n "${GH_TOKEN:-}" ]] && command -v gh >/dev/null 2>&1; then
username="$(gh api "repos/${repository}/commits/${commit}" --jq '.author.login // empty' 2>/dev/null || true)"
if [[ -z "$username" ]]; then
username="$(
gh api \
-H 'Accept: application/vnd.github+json' \
"repos/${repository}/commits/${commit}/pulls" \
--jq '.[0].user.login // empty' \
2>/dev/null \
|| true
)"
fi
fi
if [[ -z "$username" ]]; then
username="$(printf '%s' "$author_name" | tr -cd '[:alnum:]_-')"
fi
printf '%s' "${username:-unknown}"
}
seen_subjects=$'\n'
separator=$'\x1f'
while IFS="$separator" read -r commit short_hash subject author_name author_email; do
[[ -n "$commit" ]] || continue
is_excluded_hash "$commit" && continue
is_release_note "$subject" || continue
normalized_subject="$(printf '%s' "$subject" | tr '[:upper:]' '[:lower:]' | sed -E 's/[[:space:]]+/ /g; s/[[:space:].]+$//')"
[[ "$seen_subjects" != *$'\n'"$normalized_subject"$'\n'* ]] || continue
seen_subjects+="${normalized_subject}"$'\n'
display_subject="$(printf '%s' "$subject" | sed -E 's/[[:space:]]+$//; s/\.$//')"
username="$(resolve_username "$commit" "$author_name" "$author_email")"
printf '%s %s @%s \n' "$short_hash" "$display_subject" "$username"
done < <(
git log "${from_ref}..${to_ref}" --no-merges \
--format="%H${separator}%h${separator}%s${separator}%an${separator}%ae"
)

54
scripts/release-metadata.sh Executable file
View file

@ -0,0 +1,54 @@
#!/usr/bin/env bash
set -euo pipefail
version_file="${VERSION_FILE:-iosApp/Configuration/Version.xcconfig}"
target_ref="${1:-HEAD}"
if ! git cat-file -e "${target_ref}^{commit}" 2>/dev/null; then
echo "Unknown release target: ${target_ref}" >&2
exit 1
fi
read_version() {
local commit="$1"
git show "${commit}:${version_file}" \
| sed -nE 's/^[[:space:]]*MARKETING_VERSION[[:space:]]*=[[:space:]]*([^[:space:]#]+).*$/\1/p' \
| head -n 1
}
current_version=""
current_bump=""
previous_version=""
previous_bump=""
while IFS= read -r commit; do
version="$(read_version "$commit")"
[[ -n "$version" ]] || continue
if [[ -z "$current_version" ]]; then
current_version="$version"
current_bump="$commit"
elif [[ "$version" != "$current_version" ]]; then
previous_version="$version"
previous_bump="$commit"
break
fi
done < <(git log "$target_ref" --format='%H' -- "$version_file")
if [[ -z "$current_bump" || -z "$previous_bump" ]]; then
echo "Could not find two distinct version bumps in ${version_file}." >&2
exit 1
fi
if [[ ! "$current_version" =~ ^[0-9A-Za-z][0-9A-Za-z._-]*$ ]]; then
echo "Invalid release version: ${current_version}" >&2
exit 1
fi
printf 'version=%s\n' "$current_version"
printf 'tag=%s\n' "$current_version"
printf 'release_commit=%s\n' "$(git rev-parse "${target_ref}^{commit}")"
printf 'current_bump=%s\n' "$current_bump"
printf 'previous_version=%s\n' "$previous_version"
printf 'previous_bump=%s\n' "$previous_bump"