From 15ea5d857f248dc332b90376ba1233d31cb7f05d Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Mon, 20 Jul 2026 18:57:42 +0530 Subject: [PATCH] ci: add Android release workflow --- .github/workflows/android-release.yml | 192 ++++++++++++++++++++++++++ scripts/generate-release-notes.sh | 136 ++++++++++++++++++ scripts/release-metadata.sh | 54 ++++++++ 3 files changed, 382 insertions(+) create mode 100644 .github/workflows/android-release.yml create mode 100755 scripts/generate-release-notes.sh create mode 100755 scripts/release-metadata.sh diff --git a/.github/workflows/android-release.yml b/.github/workflows/android-release.yml new file mode 100644 index 000000000..0322a27da --- /dev/null +++ b/.github/workflows/android-release.yml @@ -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[@]}" diff --git a/scripts/generate-release-notes.sh b/scripts/generate-release-notes.sh new file mode 100755 index 000000000..a172a85d7 --- /dev/null +++ b/scripts/generate-release-notes.sh @@ -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 [--to ] [--repository ] [--exclude ] [--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" +) diff --git a/scripts/release-metadata.sh b/scripts/release-metadata.sh new file mode 100755 index 000000000..e518b3a3f --- /dev/null +++ b/scripts/release-metadata.sh @@ -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"