# Builds a signed release APK on every version tag and attaches it to the # Gitea release, so Obtainium can track updates. See issue #3. # # Required repository secrets (Settings → Secrets → Actions): # KEYSTORE_PASSPHRASE — decrypts android/release-keystore.enc (committed). # Use 32+ random chars; enables rotation without code changes. # RELEASE_STORE_PASSWORD — keystore password # RELEASE_KEY_ALIAS — key alias (mindlist; not secret, kept as secret for uniformity) # RELEASE_KEY_PASSWORD — key password # # Obtainium: Add App → Gitea → paste the repo URL, APK filter # `mindlist-.*\.apk`. If the repo is private, enter an access token # (account → Settings → Applications → token with `repo` scope) in the # Obtainium source settings. Keep "include prereleases" off: this pipeline # only ships stable releases (prerelease:false). name: Release APK on: push: # Semver tags only — vtest/vwip must never cut a public release. tags: - 'v[0-9]*.[0-9]*.[0-9]*' workflow_dispatch: inputs: dry_run: description: 'Build + sign only, do not create a release' type: boolean default: true concurrency: group: release-${{ github.ref }} cancel-in-progress: false jobs: release: runs-on: ubuntu-latest timeout-minutes: 30 permissions: contents: write steps: - name: Checkout uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 with: fetch-depth: 0 # needed for changelog + previous-tag versionCode check - name: Validate tag if: github.event_name == 'push' run: | TAG="${GITHUB_REF_NAME}" [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "::error::Tag $TAG is not semver vX.Y.Z"; exit 1; } - name: Setup Node uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 with: node-version: 22 cache: npm - name: Setup JDK 21 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: temurin java-version: 21 - name: Setup Android SDK # Manual install: android-actions/setup-android pulls the removed # legacy "tools" package and fails on current SDK repositories. run: | SDK="$HOME/android-sdk" mkdir -p "$SDK/cmdline-tools" curl -fsSL --retry 3 -o /tmp/cmdtools.zip https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip echo "2d2d50857e4eb553af5a6dc3ad507a17adf43d115264b1afc116f95c92e5e258 /tmp/cmdtools.zip" | sha256sum -c - unzip -q /tmp/cmdtools.zip -d "$SDK/cmdline-tools" mv "$SDK/cmdline-tools/cmdline-tools" "$SDK/cmdline-tools/latest" export PATH="$SDK/cmdline-tools/latest/bin:$PATH" # NB: `yes` dies with SIGPIPE (141) once sdkmanager quits — not a # failure under `pipefail`, so neutralize only that code. A real # license/install problem still fails loudly below. set +o pipefail yes | sdkmanager --licenses > /dev/null || true set -o pipefail sdkmanager "platform-tools" "platforms;android-36" "build-tools;36.0.0" echo "ANDROID_HOME=$SDK" >> "$GITHUB_ENV" echo "ANDROID_SDK_ROOT=$SDK" >> "$GITHUB_ENV" echo "$SDK/cmdline-tools/latest/bin" >> "$GITHUB_PATH" echo "$SDK/platform-tools" >> "$GITHUB_PATH" - name: Validate Gradle wrapper uses: gradle/actions/wrapper-validation@4c125117fe7c5aed11272ec4213f602f012f89f2 # v4 - name: Install dependencies run: npm ci - name: Test run: npm test - name: Build PWA + sync run: | npm run build npx cap sync android - name: Restore release keystore # The keystore lives in git AES-encrypted (android/release-keystore.enc); # only the short passphrase travels as a secret, so there is no # multi-KB paste to corrupt. Secrets via env: (never -pass pass:) # so they don't appear in the process table. env: ENC_PASS: ${{ secrets.KEYSTORE_PASSPHRASE }} STORE_PASSWORD: ${{ secrets.RELEASE_STORE_PASSWORD }} KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }} KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }} run: | test -n "$ENC_PASS" || { echo "::error::Missing KEYSTORE_PASSPHRASE secret"; exit 1; } openssl enc -d -aes-256-cbc -pbkdf2 -in android/release-keystore.enc \ -out android/mindlist-release.keystore -pass env:ENC_PASS printf 'storeFile=../mindlist-release.keystore\nstorePassword=%s\nkeyAlias=%s\nkeyPassword=%s\n' \ "$STORE_PASSWORD" "$KEY_ALIAS" "$KEY_PASSWORD" > android/keystore.properties echo "sdk.dir=$ANDROID_HOME" > android/local.properties # Self-check: the decoded keystore must match the committed hash # (safe to publish: file is password-encrypted) and open with the # given alias/password. Fails fast with a clear message otherwise. echo "632509e5f1bdda0633736e8bcdc0bc0ce405ebd02fd7187738029240b860b9b6 android/mindlist-release.keystore" | sha256sum -c - keytool -list -keystore android/mindlist-release.keystore -storepass:env STORE_PASSWORD -alias "$KEY_ALIAS" | head -2 - name: Check versionCode bump # Android/Obtainium refuse to install over an equal-or-lower versionCode, # so a tag without a bump would strand users on the old build. run: | CUR=$(grep -oE 'versionCode [0-9]+' android/app/build.gradle | grep -oE '[0-9]+' | head -1) PREV_TAG=$(git tag --sort=-v:refname 2>/dev/null | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -2 | tail -1 || true) if [ -n "$PREV_TAG" ]; then PREV=$(git show "$PREV_TAG:android/app/build.gradle" | grep -oE 'versionCode [0-9]+' | grep -oE '[0-9]+' | head -1) echo "versionCode: $PREV ($PREV_TAG) -> $CUR (${GITHUB_REF_NAME})" [ "$CUR" -gt "$PREV" ] || { echo "::error::Bump versionCode in android/app/build.gradle above $PREV before tagging"; exit 1; } else echo "No previous release tag; versionCode=$CUR accepted" fi - name: Assemble signed release working-directory: android run: ./gradlew assembleRelease - name: Verify signature + fingerprint # The fingerprint pins OUR key: a rotated/wrong secret would sign fine # but break Obtainium updates (signature mismatch on install). run: | $ANDROID_HOME/build-tools/36.0.0/apksigner verify --print-certs app/build/outputs/apk/release/app-release.apk | tee /tmp/certs.txt # apksigner prints the digest lowercase without colons. grep -q '60c52cfecac0a52a9db9241a25a140ea79f6c3fe04f818dae91693768ed0deba' /tmp/certs.txt \ || { echo "::error::APK not signed with the Mindlist release key"; exit 1; } working-directory: android - name: Stage artifact run: | cp android/app/build/outputs/apk/release/app-release.apk "mindlist-${GITHUB_REF_NAME}.apk" sha256sum "mindlist-${GITHUB_REF_NAME}.apk" | cut -d' ' -f1 > "mindlist-${GITHUB_REF_NAME}.apk.sha256" - name: Create Gitea release + upload APK if: github.event_name == 'push' env: TOKEN: ${{ secrets.GITHUB_TOKEN }} TAG: ${{ github.ref_name }} SERVER: ${{ github.server_url }} REPO: ${{ github.repository }} run: | NOTES=$(git log --format='- %s' "$(git describe --tags --abbrev=0 HEAD~1 2>/dev/null || echo '')..HEAD" 2>/dev/null | head -30) python3 - "$SERVER" "$TOKEN" "$REPO" "$TAG" "$NOTES" <<'EOF' import json, sys, urllib.request, urllib.error server, token, repo, tag, notes = sys.argv[1:6] def api(path, data=None, raw=None, ctype='application/json'): req = urllib.request.Request(server + path, data=data, headers={'Authorization': 'token ' + token, 'Content-Type': ctype}) try: with urllib.request.urlopen(req) as r: return r.status, r.read() except urllib.error.HTTPError as e: return e.code, e.read() # Idempotent: reuse the release if the tag already has one (re-runs). status, body = api(f'/api/v1/repos/{repo}/releases/tags/{tag}') if status == 200: rid = json.loads(body)['id'] print(f'Reusing existing release {rid} for {tag}') else: status, body = api(f'/api/v1/repos/{repo}/releases', json.dumps({'tag_name': tag, 'name': f'Mindlist {tag}', 'body': notes, 'draft': False, 'prerelease': False}).encode()) assert status == 201, f'create release failed: {status} {body[:300]}' rid = json.loads(body)['id'] print(f'Created release {rid} for {tag}') for asset in (f'mindlist-{tag}.apk', f'mindlist-{tag}.apk.sha256'): with open(asset, 'rb') as f: blob = f.read() # Replace same-name asset from an earlier run instead of duplicating. status, body = api(f'/api/v1/repos/{repo}/releases/{rid}/assets') for a in json.loads(body): if a['name'] == asset: req = urllib.request.Request( f'{server}/api/v1/repos/{repo}/releases/{rid}/assets/{a["id"]}', headers={'Authorization': 'token ' + token}, method='DELETE') with urllib.request.urlopen(req) as r: print(f'Deleted old asset {asset}: {r.status}') status, body = api(f'/api/v1/repos/{repo}/releases/{rid}/assets?name={asset}', blob, ctype='application/vnd.android.package-archive') assert status == 201, f'upload {asset} failed: {status} {body[:300]}' print(f'Uploaded {asset} ({len(blob)} bytes)') EOF - name: Scrub key material if: always() run: rm -f android/keystore.properties android/mindlist-release.keystore