diff --git a/.forgejo/workflows/ci.yaml b/.forgejo/workflows/ci.yaml index 612590f..6e85308 100644 --- a/.forgejo/workflows/ci.yaml +++ b/.forgejo/workflows/ci.yaml @@ -37,11 +37,29 @@ jobs: - name: Reproducible-release invariant run: bash scripts/check_reproducible_release.sh + # The committed versionName must parse (X.Y.Z or X.Y.Z-beta.N) and the + # committed versionCode must be the one it derives: the official F-Droid + # repo builds the tag as committed. And a beta must never reach main, + # where the release pipeline would ship it to F-Droid and Play; betas are + # cut from release/* branches (docs/RELEASING.md). + - 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 + # Also cheap, also always-on. Two failures in one: the script exits # non-zero if this version's changelog is over the 500-character limit, # and the porcelain check below catches a version with no committed # changelog, which would otherwise ship the CHANGELOG.md section instead - # of a hand-written summary. + # of a hand-written summary. A beta version passes both: it ships no + # What's New, so the script writes nothing for it. - name: Changelog fits the stores, and is committed run: | set -e diff --git a/.gitea/workflows/beta.yaml b/.gitea/workflows/beta.yaml new file mode 100644 index 0000000..9c87e6e --- /dev/null +++ b/.gitea/workflows/beta.yaml @@ -0,0 +1,206 @@ +name: Beta — Codeberg pre-release + +# A beta is cut by pushing a release branch whose committed versionName is +# X.Y.Z-beta.N (see docs/RELEASING.md). Same model as release.yaml: the +# committed version is the trigger and the vX.Y.Z-beta.N tag is an output. If +# no tag exists for that version yet, this runs the unit tests, builds and +# signs the APK with the app key, records a Gitea pre-release (with the R8 +# mapping) and publishes a Codeberg pre-release with the APK + SHA-256. +# +# Deliberately nothing else. A beta never reaches the F-Droid repos or Play: +# Obtainium hides pre-releases unless a user opts in, the official F-Droid +# recipe only picks up `^v[0-9.]+$` tags, and Codeberg's "latest release" +# skips pre-releases. Pushes of a release branch carrying a stable version +# (the usual state) fall through `detect` and do nothing. +# +# Lives in .gitea/workflows next to release.yaml for the same reason: it needs +# the app signing key, which only the self-hosted Gitea instance holds. +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 }} + version_code: ${{ steps.v.outputs.version_code }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Resolve version and whether it is a new beta + id: v + env: + # Codeberg, not Gitea: see the same lookup in release.yaml. + TAG_API: https://codeberg.org/api/v1/repos/jlmakiola/agendula + run: | + set -e + INFO=$(bash scripts/version_info.sh) + echo "$INFO" + echo "$INFO" >> "$GITHUB_OUTPUT" + VERSION=$(echo "$INFO" | sed -n 's/^version=//p') + BASE=$(echo "$INFO" | sed -n 's/^base_version=//p') + if [ "$(echo "$INFO" | sed -n 's/^channel=//p')" != "beta" ]; then + echo "versionName $VERSION is not a beta — nothing to do." + echo "is_beta=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + # Fatal on anything but a clean 200/404, as in release.yaml: guessing + # "no tag" during an outage would re-cut a published beta. + tag_status() { + curl -s -o /dev/null -w '%{http_code}' "$TAG_API/git/refs/tags/$1" || echo 000 + } + # A beta of a version that already shipped stable would carry a lower + # versionCode than the stable one: nobody could install it over it. + case "$(tag_status "v$BASE")" in + 200) + echo "v$BASE already shipped as stable; a beta of it is pointless. Bump the version." >&2 + exit 1 ;; + 404) ;; + *) echo "Codeberg tag lookup for v$BASE failed — refusing to guess." >&2; exit 1 ;; + esac + STATUS=$(tag_status "v$VERSION") + case "$STATUS" in + 200) + echo "Tag v$VERSION already exists on Codeberg — nothing to release." + echo "is_beta=false" >> "$GITHUB_OUTPUT" + ;; + 404) + echo "No tag for v$VERSION on Codeberg yet — cutting the beta." + echo "is_beta=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 published beta." >&2 + exit 1 + ;; + esac + + 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 }} + VERSION_CODE: ${{ needs.detect.outputs.version_code }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: recursive + + - 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 + 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 + + - name: Pin versionCode to versionName + run: | + set -e + sed -i "s/versionCode = .*/versionCode = $VERSION_CODE/" app/build.gradle.kts + grep -E 'versionName|versionCode' app/build.gradle.kts + + - name: Unit tests + run: ./gradlew testDebugUnitTest + + # The real app key, same as a stable release: a beta has to update in + # place to the next beta and to the stable version. + - name: Setup Android Keystore + env: + 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 + 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 + + # The point of the whole workflow, so NOT continue-on-error. + - name: Publish pre-release to Codeberg + env: + TOKEN: ${{ secrets.CODEBERG_RELEASE_TOKEN }} + API: https://codeberg.org/api/v1/repos/jlmakiola/agendula + 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 d6555a6..7ac3c97 100644 --- a/.gitea/workflows/release.yaml +++ b/.gitea/workflows/release.yaml @@ -7,7 +7,9 @@ name: Release — F-Droid repo + Gitea/Codeberg release + Play # the release on 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 can reject a good build for reasons the pipeline @@ -73,23 +75,25 @@ jobs: TAG_API: https://codeberg.org/api/v1/repos/jlmakiola/agendula 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" - # Pre-1.0 is not stable yet: mark the Gitea release as a pre-release - # while MAJOR is 0. Graduates to a stable release automatically at 1.0.0. - if [ "$MAJOR" = "0" ]; then PRERELEASE=true; else PRERELEASE=false; fi - echo "prerelease=$PRERELEASE" >> "$GITHUB_OUTPUT" - echo "Resolved version $VERSION (code $VERSION_CODE, prerelease=$PRERELEASE)" + # versionName -> versionCode, channel and the pre-release flag (set + # while MAJOR was 0) all come from the one script beta.yaml uses too. + 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 + # Betas ship from release/* branches via beta.yaml and must never be + # cut here: this path publishes to F-Droid and Play. CI blocks such a + # PR into main; this is the backstop if one gets through anyway. + if [ "$CHANNEL" != "stable" ]; then + echo "versionName $VERSION on main is a beta. Set the stable version before merging to main." >&2 + exit 1 + fi # A tag for this version already existing means the release shipped on # an earlier push; do nothing. Absent => this merge cuts the release. # @@ -196,8 +200,8 @@ jobs: 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. + # value scripts/version_info.sh derives from it, so the published APK's + # code follows the scheme even if the committed code was forgotten. - name: Pin versionCode to versionName if: env.IS_RELEASE == 'true' run: | @@ -332,6 +336,8 @@ jobs: # Creating it with target_commitish makes Gitea create the vX.Y.Z tag at # this commit, so the tag only ever marks a fully-shipped release (and a # failure before here leaves no tag, so re-running the workflow retries). + # Also attaches the R8 mapping (best-effort) so user crash stacktraces + # stay deobfuscatable. Notes = this version's CHANGELOG section. - name: Create tag + Gitea release if: env.IS_RELEASE == 'true' env: @@ -340,85 +346,17 @@ jobs: SHA: ${{ github.sha }} run: | set -e - TAG="v$VERSION" - # Notes = this version's CHANGELOG section. - awk -v ver="$VERSION" ' - $0 ~ "^## \\[" ver "\\]" { flag = 1; next } - /^## \[/ { flag = 0 } - flag' CHANGELOG.md > 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" "$PRERELEASE" <<'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, - # Pre-1.0 releases are flagged as pre-releases (see detect job). - "prerelease": sys.argv[3] == "true", - })) - 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" NOTES_FILE=release-notes.md \ + MAPPING=app/build/outputs/mapping/release/mapping.txt \ + bash scripts/publish_gitea_release.sh # Publish the release on Codeberg, which is canonical for tags and - # releases (see docs/RELEASING.md). Codeberg push-mirrors branches + tags - # to Gitea, but releases aren't git objects and don't sync in either - # direction — so this step pushes the tag straight to Codeberg and creates - # the release there over the API, attaching the signed APK plus a SHA-256 - # checksum as the direct-download channel for users who don't want - # F-Droid. The APK is identical to the F-Droid one (same app key), so this - # adds no trust surface. Needs the CODEBERG_RELEASE_TOKEN secret; skips - # cleanly if unset. + # releases (see docs/RELEASING.md): push the tag there and attach the + # signed APK plus a SHA-256 checksum as the direct-download channel for + # users who don't want F-Droid. The APK is identical to the F-Droid one + # (same app key), so this adds no trust surface. Needs the + # CODEBERG_RELEASE_TOKEN secret; skips cleanly if unset. - name: Publish release to Codeberg if: env.IS_RELEASE == 'true' # NOT continue-on-error: this step reported green through 0.2.1, 0.2.2, @@ -431,91 +369,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="agendula_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 - # Push the tag to Codeberg ourselves. Under Codeberg-canonical the - # mirror runs Codeberg -> Gitea, so waiting for a tag to arrive here - # from Gitea (what 0.3.2 did) would wait forever. The tag this - # pipeline minted on Gitea is in fact *deleted* by the next mirror - # sync until Codeberg has it — so pushing it here is what makes it - # durable on both forges. - # - # Pushing the ref first and attaching with NO target_commitish is - # deliberate: a release POST carrying a target_commitish for a commit - # or tag Codeberg hasn't received yet is what produced the - # empty-bodied 500s. Attaching to a ref that already exists doesn't - # need the API to write one. - git tag -f "$TAG" "$SHA" - git push -f "https://jlmakiola:${TOKEN}@codeberg.org/jlmakiola/agendula.git" \ - "refs/tags/$TAG" - python3 - "$TAG" "$PRERELEASE" <<'PY' > cb-payload.json - import json, sys - tag, pre = sys.argv[1:3] - print(json.dumps({ - "tag_name": tag, - "name": tag, - "body": open("release-notes.md").read(), - "draft": False, - # Pre-1.0 releases are flagged as pre-releases (see detect job). - "prerelease": pre == "true", - })) - 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 can - # fail even though the very same call succeeds seconds later. Retry - # with backoff, and PATCH in place if a release already exists (re-run - # safe, so re-running never disturbs a published release). 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" 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: a second artifact from the same # source and signing config. Play treats the release key only as the diff --git a/README.md b/README.md index 2f27864..2756ed3 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,8 @@ APK and a `.sha256` checksum attached — the same APK the F-Droid repository serves. For automatic updates from there, use **[Obtainium](https://github.com/ImranR98/Obtainium)** and **[add Agendula in one tap](https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://codeberg.org/jlmakiola/agendula)**. +Betas of upcoming versions are published there too, as pre-releases; to test +them, switch on *Include prereleases* for Agendula in Obtainium. ### Build from source diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b96a84d..578ace0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -26,10 +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. 1.0.0 -> 10000). Releases were flagged as - // pre-releases while MAJOR was 0; 1.0.0 is the first stable one, and the - // pipeline graduates it on its own. 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 + // (1.0.x: 1.0.0 -> 10000; from 1.1.0: 1.1.0-beta.1 -> 1010001, + // 1.1.0 -> 1010099), and CI fails if the committed one doesn't match. + // See docs/RELEASING.md. versionCode = 10000 versionName = "1.0.0" diff --git a/docs/RELEASING.md b/docs/RELEASING.md index eb94a73..7433ea6 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -11,9 +11,12 @@ carries no APK assets. Codeberg is the canonical forge; Gitea is build infrastructure. See [Two forges, one repo](#two-forges-one-repo) for how the two are wired. -While Agendula is pre-1.0 (`versionName` starts with `0.`), every release is -flagged as a **pre-release**. This happens automatically and graduates to a -stable release at `1.0.0` — no manual toggling. +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). + +While Agendula was pre-1.0 (`versionName` started with `0.`), every release was +flagged as a **pre-release** automatically. --- @@ -22,16 +25,30 @@ stable release at `1.0.0` — no manual toggling. A release is defined by the `versionName`/`versionCode` committed in `app/build.gradle.kts` — **not** by a hand-pushed tag: -- `versionName` = `MAJOR.MINOR.PATCH` (e.g. `0.2.0`) -- `versionCode` = `MAJOR*10000 + MINOR*100 + PATCH` (`0.2.0` → `200`, - `1.3.4` → `10304`) +- `versionName` = `MAJOR.MINOR.PATCH` (e.g. `1.1.0`), or + `MAJOR.MINOR.PATCH-beta.N` for a beta (e.g. `1.1.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`, pins `versionCode` to the derived value, builds, publishes, and — -once the APK is live — 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). +| `versionName` | `versionCode` | Example | +| --- | --- | --- | +| `X.Y.Z` below 1.1.0 (legacy) | `X*10000 + Y*100 + Z` | `1.0.1` → `10001` | +| `X.Y.Z-beta.N` (N = 1–98) | `X*1000000 + Y*10000 + Z*100 + N` | `1.1.0-beta.2` → `1010002` | +| `X.Y.Z` from 1.1.0 | `X*1000000 + Y*10000 + Z*100 + 99` | `1.1.0` → `1010099` | + +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 1.0.x keeps the code it already has, and `1.1.0-beta.1` +(`1010001`) is above all of them. `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, publishes, and — once the APK is live — 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). --- @@ -44,8 +61,8 @@ re-running the workflow safely retries). `## [X.Y.Z]` heading (Keep a Changelog format). The text between that heading and the next `## [` becomes the Gitea and Codeberg release notes. The heading **must** match the version exactly. -3. **Bump the committed `versionName`** (and `versionCode`) in - `app/build.gradle.kts`. **This bump is what triggers the release** when the +3. **Bump the committed `versionName`** (and `versionCode`, which + `scripts/version_info.sh` prints) in `app/build.gradle.kts`. **This bump is what triggers the release** when the branch merges to `main`. Then write this version's **"What's New"** by hand: `fastlane/metadata/android//changelogs/.txt`, one per @@ -79,6 +96,46 @@ re-running the workflow safely retries). --- +## 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; `1.1.0-beta.1` → `1010001`). + 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]`. +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 version without a tag, 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`. +4. **Next round:** bump to `-beta.2` (and its `versionCode`) and push again. +5. **Going stable:** set `versionName = "X.Y.Z"` and its `versionCode` + (`1.1.0` → `1010099`), 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. +- **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 of a version +that already shipped stable, and betas start at 1.1.0 (the 1.0.x 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 @@ -112,6 +169,12 @@ release work when a merge actually cuts a release: **Codeberg** with the signed APK + a SHA-256 checksum, and finally builds the App Bundle. Ordinary merges with no version bump fall through `detect` and do nothing. +- **`beta.yaml`** (`.gitea/workflows/`, on push to `release/**`, **Gitea**) — + the same `detect` idea for betas: only when the committed `versionName` is + `X.Y.Z-beta.N` and Codeberg has no tag for it does the `beta` job run: unit + tests, pin `versionCode`, build & sign the release APK with the **app key**, + create the tag + a Gitea pre-release with the R8 mapping, and publish the + **Codeberg pre-release** with the APK + SHA-256. No F-Droid, no Play. - **`play` job** (same workflow, after `release`) — uploads the App Bundle and every locale's "What's New" to Google Play. Runs last and separately so a Play rejection can't endanger a release that already shipped; skips cleanly until @@ -125,7 +188,8 @@ Releases aren't git objects and don't sync with the push mirror in either direction, so the pipeline pushes the `vX.Y.Z` tag straight to Codeberg, creates the release over the Codeberg API, and attaches `agendula_v.apk` + its `.sha256`. It's the same APK the F-Droid repo serves (same **app key**), so it -adds no trust surface. It skips cleanly if `CODEBERG_RELEASE_TOKEN` is unset, +adds no trust surface. The logic lives in `scripts/publish_codeberg_release.sh`, +which `beta.yaml` uses too (with the pre-release flag set). It skips cleanly if `CODEBERG_RELEASE_TOKEN` is unset, but it is **not** `continue-on-error`: through 0.2.1–0.3.2 this step reported green while never once publishing, which is how a crash-fix release reached F-Droid but not the Codeberg/Obtainium users who needed it. A broken mirror diff --git a/docs/fdroid-official/README.md b/docs/fdroid-official/README.md index 06e56ee..e3dc61b 100644 --- a/docs/fdroid-official/README.md +++ b/docs/fdroid-official/README.md @@ -42,7 +42,9 @@ on 2026-09-24. Its CI rebuilt v1.0.0 and verified it against our published APK The file here is a copy of the submitted recipe. fdroiddata's copy is the one that counts; after the merge F-Droid picks up new `vX.Y.Z` tags on its own -(`AutoUpdateMode`), so there is no per-release work there. Only a change to the +(`AutoUpdateMode`), so there is no per-release work there. Beta tags +(`vX.Y.Z-beta.N`) don't match `UpdateCheckMode: Tags ^v[0-9.]+$`, so betas stay +off the official repo; keep that pattern if the recipe ever changes. Only a change to the recipe itself (a new submodule, a build flag) needs an MR, pinned to a full commit hash, never a tag. diff --git a/scripts/check_store_listing.py b/scripts/check_store_listing.py index 907a199..75de010 100755 --- a/scripts/check_store_listing.py +++ b/scripts/check_store_listing.py @@ -16,6 +16,7 @@ without setup. """ import re import struct +import subprocess import sys from pathlib import Path @@ -120,10 +121,11 @@ def check_images(locale_dir): def version_code(): - gradle = (ROOT / "app/build.gradle.kts").read_text() - name = re.search(r'versionName\s*=\s*"([^"]+)"', gradle).group(1) - parts = (name.split(".") + ["0", "0"])[:3] - return int(parts[0]) * 10000 + int(parts[1]) * 100 + int(parts[2]) + """The committed version's code, or None for a beta (it ships no What's New).""" + def info(key): + return subprocess.run(["bash", str(ROOT / "scripts/version_info.sh"), key], + check=True, capture_output=True, text=True).stdout.strip() + return None if info("channel") == "beta" else int(info("version_code")) def shipped_languages(): @@ -183,7 +185,7 @@ def main(): err(images / "featureGraphic.png", "required for Play (1024x500, no alpha)") if len(list((images / "phoneScreenshots").glob("*"))) < 2: err(images / "phoneScreenshots", "Play requires at least 2 phone screenshots") - for loc in REQUIRED_LOCALES: + for loc in REQUIRED_LOCALES if current is not None else []: if not (META / loc / "changelogs" / f"{current}.txt").exists(): err(META / loc / "changelogs" / f"{current}.txt", "no What's New for this version") diff --git a/scripts/publish_codeberg_release.sh b/scripts/publish_codeberg_release.sh new file mode 100755 index 0000000..2154102 --- /dev/null +++ b/scripts/publish_codeberg_release.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# Publish TAG on Codeberg, which is canonical for tags and releases, with the +# signed APK and its SHA-256 checksum attached: the direct-download channel +# that Obtainium follows. Shared by .gitea/workflows/release.yaml (stable) and +# beta.yaml (pre-release). Run from the repo checkout: it pushes the tag. +# +# Codeberg push-mirrors branches and tags to Gitea, but releases aren't git +# objects and don't sync in either direction, so this pushes the tag straight +# to Codeberg and creates the release over the API. Skips cleanly when TOKEN is +# unset; fails loudly on anything else (see release.yaml for why). +# +# Env: +# TOKEN CODEBERG_RELEASE_TOKEN (write:repository); empty = skip +# API https://codeberg.org/api/v1/repos// +# TAG, SHA vX.Y.Z[-beta.N] and the commit it marks +# PRERELEASE true | false +# NOTES_FILE release notes (scripts/release_notes.sh) +# APK the signed release APK +set -euo pipefail +if [ -z "${TOKEN:-}" ]; then + 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="agendula_${TAG}.apk" +ASSET_SUM="${ASSET_APK}.sha256" +cp "$APK" "$WORK/$ASSET_APK" +( cd "$WORK" && sha256sum "$ASSET_APK" > "$ASSET_SUM" ) + +# Push the tag to Codeberg ourselves. Under Codeberg-canonical the mirror runs +# Codeberg -> Gitea, so waiting for a tag to arrive from Gitea (what 0.3.2 did) +# would wait forever. The tag minted on Gitea is in fact *deleted* by the next +# mirror sync until Codeberg has it, so pushing it here is what makes it +# durable on both forges. +# +# Pushing the ref first and creating the release with NO target_commitish is +# deliberate: a release POST carrying a target_commitish for a commit or tag +# Codeberg hasn't received yet is what produced the empty-bodied 500s. +# Attaching to a ref that already exists doesn't need the API to write one. +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 + +# 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 +# pushed a moment ago), so a single attempt can fail 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 @"$WORK/payload.json" "$API/releases/$EXIST" + ID="$EXIST"; break + fi + 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 "release POST attempt $attempt HTTP $CODE" + ID=$(jq -r '.id // empty' "$WORK/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=@$WORK/$A" \ + "$API/releases/$ID/assets?name=$A" -o /dev/null -w "asset $A HTTP %{http_code}\n" +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 0000000..c5e948f --- /dev/null +++ b/scripts/publish_gitea_release.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# Create (or update) the release for TAG on the Gitea build instance, the +# changelog of record there, and attach the R8 mapping. Shared by +# .gitea/workflows/release.yaml and beta.yaml. +# +# Creating it with target_commitish makes Gitea create the tag at SHA, so the +# tag only ever marks a fully-shipped version. Upsert, so re-runs are safe. +# +# Env: +# TOKEN, API Gitea token and .../api/v1/repos// +# TAG, SHA vX.Y.Z[-beta.N] and the commit it marks +# PRERELEASE true | false +# NOTES_FILE release notes (scripts/release_notes.sh) +# MAPPING optional path to R8's mapping.txt +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 + +# PATCH if a release for the tag already exists, else POST a new one (which +# also creates the tag at target_commitish). +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" + +# The R8 mapping keeps user crash stacktraces deobfuscatable. It's not an APK, +# so it fits the no-binaries rule for this release. Best-effort: never fail a +# release over it. +attach_mapping() { + local asset="mapping-${TAG#v}.txt.gz" old + gzip -c "$MAPPING" > "$WORK/$asset" + # 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') + 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_notes.sh b/scripts/release_notes.sh new file mode 100755 index 0000000..b057fb8 --- /dev/null +++ b/scripts/release_notes.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Print the forge release notes for VERSION: its `## [VERSION]` section of +# CHANGELOG.md. A beta (X.Y.Z-beta.N) normally has no section of its own, so it +# falls back to `## [Unreleased]`, i.e. what the release branch carries so far, +# under a line saying what a beta is. +# +# scripts/release_notes.sh 1.1.0 > 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 d4a8190..8c294c8 100755 --- a/scripts/sync_changelog_to_fastlane.sh +++ b/scripts/sync_changelog_to_fastlane.sh @@ -11,11 +11,15 @@ set -euo pipefail cd "$(dirname "$0")/.." # repo root -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 # Play rejects a longer "What's New" and F-Droid truncates it in-client. MAX_CHARS=${MAX_CHARS:-500} diff --git a/scripts/version_info.sh b/scripts/version_info.sh new file mode 100755 index 0000000..54858ea --- /dev/null +++ b/scripts/version_info.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# The one place that turns the committed versionName into a versionCode and a +# release channel. The release and beta pipelines, the changelog sync and the +# store-listing check all read it from here, so the scheme can't drift between +# them. +# +# versionName channel versionCode +# X.Y.Z (< 1.1.0) 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 (>= 1.1.0) stable X*1000000 + Y*10000 + Z*100 + 99 +# +# 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 1.0.x keeps the legacy code it already shipped with; +# the first new-scheme code (1.1.0-beta.1 -> 1010001) is above all of them. +# +# scripts/version_info.sh print version, version_code, base_version, +# channel and prerelease as key=value lines +# (ready for $GITHUB_OUTPUT) +# scripts/version_info.sh print just that value +# scripts/version_info.sh --check also fail unless the committed versionCode +# matches; the official F-Droid repo builds +# the tag as committed, so it must +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-9]+)\.([0-9]+)\.([0-9]+)(-beta\.([0-9]+))?$ ]]; then + echo "versionName '$NAME' is neither X.Y.Z nor X.Y.Z-beta.N" >&2 + exit 1 +fi +MAJOR=$((10#${BASH_REMATCH[1]})); MINOR=$((10#${BASH_REMATCH[2]})); PATCH=$((10#${BASH_REMATCH[3]})) +BETA=${BASH_REMATCH[5]:+$((10#${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 10100 ]; then + echo "versionName '$NAME': betas start at 1.1.0; 1.0.x 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 10100 ]; then + CHANNEL=stable + CODE=$LEGACY +else + CHANNEL=stable + CODE=$(( MAJOR * 1000000 + MINOR * 10000 + PATCH * 100 + 99 )) +fi + +# Betas are always pre-releases; so was everything before 1.0.0. +if [ "$CHANNEL" = beta ] || [ "$MAJOR" = 0 ]; 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)." + ;; + 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