From 9c35712573b6928069e6ac01dd41300cdc086b07 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Tue, 6 Oct 2026 18:43:45 +0200 Subject: [PATCH] ci(release): beta releases as Codeberg-only pre-releases (#369) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### What this changes Adds beta releases, ported from Agendula (#38 and #40 there). Pushing a `release/*` branch whose `versionName` is `X.Y.Z-beta.N` runs the new `.gitea/workflows/beta.yaml`: unit tests, build + sign with the app key, then a **Codeberg pre-release** (APK + `.sha256`) and a Gitea pre-release (R8 mapping). F-Droid (self-hosted and official) and Play never get a beta; Obtainium only offers it with *Include prereleases* on. - **New versionCode scheme**, derived in one place by `scripts/version_info.sh`. 2.22.3 is the last legacy version (`X*10000 + Y*100 + Z`); from **2.22.4** on it is `X*1000000 + Y*10000 + Z*100 + N` for a beta (N = 1–98) and `+ 99` for stable, so `2.22.4` → `2220499`, `2.23.0-beta.1` → `2230001`. - **`scripts/release_gate.sh`** decides in both `detect` jobs whether the version still needs publishing. Tags are read by exact name via `git ls-remote`: Codeberg's `git/refs/tags/` matches by prefix, so a beta tag would otherwise hide its stable release. A beta counts as done only once its Codeberg pre-release carries the APK (a failed publish is redone by the next push) and must be newer than the latest stable. - **Shared scripts** `publish_codeberg_release.sh`, `publish_gitea_release.sh`, `release_notes.sh`, `write_keystore.sh`, and a local composite action `.gitea/actions/android-env` for the toolchain setup, used by both `release.yaml` and `beta.yaml`. The stable path behaves as before (Codeberg step stays best-effort). - **Guards:** CI fails a PR whose `versionCode` doesn't match its `versionName`, or that brings a beta into `main`; `release.yaml` refuses a beta as a backstop; betas get no store What's New (`sync_changelog_to_fastlane.sh`, `check_changelog_lengths.sh`). - **Docs:** versionCode table and "Cutting a beta" in `docs/RELEASING.md`, the Obtainium note in the README, `build.gradle.kts` comment. - `gradle/gradle-daemon-jvm.properties` now points at JetBrains' own JBR 21.0.11 downloads instead of foojay, which dropped JetBrains 21 from its index (the pinned ids return 400, so a clean runner can't provision the daemon JVM). ### Why To ship test builds of an upcoming version to opted-in testers before the stable release, without them reaching F-Droid or Play users. Infra-only, so this targets `main` directly; no version bump. When cutting 2.22.4, its What's New file is `changelogs/2220499.txt`. ### Checklist - [x] Targeting `main` (infra change, noted above) - [x] No `values-*/strings.xml` touched - [x] `CHANGELOG.md` not updated: release infrastructure, not a user-visible change - [x] No planning or design documents committed Co-authored-by: Jean-Luc Makiola Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/369 --- .forgejo/workflows/ci.yaml | 17 +- .gitea/actions/android-env/action.yml | 57 +++++ .gitea/workflows/beta.yaml | 97 +++++++++ .gitea/workflows/release.yaml | 287 +++----------------------- README.md | 3 +- app/build.gradle.kts | 8 +- docs/RELEASING.md | 80 ++++++- gradle/gradle-daemon-jvm.properties | 20 +- scripts/check_changelog_lengths.sh | 11 +- scripts/publish_codeberg_release.sh | 80 +++++++ scripts/publish_gitea_release.sh | 62 ++++++ scripts/release_gate.sh | 59 ++++++ scripts/release_notes.sh | 27 +++ scripts/sync_changelog_to_fastlane.sh | 14 +- scripts/version_info.sh | 78 +++++++ scripts/write_keystore.sh | 16 ++ 16 files changed, 631 insertions(+), 285 deletions(-) create mode 100644 .gitea/actions/android-env/action.yml create mode 100644 .gitea/workflows/beta.yaml create mode 100755 scripts/publish_codeberg_release.sh create mode 100755 scripts/publish_gitea_release.sh create mode 100755 scripts/release_gate.sh create mode 100755 scripts/release_notes.sh create mode 100755 scripts/version_info.sh create mode 100755 scripts/write_keystore.sh diff --git a/.forgejo/workflows/ci.yaml b/.forgejo/workflows/ci.yaml index bbd018ac..0abfa276 100644 --- a/.forgejo/workflows/ci.yaml +++ b/.forgejo/workflows/ci.yaml @@ -37,9 +37,24 @@ jobs: - name: Reproducible-release invariant run: bash scripts/check_reproducible_release.sh + # versionCode must match versionName (the official F-Droid repo builds the + # tag as committed), and a beta must never reach main. + - name: Committed version is well-formed + env: + BASE: ${{ github.base_ref }} + run: | + set -e + bash scripts/version_info.sh --check + if [ "${BASE#refs/heads/}" = "main" ] && [ "$(bash scripts/version_info.sh channel)" = "beta" ]; then + echo "ERROR: versionName $(bash scripts/version_info.sh version) is a beta." >&2 + echo "Set the stable version (and its versionCode) before merging into main." >&2 + exit 1 + fi + # Play rejects a "What's New" over 500 characters, which would fail the # upload after the release had already shipped everywhere else. Cheap, so - # it runs on every PR rather than only on the release merge. + # it runs on every PR rather than only on the release merge. A beta ships + # no What's New, so only the older files are checked for one. - name: Changelog length invariant run: bash scripts/check_changelog_lengths.sh diff --git a/.gitea/actions/android-env/action.yml b/.gitea/actions/android-env/action.yml new file mode 100644 index 00000000..c37e5aa1 --- /dev/null +++ b/.gitea/actions/android-env/action.yml @@ -0,0 +1,57 @@ +name: Android build environment +description: JDK 17, Android SDK + build tools, Gradle cache and jq for the release and beta pipelines. +runs: + using: composite + steps: + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: 'zulu' + java-version: '17' + + - name: Setup Android SDK + uses: android-actions/setup-android@v3 + with: + packages: '' + + - name: Setup Android SDK cache + uses: actions/cache@v4 + with: + path: /opt/android-sdk + key: ${{ runner.os }}-android-sdk-37-36.0.0 + + - name: Install Android SDK packages + shell: bash + run: | + yes | sdkmanager --licenses >/dev/null || true + sdkmanager \ + "platform-tools" \ + "platforms;android-37.0" \ + "build-tools;36.0.0" + + - name: Setup Gradle cache + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'gradle/libs.versions.toml') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Install jq + shell: bash + run: | + set -e + SUDO="" + if command -v sudo >/dev/null 2>&1; then SUDO="sudo"; fi + if command -v apt-get >/dev/null 2>&1; then + $SUDO apt-get update + $SUDO apt-get install -y jq + elif command -v apk >/dev/null 2>&1; then + $SUDO apk add --no-cache jq + fi + + - name: Grant execute permission for gradlew + shell: bash + run: chmod +x ./gradlew diff --git a/.gitea/workflows/beta.yaml b/.gitea/workflows/beta.yaml new file mode 100644 index 00000000..46e33baa --- /dev/null +++ b/.gitea/workflows/beta.yaml @@ -0,0 +1,97 @@ +name: Beta — Codeberg pre-release + +# Pushing a release/* branch whose versionName is X.Y.Z-beta.N publishes a +# Codeberg pre-release (APK + SHA-256) and a Gitea pre-release (R8 mapping). +# Betas never reach F-Droid or Play. See docs/RELEASING.md. +on: + push: + branches: ['release/**'] + +concurrency: + group: beta + cancel-in-progress: false + +jobs: + detect: + # Gitea only; see the same guard in release.yaml. + if: github.repository_owner == 'makiolaj' + runs-on: docker + outputs: + is_beta: ${{ steps.v.outputs.is_beta }} + version: ${{ steps.v.outputs.version }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Resolve version and whether it still needs publishing + id: v + run: | + set -e + INFO=$(bash scripts/version_info.sh) + echo "$INFO" + echo "$INFO" >> "$GITHUB_OUTPUT" + if [ "$(bash scripts/version_info.sh channel)" != beta ]; then + echo "Not a beta — nothing to do." + echo "is_beta=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + GATE=$(bash scripts/release_gate.sh) + echo "is_beta=${GATE#cut=}" >> "$GITHUB_OUTPUT" + + beta: + needs: detect + if: needs.detect.outputs.is_beta == 'true' + runs-on: docker + env: + ANDROID_HOME: /opt/android-sdk + ANDROID_SDK_ROOT: /opt/android-sdk + VERSION: ${{ needs.detect.outputs.version }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Android build environment + uses: ./.gitea/actions/android-env + + - name: Pin versionCode to versionName + run: bash scripts/version_info.sh --pin + + - name: Unit tests + run: ./gradlew testDebugUnitTest + + # The real app key, so a beta updates in place to later betas and stable. + - name: Setup Android Keystore + env: + KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }} + KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} + KEY_ALIAS: ${{ secrets.KEY_ALIAS }} + run: bash scripts/write_keystore.sh + + - name: Build release APK + run: ./gradlew assembleRelease + + - name: Create tag + Gitea pre-release + env: + TOKEN: ${{ secrets.GITHUB_TOKEN }} + API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }} + SHA: ${{ github.sha }} + run: | + set -e + bash scripts/release_notes.sh "$VERSION" > release-notes.md + cat release-notes.md + TAG="v$VERSION" PRERELEASE=true NOTES_FILE=release-notes.md \ + MAPPING=app/build/outputs/mapping/release/mapping.txt \ + bash scripts/publish_gitea_release.sh + + - name: Publish pre-release to Codeberg + env: + TOKEN: ${{ secrets.CODEBERG_RELEASE_TOKEN }} + API: https://codeberg.org/api/v1/repos/jlmakiola/calendula + SHA: ${{ github.sha }} + run: | + set -e + TAG="v$VERSION" PRERELEASE=true NOTES_FILE=release-notes.md \ + APK=app/build/outputs/apk/release/app-release.apk \ + bash scripts/publish_codeberg_release.sh diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml index b250ba68..5d2d4b24 100644 --- a/.gitea/workflows/release.yaml +++ b/.gitea/workflows/release.yaml @@ -7,7 +7,9 @@ name: Release — F-Droid repo + Gitea/Codeberg release + Play # that release to Codeberg with the signed APK + a SHA-256 checksum as a # direct-download channel — the tag is an output of the pipeline, not its # trigger. Ordinary merges (no version bump) fall through `detect` and do -# nothing. +# nothing. Betas (X.Y.Z-beta.N) never come through here: beta.yaml cuts them +# from release/* branches as Codeberg-only pre-releases, and `detect` refuses +# one that reaches main. # # A trailing `play` job then uploads the App Bundle to Google Play. It is last # and separate because Play is the only channel that can reject a good build for @@ -54,56 +56,28 @@ jobs: - name: Resolve version and whether it is a new release id: v - env: - # Tags are read from Codeberg, which is canonical — deliberately NOT - # from the Gitea API this workflow runs on. The Codeberg -> Gitea sync - # is a push mirror, i.e. `git push --mirror`, which deletes refs the - # source does not have. A tag minted here on Gitea is therefore wiped - # by the next sync (Codeberg does not have it yet) and only reappears - # once the tag push at the end of this workflow propagates back. - # Asking Gitea inside that window would report "no tag" for a release - # that already shipped, and cut it a second time. - # Public repo, so this read needs no token. - TAG_API: https://codeberg.org/api/v1/repos/jlmakiola/calendula run: | set -e - VERSION=$(grep -oP 'versionName\s*=\s*"\K[^"]+' app/build.gradle.kts) - if [ -z "$VERSION" ]; then echo "No versionName in app/build.gradle.kts" >&2; exit 1; fi - MAJOR=$(echo "$VERSION" | cut -d. -f1); MINOR=$(echo "$VERSION" | cut -d. -f2); PATCH=$(echo "$VERSION" | cut -d. -f3) - MAJOR=${MAJOR:-0}; MINOR=${MINOR:-0}; PATCH=${PATCH:-0} - VERSION_CODE=$(( MAJOR * 10000 + MINOR * 100 + PATCH )) - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - echo "version_code=$VERSION_CODE" >> "$GITHUB_OUTPUT" - echo "Resolved version $VERSION (code $VERSION_CODE)" + INFO=$(bash scripts/version_info.sh) + echo "$INFO" + echo "$INFO" >> "$GITHUB_OUTPUT" + VERSION=$(echo "$INFO" | sed -n 's/^version=//p') + CHANNEL=$(echo "$INFO" | sed -n 's/^channel=//p') if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then echo "Manual dispatch — re-sign path, not a release." echo "is_release=false" >> "$GITHUB_OUTPUT" exit 0 fi - # A tag for this version already existing means the release shipped on - # an earlier push; do nothing. Absent => this merge cuts the release. - # - # Anything other than a clean 200/404 is treated as fatal rather than - # as "no tag". A Codeberg outage or a network blip would otherwise - # read as absent and re-cut a release that has already shipped — - # republishing to F-Droid and Play. Failing here is recoverable; a - # duplicate release is not. - STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$TAG_API/git/refs/tags/v$VERSION" || echo 000) - case "$STATUS" in - 200) - echo "Tag v$VERSION already exists on Codeberg — nothing to release." - echo "is_release=false" >> "$GITHUB_OUTPUT" - ;; - 404) - echo "No tag for v$VERSION on Codeberg yet — cutting the release." - echo "is_release=true" >> "$GITHUB_OUTPUT" - ;; - *) - echo "Codeberg tag lookup for v$VERSION returned HTTP $STATUS." >&2 - echo "Refusing to guess: treating this as 'no tag' could re-cut a shipped release." >&2 - exit 1 - ;; - esac + # Backstop for CI's guard: betas ship from release/* via beta.yaml. + if [ "$CHANNEL" != "stable" ]; then + echo "versionName $VERSION on main is a beta. Set the stable version before merging to main." >&2 + exit 1 + fi + # Tags are read from Codeberg, the canonical forge: a tag minted here is + # wiped by the next mirror sync until Codeberg has it. A lookup error is + # fatal, since guessing "no tag" would re-cut a shipped release. + GATE=$(bash scripts/release_gate.sh) + echo "is_release=${GATE#cut=}" >> "$GITHUB_OUTPUT" # Releases: build + sign + publish, then mint the tag and Gitea release. # Also runs on manual dispatch, where it skips the build and just re-signs and @@ -124,65 +98,13 @@ jobs: with: submodules: recursive - - name: Setup Java - uses: actions/setup-java@v4 - with: - distribution: 'zulu' - java-version: '17' + - name: Android build environment + uses: ./.gitea/actions/android-env - - name: Setup Android SDK - uses: android-actions/setup-android@v3 - with: - packages: '' - - - name: Setup Android SDK cache - uses: actions/cache@v4 - with: - path: /opt/android-sdk - key: ${{ runner.os }}-android-sdk-37-36.0.0 - - - name: Install Android SDK packages - run: | - yes | sdkmanager --licenses >/dev/null || true - sdkmanager \ - "platform-tools" \ - "platforms;android-37.0" \ - "build-tools;36.0.0" - - - name: Setup Gradle cache - uses: actions/cache@v4 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'gradle/libs.versions.toml') }} - restore-keys: | - ${{ runner.os }}-gradle- - - - name: Install jq - run: | - set -e - SUDO="" - if command -v sudo >/dev/null 2>&1; then SUDO="sudo"; fi - if command -v apt-get >/dev/null 2>&1; then - $SUDO apt-get update - $SUDO apt-get install -y jq - elif command -v apk >/dev/null 2>&1; then - $SUDO apk add --no-cache jq - fi - - - name: Grant execute permission for gradlew - run: chmod +x ./gradlew - - # The committed versionName is the source of truth. Pin versionCode to the - # value derived from it so the published APK's code is always - # MAJOR*10000 + MINOR*100 + PATCH even if the committed code was forgotten. + # The committed versionName is the source of truth; pin the derived code. - name: Pin versionCode to versionName if: env.IS_RELEASE == 'true' - run: | - set -e - sed -i "s/versionCode = .*/versionCode = $VERSION_CODE/" app/build.gradle.kts - grep -E 'versionName|versionCode' app/build.gradle.kts + run: bash scripts/version_info.sh --pin # Test the exact commit being shipped (only on a real release). - name: Unit tests @@ -195,15 +117,7 @@ jobs: KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }} KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} KEY_ALIAS: ${{ secrets.KEY_ALIAS }} - run: | - mkdir -p app - echo "$KEYSTORE_BASE64" | base64 --decode > app/upload-keystore.jks - cat > key.properties < release-notes.md - sed -i -e '/./,$!d' release-notes.md - if [ ! -s release-notes.md ]; then - echo "_No changelog entry for ${VERSION} — see CHANGELOG.md._" > release-notes.md - fi - python3 - "$TAG" "$SHA" <<'PY' > payload.json - import json, sys - print(json.dumps({ - "tag_name": sys.argv[1], - "target_commitish": sys.argv[2], - "name": sys.argv[1], - "body": open("release-notes.md").read(), - "draft": False, - "prerelease": False, - })) - PY - # Upsert (re-run safe): PATCH if a release for the tag already exists, - # else POST a new one (which also creates the tag at target_commitish). - curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" > existing.json - ID=$(jq -r '.id // empty' existing.json 2>/dev/null || true) - if [ -n "$ID" ]; then - CODE=$(curl -s -o response.json -w '%{http_code}' -X PATCH \ - -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \ - -d @payload.json "$API/releases/$ID") - OK=200 - else - CODE=$(curl -s -o response.json -w '%{http_code}' -X POST \ - -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \ - -d @payload.json "$API/releases") - OK=201 - fi - cat response.json - if [ "$CODE" != "$OK" ]; then - echo "Release upsert failed with HTTP $CODE (expected $OK)" >&2 - exit 1 - fi - echo "Created/updated release $TAG at $SHA" - - # Archive the R8 mapping so user crash stacktraces stay deobfuscatable. - # Attached to the release (it's not an APK, so it fits the no-binaries - # rule). Best-effort: never fail a release over it. - - name: Attach R8 mapping to Gitea release - if: env.IS_RELEASE == 'true' - continue-on-error: true - env: - TOKEN: ${{ secrets.GITHUB_TOKEN }} - API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }} - run: | - set -e - MAP="app/build/outputs/mapping/release/mapping.txt" - if [ ! -f "$MAP" ]; then echo "No mapping.txt (R8 off?) — skipping."; exit 0; fi - TAG="v$VERSION" - ASSET="mapping-${VERSION}.txt.gz" - gzip -c "$MAP" > "/tmp/$ASSET" - ID=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty') - if [ -z "$ID" ]; then echo "Could not resolve release id — skipping."; exit 0; fi - # Replace any prior asset of the same name (re-run safe). - OLD=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/$ID/assets" \ - | jq -r --arg n "$ASSET" '.[] | select(.name==$n) | .id') - [ -n "$OLD" ] && curl -s -X DELETE -H "Authorization: token $TOKEN" "$API/releases/$ID/assets/$OLD" >/dev/null || true - curl -s -X POST -H "Authorization: token $TOKEN" \ - -F "attachment=@/tmp/$ASSET" \ - "$API/releases/$ID/assets?name=$ASSET" -o /dev/null -w "asset upload HTTP %{http_code}\n" + bash scripts/release_notes.sh "$VERSION" > release-notes.md + TAG="v$VERSION" PRERELEASE=false NOTES_FILE=release-notes.md \ + MAPPING=app/build/outputs/mapping/release/mapping.txt \ + bash scripts/publish_gitea_release.sh # Mirror the release to the Codeberg mirror as a direct-download channel # for users who don't want F-Droid. Gitea already push-mirrors branches + @@ -406,84 +257,10 @@ jobs: SHA: ${{ github.sha }} run: | set -e - if [ -z "${TOKEN:-}" ]; then - echo "CODEBERG_RELEASE_TOKEN not set — skipping Codeberg publish." - exit 0 - fi - TAG="v$VERSION" - APK="app/build/outputs/apk/release/app-release.apk" - if [ ! -f "$APK" ]; then echo "No release APK found — skipping." >&2; exit 1; fi - ASSET_APK="calendula_v${VERSION}.apk" - ASSET_SUM="${ASSET_APK}.sha256" - cp "$APK" "/tmp/$ASSET_APK" - ( cd /tmp && sha256sum "$ASSET_APK" > "$ASSET_SUM" ) - - # Release notes: reuse the section extracted for the Gitea release, - # fall back to the CHANGELOG entry if that step's file is gone. - if [ ! -s release-notes.md ]; then - awk -v ver="$VERSION" ' - $0 ~ "^## \\[" ver "\\]" { flag = 1; next } - /^## \[/ { flag = 0 } - flag' CHANGELOG.md > release-notes.md - sed -i -e '/./,$!d' release-notes.md - fi - [ -s release-notes.md ] || echo "_See CHANGELOG.md for ${VERSION}._" > release-notes.md - # The pipeline creates the tag via the Gitea API, which the push mirror - # (sync_on_commit only fires on real git pushes) doesn't propagate - # promptly — so a release POST that carries a target_commitish can - # outrun the mirror and 500 on a commit/tag Codeberg hasn't received. - # Push the tag straight to Codeberg so it's guaranteed present, then - # attach the release to that existing tag with NO target_commitish - # (which is what triggered the 500). - git tag -f "$TAG" "$SHA" - git push -f "https://jlmakiola:${TOKEN}@codeberg.org/jlmakiola/calendula.git" \ - "refs/tags/$TAG" - python3 - "$TAG" <<'PY' > cb-payload.json - import json, sys - print(json.dumps({ - "tag_name": sys.argv[1], - "name": sys.argv[1], - "body": open("release-notes.md").read(), - "draft": False, - "prerelease": False, - })) - PY - # Create (or update) the release. Codeberg 500s on a POST/GET against a - # tag it has only just received — the release request outruns the - # indexing of the ref we pushed a moment ago — so a single attempt kept - # failing and skipping the mirror even though the very same call - # succeeds seconds later. Retry with backoff, and PATCH in place if a - # release already exists (re-run safe). A 5xx body still exits curl 0, - # so the loop, not `set -e`, controls the flow. - ID="" - for attempt in 1 2 3 4 5 6; do - EXIST=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty' 2>/dev/null || true) - if [ -n "$EXIST" ]; then - curl -s -o /dev/null -w "release PATCH HTTP %{http_code}\n" -X PATCH \ - -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \ - -d @cb-payload.json "$API/releases/$EXIST" - ID="$EXIST"; break - fi - CODE=$(curl -s -o cb-response.json -w "%{http_code}" -X POST \ - -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \ - -d @cb-payload.json "$API/releases") - echo "release POST attempt $attempt HTTP $CODE" - ID=$(jq -r '.id // empty' cb-response.json 2>/dev/null || true) - [ -n "$ID" ] && break - sleep $((attempt * 10)) - done - if [ -z "$ID" ]; then echo "Could not resolve Codeberg release id after retries." >&2; exit 1; fi - - # Attach APK + checksum, replacing any prior asset of the same name. - for A in "$ASSET_APK" "$ASSET_SUM"; do - OLD=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/$ID/assets" \ - | jq -r --arg n "$A" '.[] | select(.name==$n) | .id') - [ -n "$OLD" ] && curl -s -X DELETE -H "Authorization: token $TOKEN" "$API/releases/$ID/assets/$OLD" >/dev/null || true - curl -s -X POST -H "Authorization: token $TOKEN" \ - -F "attachment=@/tmp/$A" \ - "$API/releases/$ID/assets?name=$A" -o /dev/null -w "asset $A HTTP %{http_code}\n" - done - echo "Published $TAG to Codeberg." + [ -s release-notes.md ] || bash scripts/release_notes.sh "$VERSION" > release-notes.md + TAG="v$VERSION" PRERELEASE=false NOTES_FILE=release-notes.md \ + APK=app/build/outputs/apk/release/app-release.apk \ + bash scripts/publish_codeberg_release.sh # Play takes an App Bundle, not the APK, so it is a second artifact from # the same source and the same signing config — not a repackage of the diff --git a/README.md b/README.md index a09d8406..73780264 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,8 @@ For automatic updates from Codeberg, use [add Calendula in one tap](https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://codeberg.org/jlmakiola/calendula), or add it by hand: *Add App*, paste `https://codeberg.org/jlmakiola/calendula`, then *Add*. Obtainium then watches the releases and tells you when a new one -is out. +is out. Betas of upcoming versions are published there too, as pre-releases; to +test them, switch on *Include prereleases* for Calendula in Obtainium. ### Google Play diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9242dcc7..99bb4fbe 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -26,8 +26,12 @@ android { // These committed values ARE the source of truth for a release: merging // a bumped versionName into main triggers .gitea/workflows/release.yaml, // which builds this version and then creates the matching vX.Y.Z tag + - // release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 + - // PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md. + // release itself. A versionName of X.Y.Z-beta.N pushed to a release/* + // branch instead cuts a Codeberg-only pre-release (beta.yaml). + // versionCode is derived from versionName by scripts/version_info.sh + // (up to 2.22.3: 2.22.3 -> 22203; from 2.22.4: 2.23.0-beta.1 -> 2230001, + // 2.22.4 -> 2220499), and CI fails if the committed one doesn't match. + // See docs/RELEASING.md. versionCode = 22203 versionName = "2.22.3" diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 2801ac76..ee772a2c 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -5,34 +5,56 @@ built, signed, and published automatically by `.gitea/workflows/release.yaml` when a **bumped `versionName` reaches `main`** — the pipeline then creates the matching `vX.Y.Z` tag and Gitea release itself. +Before a stable release you can ship **betas** (`X.Y.Z-beta.N`) from the +release branch. They go to **Codeberg only**, flagged as pre-releases; F-Droid +and Play never see them. See [Cutting a beta](#cutting-a-beta). + ## Versioning — the committed version is the source of truth A release is defined by the `versionName`/`versionCode` committed in `app/build.gradle.kts`: -- `versionName` = `MAJOR.MINOR.PATCH` (e.g. `2.1.0`) -- `versionCode` = `MAJOR*10000 + MINOR*100 + PATCH` (`2.1.0` → `20100`) +- `versionName` = `MAJOR.MINOR.PATCH` (e.g. `2.22.4`), or + `MAJOR.MINOR.PATCH-beta.N` for a beta (e.g. `2.23.0-beta.2`) +- `versionCode` is derived from it by `scripts/version_info.sh`: -So `MINOR` and `PATCH` each have room for 0–99. The release pipeline reads +| `versionName` | `versionCode` | Example | +| --- | --- | --- | +| `X.Y.Z` up to 2.22.3 (legacy) | `X*10000 + Y*100 + Z` | `2.22.3` → `22203` | +| `X.Y.Z-beta.N` (N = 1–98) | `X*1000000 + Y*10000 + Z*100 + N` | `2.23.0-beta.2` → `2230002` | +| `X.Y.Z` from 2.22.4 | `X*1000000 + Y*10000 + Z*100 + 99` | `2.22.4` → `2220499` | + +A beta's code sits below its own stable release and above everything before +it, so a beta install updates in place to the next beta and then to the stable +version. Every version up to 2.22.3 keeps the code it already shipped with, and +`2.22.4` is the first on the new scheme. `MINOR` and `PATCH` each have room for +0–99. Run `scripts/version_info.sh` to see what the committed version resolves +to; CI fails a PR whose committed `versionCode` doesn't match, because the +official F-Droid repo builds the tag exactly as committed. + +The release pipeline reads `versionName`, pins `versionCode` to the derived value, builds, and — once the APK is published — creates the tag `v` at that commit. The tag is an **output** of a successful release, not its trigger, so a tag always marks a fully-shipped version (and a failure before publish leaves no tag, so re-running the workflow safely retries). -Published version codes so far: `v0.1.0`→100 … `v1.0.0`→10000 … `v2.0.0`→20000. +Published version codes so far: `v0.1.0`→100 … `v2.0.0`→20000 … +`v2.22.3`→22203, then `v2.22.4`→2220499. ## Cutting a release 1. **Assemble the release branch.** Create `release/vX.Y.Z` and merge the feature/fix branches that make up this release into it. This branch is the release candidate — everything below happens on it, before it reaches `main`. + To ship betas first, follow [Cutting a beta](#cutting-a-beta) from here and + come back to step 2 when going stable. 2. Move the `## [Unreleased]` section of `CHANGELOG.md` under a new `## [X.Y.Z] — ` heading (Keep a Changelog format). The text between that heading and the next `## [` becomes both the Gitea release notes and the F-Droid per-version changelog. -3. Bump the committed `versionName` (and `versionCode`) in - `app/build.gradle.kts` to the new version. **This bump is what triggers the +3. Bump the committed `versionName` (and `versionCode`, which + `scripts/version_info.sh` prints) in `app/build.gradle.kts` to the new version. **This bump is what triggers the release** when the branch merges to `main`. Then write the per-version "What's New" by hand to `fastlane/metadata/android/en-US/changelogs/.txt` and commit it. @@ -89,6 +111,48 @@ Published version codes so far: `v0.1.0`→100 … `v1.0.0`→10000 … `v2.0.0` > The `releaseTest` build type exists only for step 4 — it is never published. > The pipeline always builds and signs the real `release` variant. +## Cutting a beta + +A beta is a test build of the next version, published as a **pre-release on +Codeberg** and nowhere else. It is signed with the real app key, so testers +install it over their stable install and it updates in place to later betas +and to the stable release. + +1. **On `release/vX.Y.Z`**, with the features merged in, set + `versionName = "X.Y.Z-beta.1"` and the matching `versionCode` + (`scripts/version_info.sh` prints it; `2.23.0-beta.1` → `2230001`). + No What's New file — betas don't ship to the stores. Notes come from a + `## [X.Y.Z-beta.N]` section of `CHANGELOG.md` if you write one, otherwise + from `## [Unreleased]`. So keep the entries under `## [Unreleased]` while + betas are going out and only move them to `## [X.Y.Z]` when going stable: + once they're moved, a beta's notes come out empty. +2. **Optionally verify it on a device** with `scripts/verify-release.sh`, as for + a stable release. +3. **Push the branch to Codeberg.** When it reaches Gitea, `beta.yaml` sees a + beta whose Codeberg pre-release doesn't carry its APK yet, runs the unit + tests, builds and signs the APK, creates the `vX.Y.Z-beta.1` tag + a Gitea + pre-release (with the R8 mapping) and publishes the **Codeberg + pre-release** with the APK + `.sha256`. If that publish fails part-way, the + next push of the branch redoes it. +4. **Next round:** bump to `-beta.2` (and its `versionCode`) and push again. +5. **Going stable:** set `versionName = "X.Y.Z"` and its `versionCode` + (`2.23.0` → `2230099`), then continue from step 2 of + [Cutting a release](#cutting-a-release). + +Who gets a beta: + +- **Obtainium** users only with *Include prereleases* switched on for the app. + That is how a tester opts in; everyone else stays on stable. +- **F-Droid** (self-hosted and official) never: `beta.yaml` doesn't touch the + self-hosted repo, and the official recipe's `UpdateCheckMode: Tags ^v[0-9.]+$` + ignores `-beta` tags. Keep that pattern if the recipe ever changes. +- **Play** never. + +Guards: a beta version can't reach `main` (CI fails the PR, and `release.yaml`'s +`detect` refuses one as a backstop), `beta.yaml` refuses a beta that isn't +newer than the latest stable release, and betas start at 2.22.4 (the legacy +codes have no room below them). + ## What the pipeline does CI and release are split so a change is built once on its PR and only does @@ -108,6 +172,10 @@ release work when a merge actually cuts a release: mirror the release to **Codeberg** with the signed APK + a SHA-256 checksum (both best-effort). Ordinary merges with no version bump fall through `detect` and do nothing. +- **`beta.yaml`** (on push to `release/**`) — when the committed `versionName` + is a beta with no tag yet: unit tests, build & sign with the app key, Gitea + pre-release with the R8 mapping, Codeberg pre-release with the APK + + `.sha256`. Nothing else; see [Cutting a beta](#cutting-a-beta). - **`play` job** (same workflow, after `release`) — uploads the App Bundle to Google Play. Runs last and separately so a Play rejection can't endanger a release that already shipped; skips cleanly until Play is configured. diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties index baa28d15..35035325 100644 --- a/gradle/gradle-daemon-jvm.properties +++ b/gradle/gradle-daemon-jvm.properties @@ -1,13 +1,13 @@ #This file is generated by updateDaemonJvm -toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect -toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect -toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect -toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect -toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/7083b89563e7ce20943037b8cd2b8cc2/redirect -toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/060bbb778a1f55ea705fdebd2ccfeab9/redirect -toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect -toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect -toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/d09679dc60fe5aa05ef7d03efdefac20/redirect -toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/ed4e3bf2f5e7c5d9aabc4cbd8acd555e/redirect +toolchainUrl.FREE_BSD.AARCH64=https\://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.11-linux-aarch64-b1163.116.tar.gz +toolchainUrl.FREE_BSD.X86_64=https\://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.11-linux-x64-b1163.116.tar.gz +toolchainUrl.LINUX.AARCH64=https\://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.11-linux-aarch64-b1163.116.tar.gz +toolchainUrl.LINUX.X86_64=https\://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.11-linux-x64-b1163.116.tar.gz +toolchainUrl.MAC_OS.AARCH64=https\://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.11-osx-aarch64-b1163.116.tar.gz +toolchainUrl.MAC_OS.X86_64=https\://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.11-osx-x64-b1163.116.tar.gz +toolchainUrl.UNIX.AARCH64=https\://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.11-linux-aarch64-b1163.116.tar.gz +toolchainUrl.UNIX.X86_64=https\://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.11-linux-x64-b1163.116.tar.gz +toolchainUrl.WINDOWS.AARCH64=https\://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.11-windows-aarch64-b1163.116.tar.gz +toolchainUrl.WINDOWS.X86_64=https\://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.11-windows-x64-b1163.116.tar.gz toolchainVendor=JETBRAINS toolchainVersion=21 diff --git a/scripts/check_changelog_lengths.sh b/scripts/check_changelog_lengths.sh index 6a80bd68..b00fbdda 100755 --- a/scripts/check_changelog_lengths.sh +++ b/scripts/check_changelog_lengths.sh @@ -28,10 +28,11 @@ LIMIT=500 STRICT=0 [ "${1:-}" = "--strict" ] && STRICT=1 -VERSION=$(grep -oP 'versionName\s*=\s*"\K[^"]+' app/build.gradle.kts) -[ -n "$VERSION" ] || { echo "No versionName in app/build.gradle.kts" >&2; exit 1; } -MAJOR=${VERSION%%.*}; rest=${VERSION#*.}; MINOR=${rest%%.*}; PATCH=${rest##*.} -VERSION_CODE=$(( ${MAJOR:-0} * 10000 + ${MINOR:-0} * 100 + ${PATCH:-0} )) +VERSION=$(bash scripts/version_info.sh version) +VERSION_CODE=$(bash scripts/version_info.sh version_code) +# A beta ships no What's New, so only the older files are checked. +BETA=0 +[ "$(bash scripts/version_info.sh channel)" = beta ] && BETA=1 fail=0 warned=0 @@ -65,7 +66,7 @@ for f in fastlane/metadata/android/*/changelogs/*.txt; do fi done -if [ "$current" -eq 0 ]; then +if [ "$current" -eq 0 ] && [ "$BETA" -eq 0 ]; then echo "ERROR: no changelog for version $VERSION (code $VERSION_CODE)." >&2 echo " Write fastlane/metadata/android/en-US/changelogs/$VERSION_CODE.txt" >&2 echo " before releasing — see docs/RELEASING.md step 3." >&2 diff --git a/scripts/publish_codeberg_release.sh b/scripts/publish_codeberg_release.sh new file mode 100755 index 00000000..ad91f1d5 --- /dev/null +++ b/scripts/publish_codeberg_release.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# Publishes TAG on Codeberg with the signed APK and its SHA-256 attached. +# Upserts, so re-runs are safe. A missing TOKEN skips a stable release but fails +# a pre-release, where Codeberg is the only channel. +# +# Env: TOKEN, API (.../api/v1/repos//), TAG, SHA, PRERELEASE, +# NOTES_FILE, APK +set -euo pipefail +if [ -z "${TOKEN:-}" ]; then + if [ "${PRERELEASE:-}" = true ]; then + echo "CODEBERG_RELEASE_TOKEN not set — a pre-release has nowhere else to go." >&2 + exit 1 + fi + echo "CODEBERG_RELEASE_TOKEN not set — skipping Codeberg publish." + exit 0 +fi +: "${API:?}" "${TAG:?}" "${SHA:?}" "${PRERELEASE:?}" "${NOTES_FILE:?}" "${APK:?}" +if [ ! -f "$APK" ]; then echo "No release APK at $APK." >&2; exit 1; fi + +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT + +ASSET_APK="calendula_${TAG}.apk" +ASSET_SUM="${ASSET_APK}.sha256" +cp "$APK" "$WORK/$ASSET_APK" +( cd "$WORK" && sha256sum "$ASSET_APK" > "$ASSET_SUM" ) + +# Push the tag first and create the release without target_commitish: a POST +# naming a commit Codeberg hasn't received yet 500s. +HOST=${API#https://}; HOST=${HOST%%/*} +REPO=${API#*/repos/} +git tag -f "$TAG" "$SHA" +git push -f "https://${REPO%%/*}:${TOKEN}@${HOST}/${REPO}.git" "refs/tags/$TAG" + +python3 - "$TAG" "$PRERELEASE" "$NOTES_FILE" <<'PY' > "$WORK/payload.json" +import json, sys +tag, pre, notes = sys.argv[1:4] +print(json.dumps({ + "tag_name": tag, + "name": tag, + "body": open(notes).read(), + "draft": False, + "prerelease": pre == "true", +})) +PY + +# Codeberg 500s on a freshly pushed tag for a few seconds, hence the retries. +ID="" +for attempt in 1 2 3 4 5 6; do + EXIST=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty' 2>/dev/null || true) + if [ -n "$EXIST" ]; then + CODE=$(curl -s -o /dev/null -w '%{http_code}' -X PATCH \ + -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \ + -d @"$WORK/payload.json" "$API/releases/$EXIST" || echo 000) + echo "release PATCH HTTP $CODE" + if [ "$CODE" = 200 ]; then ID="$EXIST"; break; fi + else + CODE=$(curl -s -o "$WORK/response.json" -w '%{http_code}' -X POST \ + -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \ + -d @"$WORK/payload.json" "$API/releases" || echo 000) + echo "release POST attempt $attempt HTTP $CODE" + ID=$(jq -r '.id // empty' "$WORK/response.json" 2>/dev/null || true) + [ -n "$ID" ] && break + fi + sleep $((attempt * 10)) +done +if [ -z "$ID" ]; then echo "Could not create or update the Codeberg release." >&2; exit 1; fi + +for A in "$ASSET_APK" "$ASSET_SUM"; do + OLD=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/$ID/assets" \ + | jq -r --arg n "$A" '.[]? | select(.name==$n) | .id' 2>/dev/null || true) + for O in $OLD; do + curl -s -o /dev/null -X DELETE -H "Authorization: token $TOKEN" "$API/releases/$ID/assets/$O" || true + done + CODE=$(curl -s -o /dev/null -w '%{http_code}' -X POST -H "Authorization: token $TOKEN" \ + -F "attachment=@$WORK/$A" "$API/releases/$ID/assets?name=$A" || echo 000) + echo "asset $A HTTP $CODE" + if [ "$CODE" != 201 ]; then echo "Uploading $A failed." >&2; exit 1; fi +done +echo "Published $TAG to Codeberg." diff --git a/scripts/publish_gitea_release.sh b/scripts/publish_gitea_release.sh new file mode 100755 index 00000000..d184b565 --- /dev/null +++ b/scripts/publish_gitea_release.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# Upserts the Gitea release for TAG (creating the tag at SHA) and attaches the +# R8 mapping, best-effort. +# +# Env: TOKEN, API (.../api/v1/repos//), TAG, SHA, PRERELEASE, +# NOTES_FILE, MAPPING (optional) +set -euo pipefail +: "${TOKEN:?}" "${API:?}" "${TAG:?}" "${SHA:?}" "${PRERELEASE:?}" "${NOTES_FILE:?}" + +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT + +python3 - "$TAG" "$SHA" "$PRERELEASE" "$NOTES_FILE" <<'PY' > "$WORK/payload.json" +import json, sys +tag, sha, pre, notes = sys.argv[1:5] +print(json.dumps({ + "tag_name": tag, + "target_commitish": sha, + "name": tag, + "body": open(notes).read(), + "draft": False, + "prerelease": pre == "true", +})) +PY + +ID=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty' 2>/dev/null || true) +if [ -n "$ID" ]; then + CODE=$(curl -s -o "$WORK/response.json" -w '%{http_code}' -X PATCH \ + -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \ + -d @"$WORK/payload.json" "$API/releases/$ID") + OK=200 +else + CODE=$(curl -s -o "$WORK/response.json" -w '%{http_code}' -X POST \ + -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \ + -d @"$WORK/payload.json" "$API/releases") + OK=201 +fi +cat "$WORK/response.json"; echo +if [ "$CODE" != "$OK" ]; then + echo "Gitea release upsert failed with HTTP $CODE (expected $OK)" >&2 + exit 1 +fi +ID=$(jq -r '.id' "$WORK/response.json") +echo "Created/updated Gitea release $TAG at $SHA" + +attach_mapping() { + local asset="mapping-${TAG#v}.txt.gz" old + gzip -c "$MAPPING" > "$WORK/$asset" + old=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/$ID/assets" \ + | jq -r --arg n "$asset" '.[] | select(.name==$n) | .id') + if [ -n "$old" ]; then + curl -s -X DELETE -H "Authorization: token $TOKEN" "$API/releases/$ID/assets/$old" >/dev/null + fi + curl -s -X POST -H "Authorization: token $TOKEN" \ + -F "attachment=@$WORK/$asset" \ + "$API/releases/$ID/assets?name=$asset" -o /dev/null -w "asset $asset HTTP %{http_code}\n" +} +if [ -z "${MAPPING:-}" ] || [ ! -f "$MAPPING" ]; then + echo "No mapping.txt (R8 off?) — skipping." +else + attach_mapping || echo "warning: could not attach the R8 mapping to $TAG" >&2 +fi diff --git a/scripts/release_gate.sh b/scripts/release_gate.sh new file mode 100755 index 00000000..4ced7270 --- /dev/null +++ b/scripts/release_gate.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# Decides whether the committed version still needs publishing; prints cut=true|false. +# +# stable: no vX.Y.Z tag on Codeberg yet. +# beta: its Codeberg pre-release doesn't carry the APK yet, so a failed +# publish is redone by the next push. Refused unless it is newer than +# every shipped stable release. +# +# Fails on any lookup error rather than guessing. +set -euo pipefail +cd "$(dirname "$0")/.." # repo root + +REPO=${REPO:-jlmakiola/calendula} +VERSION=$(bash scripts/version_info.sh version) +BASE=$(bash scripts/version_info.sh base_version) +CHANNEL=$(bash scripts/version_info.sh channel) + +# Exact tag names. The Codeberg git/refs/tags/ API matches by prefix. +TAGS=$(git ls-remote --tags --refs "https://codeberg.org/$REPO.git" | sed 's#.*refs/tags/##') + +if [ "$CHANNEL" = stable ]; then + if grep -qxF "v$VERSION" <<<"$TAGS"; then + echo "Tag v$VERSION already exists on Codeberg — nothing to release." >&2 + echo "cut=false" + else + echo "No tag for v$VERSION on Codeberg yet — cutting the release." >&2 + echo "cut=true" + fi + exit 0 +fi + +LATEST=$(grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' <<<"$TAGS" | sed 's/^v//' | sort -V | tail -n1 || true) +if [ -n "$LATEST" ] && [ "$(printf '%s\n%s\n' "$BASE" "$LATEST" | sort -V | tail -n1)" = "$LATEST" ]; then + echo "Beta $VERSION is not newer than the shipped stable $LATEST — bump the version." >&2 + exit 1 +fi + +ASSET="calendula_v${VERSION}.apk" +BODY=$(mktemp); trap 'rm -f "$BODY"' EXIT +STATUS=$(curl -s -o "$BODY" -w '%{http_code}' "https://codeberg.org/api/v1/repos/$REPO/releases/tags/v$VERSION" || echo 000) +case "$STATUS" in + 200) + if grep -qF "\"name\":\"$ASSET\"" "$BODY"; then + echo "Pre-release v$VERSION already carries $ASSET — nothing to do." >&2 + echo "cut=false" + else + echo "Pre-release v$VERSION exists without $ASSET — publishing it again." >&2 + echo "cut=true" + fi + ;; + 404) + echo "No pre-release for v$VERSION yet — cutting the beta." >&2 + echo "cut=true" + ;; + *) + echo "Codeberg release lookup for v$VERSION returned HTTP $STATUS — refusing to guess." >&2 + exit 1 + ;; +esac diff --git a/scripts/release_notes.sh b/scripts/release_notes.sh new file mode 100755 index 00000000..e16d048b --- /dev/null +++ b/scripts/release_notes.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Prints the release notes for VERSION from CHANGELOG.md. A beta without its own +# section falls back to [Unreleased], under a line saying what a beta is. +# +# scripts/release_notes.sh 2.22.4 > release-notes.md +set -euo pipefail +cd "$(dirname "$0")/.." # repo root + +VERSION=${1:?usage: release_notes.sh VERSION} + +section() { + awk -v ver="$1" ' + $0 ~ "^## \\[" ver "\\]" { flag = 1; next } + /^## \[/ { flag = 0 } + flag' CHANGELOG.md | sed -e '/./,$!d' +} + +NOTES=$(section "$VERSION") +if [[ "$VERSION" == *-beta.* ]]; then + [ -n "$NOTES" ] || NOTES=$(section Unreleased) + printf '%s\n\n' "**Beta of ${VERSION%%-*}, for testing.** It updates in place to later betas and to the stable release. Obtainium only offers betas with *Include prereleases* switched on; F-Droid and Play never get them." +fi +if [ -n "$NOTES" ]; then + printf '%s\n' "$NOTES" +else + echo "_No changelog entry for ${VERSION} — see CHANGELOG.md._" +fi diff --git a/scripts/sync_changelog_to_fastlane.sh b/scripts/sync_changelog_to_fastlane.sh index f4469659..a9888556 100755 --- a/scripts/sync_changelog_to_fastlane.sh +++ b/scripts/sync_changelog_to_fastlane.sh @@ -19,11 +19,15 @@ cd "$(dirname "$0")/.." # repo root LIMIT=500 -VERSION=$(grep -oP 'versionName\s*=\s*"\K[^"]+' app/build.gradle.kts) -[ -n "$VERSION" ] || { echo "No versionName in app/build.gradle.kts" >&2; exit 1; } -MAJOR=${VERSION%%.*}; rest=${VERSION#*.}; MINOR=${rest%%.*}; PATCH=${rest##*.} -MAJOR=${MAJOR:-0}; MINOR=${MINOR:-0}; PATCH=${PATCH:-0} -VERSION_CODE=$(( MAJOR * 10000 + MINOR * 100 + PATCH )) +VERSION=$(bash scripts/version_info.sh version) +VERSION_CODE=$(bash scripts/version_info.sh version_code) + +# Betas only ship to Codeberg, whose notes come from CHANGELOG.md. A What's New +# file for one would sit in the tree F-Droid and Play read, so none is wanted. +if [ "$(bash scripts/version_info.sh channel)" = beta ]; then + echo "Beta $VERSION: no store What's New (betas only ship to Codeberg)." + exit 0 +fi CL_DIR="fastlane/metadata/android/en-US/changelogs" mkdir -p "$CL_DIR" diff --git a/scripts/version_info.sh b/scripts/version_info.sh new file mode 100755 index 00000000..cfd4d4f9 --- /dev/null +++ b/scripts/version_info.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# Derives versionCode, channel and pre-release flag from the committed versionName. +# +# X.Y.Z (<= 2.22.3) stable X*10000 + Y*100 + Z (legacy) +# X.Y.Z-beta.N beta X*1000000 + Y*10000 + Z*100 + N (N = 1..98) +# X.Y.Z (>= 2.22.4) stable X*1000000 + Y*10000 + Z*100 + 99 +# +# scripts/version_info.sh all values as key=value lines +# scripts/version_info.sh one value +# scripts/version_info.sh --check fail unless the committed versionCode matches +# scripts/version_info.sh --pin rewrite the committed versionCode to the derived one +set -euo pipefail +cd "$(dirname "$0")/.." # repo root + +GRADLE="app/build.gradle.kts" +NAME=$(grep -oP 'versionName\s*=\s*"\K[^"]+' "$GRADLE" || true) +COMMITTED=$(grep -oP 'versionCode\s*=\s*\K[0-9]+' "$GRADLE" || true) +[ -n "$NAME" ] || { echo "No versionName in $GRADLE" >&2; exit 1; } + +if [[ ! "$NAME" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-beta\.([1-9][0-9]*))?$ ]]; then + echo "versionName '$NAME' is neither X.Y.Z nor X.Y.Z-beta.N" >&2 + exit 1 +fi +MAJOR=${BASH_REMATCH[1]}; MINOR=${BASH_REMATCH[2]}; PATCH=${BASH_REMATCH[3]} +BETA=${BASH_REMATCH[5]} +BASE="$MAJOR.$MINOR.$PATCH" + +if [ "$MINOR" -gt 99 ] || [ "$PATCH" -gt 99 ]; then + echo "versionName '$NAME': MINOR and PATCH each have room for 0-99" >&2 + exit 1 +fi + +LEGACY=$(( MAJOR * 10000 + MINOR * 100 + PATCH )) +if [ -n "$BETA" ]; then + if [ "$LEGACY" -lt 22204 ]; then + echo "versionName '$NAME': betas start at 2.22.4; legacy codes have no room for them" >&2 + exit 1 + fi + if [ "$BETA" -lt 1 ] || [ "$BETA" -gt 98 ]; then + echo "versionName '$NAME': beta number must be 1-98 (99 is the stable release)" >&2 + exit 1 + fi + CHANNEL=beta + CODE=$(( MAJOR * 1000000 + MINOR * 10000 + PATCH * 100 + BETA )) +elif [ "$LEGACY" -lt 22204 ]; then + CHANNEL=stable + CODE=$LEGACY +else + CHANNEL=stable + CODE=$(( MAJOR * 1000000 + MINOR * 10000 + PATCH * 100 + 99 )) +fi + +if [ "$CHANNEL" = beta ]; then PRERELEASE=true; else PRERELEASE=false; fi + +case "${1:-}" in + "") + printf 'version=%s\nversion_code=%s\nbase_version=%s\nchannel=%s\nprerelease=%s\n' \ + "$NAME" "$CODE" "$BASE" "$CHANNEL" "$PRERELEASE" + ;; + --check) + if [ "$COMMITTED" != "$CODE" ]; then + echo "ERROR: $GRADLE has versionCode = ${COMMITTED:-}, but versionName '$NAME' needs $CODE." >&2 + echo "Set versionCode = $CODE (see docs/RELEASING.md for the scheme)." >&2 + exit 1 + fi + echo "OK: versionName $NAME -> versionCode $CODE ($CHANNEL)." + ;; + --pin) + sed -i "s/versionCode = .*/versionCode = $CODE/" "$GRADLE" + grep -E 'versionName|versionCode' "$GRADLE" + ;; + version) echo "$NAME" ;; + version_code) echo "$CODE" ;; + base_version) echo "$BASE" ;; + channel) echo "$CHANNEL" ;; + prerelease) echo "$PRERELEASE" ;; + *) echo "Unknown key '$1'" >&2; exit 2 ;; +esac diff --git a/scripts/write_keystore.sh b/scripts/write_keystore.sh new file mode 100755 index 00000000..a736a9b9 --- /dev/null +++ b/scripts/write_keystore.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Writes the app signing keystore and key.properties from CI secrets. +# +# Env: KEYSTORE_BASE64, KEY_PASSWORD, KEY_ALIAS +set -euo pipefail +cd "$(dirname "$0")/.." # repo root +: "${KEYSTORE_BASE64:?}" "${KEY_PASSWORD:?}" "${KEY_ALIAS:?}" + +mkdir -p app +echo "$KEYSTORE_BASE64" | base64 --decode > app/upload-keystore.jks +cat > key.properties <