From 1d2b20564d038f8e8ee278f2b1d0a395b7a8c522 Mon Sep 17 00:00:00 2001 From: Angel Sanadinov Date: Tue, 10 Nov 2026 16:27:37 +0100 Subject: [PATCH] feat: add release step to CI --- .github/workflows/default.yml | 7 +- .github/workflows/publish_release.yml | 79 +++++++++ .github/workflows/release.yml | 55 ++++++ .gitignore | 2 + client-android/build.gradle.kts | 15 ++ release.py | 236 ++++++++++++++++++++++++++ 6 files changed, 392 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/publish_release.yml create mode 100644 .github/workflows/release.yml create mode 100755 release.py diff --git a/.github/workflows/default.yml b/.github/workflows/default.yml index 814ccf2..5c64608 100644 --- a/.github/workflows/default.yml +++ b/.github/workflows/default.yml @@ -6,7 +6,7 @@ permissions: contents: read concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.ref_name }} + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.head_ref || github.ref_name }} cancel-in-progress: true jobs: @@ -44,4 +44,7 @@ jobs: uses: gradle/actions/setup-gradle@8da46b539cd8fe6ab27ca75128919a18d757604c # v6.4.1 - name: Run QA - run: ./gradlew client-android:qa client-android:assembleDebug client-android:assembleRelease + run: ./gradlew client-android:qa + + - name: Build + run: ./gradlew client-android:assembleDebug client-android:assembleRelease diff --git a/.github/workflows/publish_release.yml b/.github/workflows/publish_release.yml new file mode 100644 index 0000000..66445e8 --- /dev/null +++ b/.github/workflows/publish_release.yml @@ -0,0 +1,79 @@ +name: Eventually Publish (Release) + +permissions: + contents: read + +on: + push: + tags: + - 'v[0-9]+.[0-9]+.[0-9]+' + workflow_dispatch: + +jobs: + android: + runs-on: ubuntu-latest + + if: startsWith(github.ref, 'refs/tags/v') + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup JDK 21 + uses: actions/setup-java@77dd5b2ce225ea11469928ac317850245a74ae78 # v6.2.0 + with: + java-version: 21 + distribution: temurin + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@8da46b539cd8fe6ab27ca75128919a18d757604c # v6.4.1 + + - name: Decode Keystore + id: decode_keystore + uses: timheuer/base64-to-file@dfa5a5dd38803cb4f5c3a081eeb28e8362d43e9c # v2.0.0 + with: + fileName: 'signing.jks' + fileDir: './client-android/secrets/' + encodedString: ${{ secrets.ANDROID_SIGNING_KEYSTORE }} + + - name: Build client-android artifacts + env: + ANDROID_SIGNING_KEY_ALIAS: ${{ secrets.ANDROID_SIGNING_KEY_ALIAS }} + ANDROID_SIGNING_KEY_PASSWORD: ${{ secrets.ANDROID_SIGNING_KEY_PASSWORD }} + ANDROID_SIGNING_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_SIGNING_KEYSTORE_PASSWORD }} + RELEASE_TAG: ${{ github.ref_name }} + run: | + ./gradlew :client-android:assembleRelease + mv client-android/build/outputs/apk/release/client-android-release.apk "client-android/build/outputs/apk/release/eventually-${RELEASE_TAG}.apk" + + - name: Upload client-android artifacts + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: client-android + path: client-android/build/outputs/apk/release/eventually-${{ github.ref_name }}.apk + if-no-files-found: error + retention-days: 1 + + release: + permissions: + contents: write + + runs-on: ubuntu-latest + + needs: + - android + + steps: + - name: Download artifacts + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: client-android + path: client-android + + - name: Release + uses: softprops/action-gh-release@8a0cd651e5b2365bd640854a3fa5dfc70e4875f3 # v3.0.4 + with: + fail_on_unmatched_files: true + files: | + client-android/eventually-${{ github.ref_name }}.apk diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..1c043f5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,55 @@ +name: Eventually Release + +permissions: + contents: read + +concurrency: + group: release + cancel-in-progress: false + +on: + workflow_dispatch: + inputs: + next_version: + description: 'Next release version' + required: true + default: 'patch' + type: choice + options: + - patch + - minor + - major + verbose: + description: 'Enable debug logging during release' + required: false + type: boolean + +jobs: + release: + runs-on: ubuntu-latest + + if: github.ref == 'refs/heads/master' + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ssh-key: ${{ secrets.RELEASE_KEY }} + ref: master + + - name: Setup Python 3 + uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + with: + python-version: '3.14' + + - name: Release project + run: | + pip install semver==3.1.1 + git config --global user.name ${{ github.actor }} + git config --global user.email ${{ github.actor }}@users.noreply.github.com + ./release.py $VERBOSE --next $NEXT_VERSION + git push + git push --tags + + env: + NEXT_VERSION: ${{ inputs.next_version }} + VERBOSE: ${{ inputs.verbose && '--verbose' || '' }} diff --git a/.gitignore b/.gitignore index 4d4cdb6..4ec7fce 100644 --- a/.gitignore +++ b/.gitignore @@ -31,5 +31,7 @@ libraries/tools/kotlin-test-js-runner/lib/ local.properties *.hprof client-android/release +client-android/secrets +*.jks .local .kotlin diff --git a/client-android/build.gradle.kts b/client-android/build.gradle.kts index 7320e21..f2aafae 100644 --- a/client-android/build.gradle.kts +++ b/client-android/build.gradle.kts @@ -8,6 +8,8 @@ plugins { alias(libs.plugins.hilt) } +val releaseKeystore: File = file("secrets/signing.jks") + android { namespace = "eventually.client" compileSdk = 37 @@ -22,6 +24,15 @@ android { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } + signingConfigs { + create("release") { + storeFile = releaseKeystore + storePassword = System.getenv("ANDROID_SIGNING_KEYSTORE_PASSWORD") + keyAlias = System.getenv("ANDROID_SIGNING_KEY_ALIAS") + keyPassword = System.getenv("ANDROID_SIGNING_KEY_PASSWORD") + } + } + buildTypes { getByName("debug") { applicationIdSuffix = ".debug" @@ -33,6 +44,10 @@ android { getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) + + if (releaseKeystore.exists()) { + signingConfig = signingConfigs.getByName("release") + } } } diff --git a/release.py b/release.py new file mode 100755 index 0000000..6bfd8cc --- /dev/null +++ b/release.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 + +import argparse +import logging +import re +import semver +import subprocess +import sys + +DESCRIPTION = ('Bumps all versions across the project to the next release version, ' + 'commits and tags the changes, and updates to the next snapshot version') + + +def require_no_changes(): + build_result = subprocess.run(['git', 'status', '--porcelain'], capture_output=True, text=True) + output = build_result.stdout.split('\n') + build_result.stderr.split('\n') + output = list(filter(None, output)) + + if len(output) == 0: + logging.debug('No changes found in repo') + else: + logging.error('Release failed - uncommitted changes found in repo [\n {}\n]'.format('\n '.join(output))) + sys.exit(1) + + +def require_version_updated(next_version, updated_version): + if updated_version == next_version: + logging.debug('Version change applied') + else: + logging.error( + 'Release failed - expected updated version to be [{}] but [{}] found'.format( + next_version, + updated_version + ) + ) + sys.exit(1) + + +def get_version_from(version_file, with_version_regex): + with open(version_file) as f: + content = f.readlines() + pattern = re.compile(with_version_regex) + match = next(filter(lambda m: m is not None, map(lambda line: pattern.match(line), content)), None) + if match: + logging.debug( + 'Loaded version [{}] from file [{}] with regex [{}]'.format( + match.group(1), + version_file, + with_version_regex + ) + ) + return match.group(1) + else: + logging.error('Release failed - could not find version in [{}]'.format(version_file)) + sys.exit(1) + + +def get_current_version(version_files): + versions = {} + for version_file, version_regex in version_files.items(): + versions[version_file] = get_version_from(version_file=version_file, with_version_regex=version_regex) + + unique_versions = list(set(versions.values())) + + if len(unique_versions) != 1: + logging.error( + 'Release failed - found [{}] different versions ({}) in [\n {}\n]'.format( + len(unique_versions), + ', '.join(unique_versions), + '\n '.join(map(lambda e: '[{}] in {}'.format(e[1], e[0]), versions.items())) + ) + ) + sys.exit(1) + else: + return list(versions.values())[0] + + +def get_next_version(current_version, next_version): + current = semver.Version.parse(current_version) + + next = { + 'patch': current.bump_patch() if not current_version.endswith('-SNAPSHOT') else semver.Version.parse( + current_version.replace('-SNAPSHOT', '')), + 'minor': current.bump_minor(), + 'major': current.bump_major(), + }.get(next_version.lower()) + + return str(next or semver.Version.parse(next_version)) + + +def apply_next_version_to(version_file, current_version, next_version, with_version_regex): + with open(version_file, 'r') as f: + content = f.readlines() + pattern = re.compile(with_version_regex) + updated = list( + map( + lambda line: line.replace(current_version, next_version) if pattern.match(line) else line, + content + ) + ) + + with open(version_file, 'w') as f: + f.write(''.join(updated)) + + +def apply_next_version(version_files, current_version, next_version): + for version_file, version_regex in version_files.items(): + apply_next_version_to( + version_file=version_file, + current_version=current_version, + next_version=next_version, + with_version_regex=version_regex + ) + + +def apply_extra_actions(actions): + for target_file, action in actions.items(): + action(target_file) + + +def action_android_increment_version_code(target_file): + version_code_regex = "\\s*versionCode = (\\d+)$" + + with open(target_file, 'r') as f: + content = f.readlines() + pattern = re.compile(version_code_regex) + match = next(filter(lambda m: m is not None, map(lambda line: pattern.match(line), content)), None) + if match: + current_version_code = int(match.group(1)) + next_version_code = current_version_code + 1 + + logging.debug( + 'Loaded current version code [{}] from file [{}] with regex [{}]; next version code is [{}]'.format( + current_version_code, + target_file, + version_code_regex, + next_version_code + ) + ) + + updated = list( + map( + lambda line: line.replace(str(current_version_code), str(next_version_code)) if pattern.match(line) else line, + content + ) + ) + + with open(target_file, 'w') as f: + f.write(''.join(updated)) + else: + logging.error('Release failed - could not find version code in [{}]'.format(target_file)) + sys.exit(1) + + +def exec_git_command(command): + if subprocess.run(command).returncode == 0: + logging.debug('Executed git command [{}]'.format(' '.join(command))) + else: + logging.error('Release failed - could not execute git command [{}]'.format(' '.join(command))) + sys.exit(1) + + +def commit_version_files(version_files, next_version): + for version_file in version_files: + exec_git_command(command=['git', 'add', version_file]) + exec_git_command(command=['git', 'commit', '-m', 'Update version to {}'.format(next_version)]) + + +def create_tag(next_version): + exec_git_command(command=['git', 'tag', 'v{}'.format(next_version)]) + + +def main(): + version_regex = '\\d+\\.\\d+\\.\\d+.*' + + version_files = { + 'build.gradle.kts': '\\s*version = "({})"$'.format(version_regex), + 'client-android/build.gradle.kts': '\\s*versionName = "({})"$'.format(version_regex), + } + + release_actions = { + 'client-android/build.gradle.kts': action_android_increment_version_code, + } + + snapshot_actions = {} + + parser = argparse.ArgumentParser(description=DESCRIPTION) + + parser.add_argument( + '-n', '--next', + required=False, + default='patch', + help='select next release version; can be either one of [major|minor|patch] or an explicit version (ex: 1.5.0)' + ) + + parser.add_argument( + '-v', '--verbose', + action='store_true', + help='enable debug logging' + ) + + args = parser.parse_args() + + logging.basicConfig( + format='[%(asctime)-15s] [%(levelname)s] [%(name)-5s]: %(message)s', + level=logging.getLevelName(logging.DEBUG if args.verbose else logging.INFO) + ) + + require_no_changes() + + current_version = get_current_version(version_files=version_files) + next_version = get_next_version(current_version=current_version, next_version=args.next) + + apply_next_version(version_files=version_files, current_version=current_version, next_version=next_version) + apply_extra_actions(release_actions) + + updated_version = get_current_version(version_files=version_files) + + require_version_updated(next_version=next_version, updated_version=updated_version) + + commit_version_files(version_files=version_files, next_version=next_version) + create_tag(next_version=next_version) + + next_snapshot_version = '{}-SNAPSHOT'.format(get_next_version(current_version=next_version, next_version='patch')) + apply_next_version(version_files=version_files, current_version=next_version, next_version=next_snapshot_version) + apply_extra_actions(snapshot_actions) + + updated_snapshot_version = get_current_version(version_files=version_files) + + require_version_updated(next_version=next_snapshot_version, updated_version=updated_snapshot_version) + + commit_version_files(version_files=version_files, next_version=next_snapshot_version) + + +if __name__ == '__main__': + main()