diff --git a/.forgejo/workflows/ci.yaml b/.forgejo/workflows/ci.yaml index fe0c5fa..a6a47ad 100644 --- a/.forgejo/workflows/ci.yaml +++ b/.forgejo/workflows/ci.yaml @@ -37,6 +37,47 @@ 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. 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 + bash scripts/sync_changelog_to_fastlane.sh + DIRTY=$(git status --porcelain fastlane/metadata/android/en-US/changelogs) + if [ -n "$DIRTY" ]; then + echo "$DIRTY" + echo "ERROR: no committed What's New for this version." >&2 + echo "Write fastlane/metadata/android//changelogs/.txt" >&2 + echo "(under 500 chars, every shipped locale) and commit it." >&2 + exit 1 + fi + + # The fastlane tree feeds F-Droid and Play alike; Play rejects oversized + # text and off-spec graphics at upload time, so catch that on the PR. + - name: Store listing fits both stores + run: python3 scripts/check_store_listing.py + # Decide whether anything that affects the app build changed. Docs, store # metadata, licence texts and forge housekeeping don't, so those PRs skip # the SDK + Gradle work below but still report a green `ci`. @@ -47,7 +88,7 @@ jobs: # about defaults to building. Only paths the Gradle build provably # never reads belong here — note that the workflows themselves, the # `.gitmodules` submodule pointer and `scripts/` are *not* in it. - SKIP_RE: '(\.md$|^docs/|^fastlane/|^fdroid-metadata/|^licenses/|^\.(forgejo|gitea)/ISSUE_TEMPLATE/|^\.editorconfig$|^\.gitattributes$|^\.gitignore$|^LICENSE$)' + SKIP_RE: '(\.md$|^docs/|^fastlane/|^fdroid-metadata/|^Gemfile$|^licenses/|^\.(forgejo|gitea)/ISSUE_TEMPLATE/|^\.editorconfig$|^\.gitattributes$|^\.gitignore$|^LICENSE$)' run: | set -e BASE="${{ github.base_ref }}" diff --git a/.gitea/workflows/beta.yaml b/.gitea/workflows/beta.yaml new file mode 100644 index 0000000..48f59d3 --- /dev/null +++ b/.gitea/workflows/beta.yaml @@ -0,0 +1,208 @@ +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/clockula + 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. + # /tags/{tag}, not /git/refs/tags/{tag}: the refs endpoint matches by + # prefix, so v1.0.0 would "exist" as soon as v1.0.0-beta.1 did. + tag_status() { + curl -s -o /dev/null -w '%{http_code}' "$TAG_API/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/clockula + 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 3efe65b..a90dcd4 100644 --- a/.gitea/workflows/release.yaml +++ b/.gitea/workflows/release.yaml @@ -7,14 +7,14 @@ 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 is the only channel that can reject a good build for -# reasons the pipeline can't see, and that must not endanger a release which has -# already shipped to F-Droid and Codeberg. It skips cleanly until the -# PLAY_SERVICE_ACCOUNT_JSON secret exists — so it stays dormant through the -# whole pre-1.0 run, which is the correct behaviour anyway. +# and separate because Play can reject a good build for reasons the pipeline +# can't see, and that must not endanger a release which already shipped to +# F-Droid and Codeberg. It skips cleanly until PLAY_SERVICE_ACCOUNT_JSON exists. # # This file lives in .gitea/workflows on purpose: Codeberg is canonical for git, # issues, PRs and releases, but every secret (app key, F-Droid repo key, Hetzner @@ -75,23 +75,25 @@ jobs: TAG_API: https://codeberg.org/api/v1/repos/jlmakiola/clockula 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. # @@ -100,7 +102,11 @@ jobs: # read as absent and re-cut a release that has already shipped — # republishing to F-Droid. 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) + # + # /tags/{tag}, not /git/refs/tags/{tag}: the refs endpoint matches by + # prefix, so v1.0.0 would "exist" once v1.0.0-beta.1 does and the + # stable release would never be cut. + STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$TAG_API/tags/v$VERSION" || echo 000) case "$STATUS" in 200) echo "Tag v$VERSION already exists on Codeberg — nothing to release." @@ -117,6 +123,16 @@ jobs: ;; esac + # Before a single Gradle task runs: F-Droid truncates the in-client + # changelog, so an over-long one would reach users cut off mid-sentence. + # The script exits non-zero past the limit. Cheap enough to sit in the + # gate job, where failing costs nothing and publishes nothing — the step + # further down that regenerates the file for the repo would otherwise be + # the first thing to notice, after the build and the signing. + - name: Changelog fits the stores + if: steps.v.outputs.is_release == 'true' + run: bash scripts/sync_changelog_to_fastlane.sh + # 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 # re-uploads the existing index (recovery path). @@ -188,8 +204,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: | @@ -324,6 +340,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: @@ -332,217 +350,52 @@ 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, and inherited that way deliberately: in - # Agendula this step reported green through five consecutive releases - # (0.2.1 through 0.3.2) 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 must fail the release loudly. + # NOT continue-on-error: this step reported green through 0.2.1, 0.2.2, + # 0.3.0, 0.3.1 and 0.3.2 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 must fail the release loudly. env: TOKEN: ${{ secrets.CODEBERG_RELEASE_TOKEN }} API: https://codeberg.org/api/v1/repos/jlmakiola/clockula 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="clockula_v${VERSION}.apk" - ASSET_SUM="${ASSET_APK}.sha256" - cp "$APK" "/tmp/$ASSET_APK" - ( cd /tmp && sha256sum "$ASSET_APK" > "$ASSET_SUM" ) + [ -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 - # 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 Agendula 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/clockula.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." - - # 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 - # APK. The release key signs it, but Play only ever treats that key as the - # *upload* key: Play App Signing re-signs with Google's own key before - # delivery. A Play install and an F-Droid install therefore carry - # different signatures and cannot update each other. That divergence is a - # deliberate, documented choice (docs/RELEASING.md), not an accident. + # 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 + # upload key and re-signs with its own (Play App Signing), so Play and + # F-Droid installs carry different signatures and can't update each other. # - # Built LAST and `continue-on-error`, both deliberately: everything above - # has already shipped by this point, and nothing Play-related may put that - # at risk. Sitting mid-job without continue-on-error, this block took the - # whole Calendula 2.17.0 release down with it — no F-Droid publish, no tag, no - # Codeberg mirror — over an artifact upload. A failure here now costs the - # Play upload and nothing else. - # - # Nothing here touches the F-Droid path: the AAB is never copied into the - # repo, never attached to a release, and its build cannot change the APK - # published above. - # - # AGP embeds the R8 mapping in the bundle's BUNDLE-METADATA, so Play gets - # deobfuscated stacktraces without a separate mapping upload. + # Built last and continue-on-error: everything above has already shipped, + # and nothing Play-related may take it down. The AAB never touches the + # F-Droid repo or the releases. AGP embeds the R8 mapping in the bundle, + # so Play gets deobfuscated stacktraces without a separate upload. - name: Build release AAB if: env.IS_RELEASE == 'true' continue-on-error: true run: ./gradlew bundleRelease - # NOT actions/upload-artifact@v4: it runs @actions/artifact v2, which - # refuses to start whenever GITHUB_SERVER_URL is not github.com — it reads - # any other forge as an unsupported GHES instance and fails before it ever - # talks to the server (go-gitea/gitea#36024). Gitea 1.25 serves the v4 - # artifact API fine; only the client-side check is wrong. This fork is that - # client with the check removed. Pinned to a commit, not the v4 branch: a - # third-party action in the signing pipeline must not change under us. + # NOT actions/upload-artifact@v4: its client refuses any non-github.com + # server as unsupported GHES (go-gitea/gitea#36024). This fork drops that + # check. Pinned to a commit — a third-party action in the signing + # pipeline must not change under us. - name: Hand the AAB to the Play job if: env.IS_RELEASE == 'true' continue-on-error: true @@ -553,46 +406,34 @@ jobs: if-no-files-found: error retention-days: 14 - # Google Play channel. + # Google Play channel. A separate job after the F-Droid publish and both forge + # releases, so a Play rejection (policy review, API outage, listing rules) + # shows up as one red job next to a release that already shipped. # - # A separate job, on purpose, running only AFTER the F-Droid publish and both - # forge releases have completed. Play is the one channel that can reject a - # perfectly good build for reasons outside the pipeline (listing rules, policy - # review, API outage, a track that needs manual promotion). Isolating it means - # such a rejection surfaces as one red job next to a release that already - # shipped everywhere else, instead of failing the workflow that publishes it. - # - # Not a `container:` job even though a fastlane image exists: act_runner does - # not provide node inside custom job containers, so JavaScript actions - # (checkout, download-artifact) can't run there. The Renovate job gets away - # with a container because its only step is a shell command. Ruby is installed - # the same way sshpass, jq and fdroidserver are in the job above. + # Not a `container:` job: act_runner provides no node inside custom job + # containers, so JavaScript actions (checkout, download-artifact) can't run. play: needs: [detect, release] - # workflow_dispatch is the F-Droid re-sign recovery path — it must never - # touch Play, so gate on a real release only. + # workflow_dispatch is the F-Droid re-sign recovery path; never touch Play. if: needs.detect.outputs.is_release == 'true' runs-on: docker env: VERSION: ${{ needs.detect.outputs.version }} VERSION_CODE: ${{ needs.detect.outputs.version_code }} - # Where the bundle lands. `production` — the release itself is already the - # gate (a bumped versionName only reaches main after on-device review), so - # a second manual promotion in the Play Console bought nothing but delay. - # Override with the PLAY_TRACK repo variable to stage a release instead. + # The release itself is the gate (a bumped versionName only reaches main + # after on-device review), so it goes straight to production. Override + # with repo variables to stage instead. PLAY_TRACK: ${{ vars.PLAY_TRACK || 'production' }} PLAY_RELEASE_STATUS: ${{ vars.PLAY_RELEASE_STATUS || 'completed' }} - # Set PLAY_DRY_RUN=true to validate the edit against the API and discard - # it instead of committing — used to rehearse the first upload. + # true validates the edit against the API and discards it. PLAY_DRY_RUN: ${{ vars.PLAY_DRY_RUN || 'false' }} BUNDLE_PATH: vendor/bundle steps: - name: Checkout uses: actions/checkout@v4 - # Skip cleanly (not fatally) when Play isn't configured yet, so the rest - # of the release pipeline keeps working during setup — same contract as - # the Codeberg mirror step. + # Skip cleanly when Play isn't configured yet, same contract as the + # Codeberg publish. - name: Write the Play service-account key id: key env: @@ -605,14 +446,11 @@ jobs: exit 0 fi printf '%s' "$PLAY_SERVICE_ACCOUNT_JSON" > play-service-account.json - # Fail here, with a clear message, rather than inside fastlane: a - # mangled multi-line secret is the likeliest setup mistake. python3 -c "import json,sys; d=json.load(open('play-service-account.json')); sys.exit(0 if d.get('type')=='service_account' else 1)" \ || { echo "PLAY_SERVICE_ACCOUNT_JSON is not a valid service-account JSON." >&2; exit 1; } echo "configured=true" >> "$GITHUB_OUTPUT" - # Same GHES-detection problem as the upload side, same fix — see the - # handoff step in the release job. + # Same GHES-detection fix as the upload side. - name: Download the AAB if: steps.key.outputs.configured == 'true' uses: https://github.com/ChristopherHX/gitea-download-artifact@75635f32b4c1c41c4b3d64e8f85210112ed4c9c7 # v4 @@ -627,12 +465,10 @@ jobs: SUDO="" if command -v sudo >/dev/null 2>&1; then SUDO="sudo"; fi $SUDO apt-get update - # ruby-dev + build-essential: several of fastlane's dependencies build - # native extensions. + # Several fastlane dependencies build native extensions. $SUDO apt-get install -y ruby-full ruby-dev build-essential ruby -v - # Only the first release pays the full gem build; afterwards this restores. - name: Cache bundled gems if: steps.key.outputs.configured == 'true' uses: actions/cache@v4 @@ -655,15 +491,12 @@ jobs: if: steps.key.outputs.configured == 'true' env: SUPPLY_JSON_KEY: play-service-account.json - # supply is chatty on a TTY-less runner otherwise. FASTLANE_SKIP_UPDATE_CHECK: '1' FASTLANE_HIDE_CHANGELOG: '1' run: | set -euo pipefail + # Absolute: a lane body runs from fastlane/, not the workspace root. AAB="$GITHUB_WORKSPACE/dist/app-release.aab" - # Absolute, because a lane body runs from fastlane/, not the - # workspace root — a relative path resolves against the wrong - # directory there and Calendula 2.17.1 died on exactly that. test -f "$AAB" || { echo "No AAB at $AAB — the artifact handoff failed." >&2; ls -la dist || true; exit 1; } bundle exec fastlane deploy \ aab:"$AAB" \ @@ -672,8 +505,8 @@ jobs: dry_run:"$PLAY_DRY_RUN" echo "Uploaded $VERSION (code $VERSION_CODE) to the '$PLAY_TRACK' track." - # The workspace is reused between runs on a self-hosted runner, so the - # credential must not outlive the job. + # The workspace is reused on a self-hosted runner; the key must not + # outlive the job. - name: Shred the service-account key if: always() run: shred -u play-service-account.json 2>/dev/null || rm -f play-service-account.json diff --git a/.gitea/workflows/renovate.yml b/.gitea/workflows/renovate.yml index e6aaa21..9016c54 100644 --- a/.gitea/workflows/renovate.yml +++ b/.gitea/workflows/renovate.yml @@ -1,8 +1,17 @@ name: Renovate on: - # Weekly sweep. Mondays 05:00 UTC — this cron owns the cadence; the repo's - # renovate.json5 deliberately has no internal schedule (avoids double-gating). + # Every merge to main. Mirror syncs from Codeberg fire push events here (the + # same trigger release.yaml relies on), so a merged Renovate PR is followed + # within minutes by a run that rebases the sibling PRs it just conflicted — + # most bumps touch gradle/libs.versions.toml. Outside renovate.json5's + # `schedule` window such a run only maintains existing branches + # (updateNotScheduled), it never opens new PRs. Renovate's own rebases push + # to renovate/* branches, not main, so this cannot loop. + push: + branches: [main] + # Weekly sweep for new updates. Mondays 05:00 UTC, inside the schedule window + # in renovate.json5 — keep the two in step. schedule: - cron: '0 5 * * 1' # Manual run for an on-demand sweep from the Actions tab. diff --git a/Gemfile b/Gemfile index e12874e..2e792a2 100644 --- a/Gemfile +++ b/Gemfile @@ -1,10 +1,6 @@ source "https://rubygems.org" -# fastlane is used ONLY to upload the release bundle to Google Play -# (see fastlane/Fastfile). It is not part of the build or the signing path, so -# it never runs on a PR — only in release.yaml's `play` job. -# -# Pinned exactly; Renovate's bundler manager keeps it bumped. No Gemfile.lock is -# committed on purpose: this resolves an uploader's transitive deps, not the -# app's, and none of it affects the reproducible release build. +# fastlane is used ONLY as the Google Play Developer API client (see +# fastlane/Fastfile). It never builds and never signs. Pinned exactly, no +# Gemfile.lock: it resolves an uploader's deps, not the app's. gem "fastlane", "2.237.0" diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 491ed21..6ba49cd 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -110,6 +110,12 @@ android { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" } + // Ship the prebuilt .so files (datastore's shared counter) exactly as the + // AAR has them. AGP strips them only when an NDK happens to be installed, + // so CI and F-Droid's buildserver would otherwise disagree on the bytes. + jniLibs { + keepDebugSymbols += "**/*.so" + } } lint { diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 39aae71..5d76112 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -1,37 +1,51 @@ # Clockula — releasing -Clockula is distributed through a **self-hosted F-Droid repo** (on Hetzner) and a -**Codeberg release** per version carrying the signed APK as a direct download. -Both are produced automatically by `.gitea/workflows/release.yaml` when a -**bumped `versionName` reaches `main`** — the pipeline builds and publishes that +Clockula is distributed through a self-hosted F-Droid repo (on Hetzner), a +Codeberg release per version carrying the signed APK as a direct download, +and Google Play. All three are produced automatically by `.gitea/workflows/release.yaml` when a +**bumped `versionName` reaches `main`**. The pipeline builds and publishes that version, then creates the matching `vX.Y.Z` tag and the releases itself. The -parallel **Gitea release** is the changelog of record on the build instance and +parallel Gitea release is the changelog of record on the build instance and 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 Clockula 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). + +Clockula's first public build is `1.0.0-beta.1`; there was never a `0.x` release. --- ## The source of truth: the committed version A release is defined by the `versionName`/`versionCode` committed in -`app/build.gradle.kts` — **not** by a hand-pushed tag: +`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.0.0`), or + `MAJOR.MINOR.PATCH-beta.N` for a beta (e.g. `1.0.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-beta.N` (N = 1–98) | `X*1000000 + Y*10000 + Z*100 + N` | `1.0.0-beta.2` → `1000002` | +| `X.Y.Z` | `X*1000000 + Y*10000 + Z*100 + 99` | `1.0.0` → `1000099` | + +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. `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). --- @@ -39,66 +53,118 @@ re-running the workflow safely retries). 1. **Assemble the release branch.** Create `release/vX.Y.Z` and merge the feature/fix branches for this release into it. Everything below happens on - that branch, before it reaches `main`. + that branch, 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. **Update `CHANGELOG.md`.** Move the `## [Unreleased]` items 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 "What's New". 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 - branch merges to `main`. Then run - ```sh - scripts/sync_changelog_to_fastlane.sh - ``` - and commit the generated - `fastlane/metadata/android/en-US/changelogs/.txt` — this is what - makes the **official** F-Droid listing (which harvests the changelog from the - tagged source tree) show this version. The self-hosted pipeline regenerates it - regardless, so forgetting only affects the official listing. -4. **Verify the release build on a real device** — the mandatory gate: + and the next `## [` becomes the Gitea and Codeberg release notes. The + heading **must** match the version exactly. +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` (e.g. + `1000099.txt` for 1.0.0), one per + shipped locale, each a short summary **under 500 characters**. F-Droid and + Play both publish these files, and Play rejects a longer one. It is not a + copy of the CHANGELOG.md section. `scripts/sync_changelog_to_fastlane.sh` + keeps a committed `en-US` file as it is and only generates one from + CHANGELOG.md when it is missing; CI fails a PR whose version has no + committed `en-US` changelog. +4. **Verify the release build on a real device.** This is the mandatory gate: ```sh scripts/verify-release.sh ``` It builds the `releaseTest` variant (same R8 config as `release`, debug-signed with a `.releasetest` suffix so it installs alongside the real app) and resets it to a first-run state. Then, on the device: - - launch from a **clean / permission-not-granted** state — the permission - screen must appear, no crash; - - grant tasks access — the task list must load; - - create a task with a due reminder and confirm the notification fires; + - launch from a **clean / permission-not-granted** state: notification + permission is asked for, no crash; + - set an alarm a minute out and lock the device: it must ring over the lock + screen with sound and vibration, and snooze and dismiss must both work; + - reboot with that alarm armed: it must survive and still fire; + - start a timer and the stopwatch, background the app: both keep time and + stay controllable from the notification shade; - exercise this release's headline changes. - Only proceed once all of that passes on-device. -5. **Merge `release/vX.Y.Z` into `main`.** That's it — no manual tagging. The - merge triggers `release.yaml`, which detects the new version, builds, signs, - publishes to F-Droid, and creates the `vX.Y.Z` tag + Gitea release. + The script prints the same list. -> The `releaseTest` build type exists only for step 4 — it is never published. + Only proceed once all of that passes on-device. +5. **Merge `release/vX.Y.Z` into `main`.** There is no manual tagging. The + merge triggers `release.yaml`, which detects the new version, builds, signs, + publishes to F-Droid, creates the `vX.Y.Z` tag + releases, and uploads the + App Bundle to Play. + +> The `releaseTest` build type exists only for step 4 and 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; `1.0.0-beta.1` → `1000001`). + No What's New file, since 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 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.0.0` → `1000099`), 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. + +--- + ## What the pipeline does CI and release are split so a change is built once on its PR and only does release work when a merge actually cuts a release: -- **`ci.yaml`** (`.forgejo/workflows/`, on `pull_request`, **Codeberg**) — the +- **`ci.yaml`** (`.forgejo/workflows/`, on `pull_request`, **Codeberg**): the reproducible-release invariant guard (`scripts/check_reproducible_release.sh`), then lint + unit tests + a debug assemble and a Trivy scan, once per PR. Docs/metadata-only PRs skip the Android build but still report a green `CI` check. -- **`translations.yaml`** (`.forgejo/workflows/`, on `pull_request`, **Codeberg**) - — an SDK-free parity check (`scripts/check_translations.py`) over +- **`translations.yaml`** (`.forgejo/workflows/`, on `pull_request`, **Codeberg**): + an SDK-free parity check (`scripts/check_translations.py`) over `values-*/strings.xml`, so Weblate PRs get fast feedback. Runs on every PR without a path filter, so the required `Translations / check` status is always reported. -- **`renovate.yml`** (`.gitea/workflows/`, Mondays 05:00 UTC plus - `workflow_dispatch`, **Gitea**) — the dependency sweep. Runs the pinned - Renovate image as its job container and targets Codeberg's API; the cadence - lives here, not in `renovate.json5`, so the two don't double-gate. +- **`renovate.yml`** (`.gitea/workflows/`, Mondays 05:00 UTC, on push to + `main`, plus `workflow_dispatch`, **Gitea**): the dependency sweep. Runs the + pinned Renovate image as its job container and targets Codeberg's API. New + PRs only open in the Monday `schedule` window in `renovate.json5` (keep it + bracketing the cron); the on-merge runs fall outside it and only rebase + existing PRs that the merge left conflicted. - **`release.yaml`** (`.gitea/workflows/`, on push to `main` plus - `workflow_dispatch`, **Gitea**) — a cheap `detect` job reads `versionName` and + `workflow_dispatch`, **Gitea**): a cheap `detect` job reads `versionName` and checks **Codeberg** for a tag for it. Only when there isn't one does the `release` job run: unit tests on the merged commit, pin `versionCode`, build & sign the release APK with the **app key**, copy it into the F-Droid repo, @@ -106,13 +172,19 @@ release work when a merge actually cuts a release: with the **repo key**, upload `repo/` + `metadata/`, then create the `vX.Y.Z` tag + Gitea release (CHANGELOG section as notes, flagged pre-release while `MAJOR` is 0), attach the R8 `mapping.txt`, and publish the release on - **Codeberg** with the signed APK + a SHA-256 checksum. Ordinary merges with no - version bump fall through `detect` and do nothing. - -The pipeline ends with a separate `play` job that uploads the App Bundle to -Google Play. It runs last and in isolation so a Play rejection cannot endanger -a release that has already shipped to F-Droid and Codeberg, and it skips -cleanly until Play is configured. + **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 + Play is configured. ### Codeberg direct-download channel @@ -122,77 +194,54 @@ 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 `clockula_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, -but it is **not** `continue-on-error`: through 0.2.1–0.3.2 this step reported +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`: in Agendula 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 fails the release loudly. -One-time setup: the Codeberg repo's **Releases** unit must be enabled and a -`CODEBERG_RELEASE_TOKEN` secret (Codeberg access token, `write:repository` scope -— it pushes the tag as well as creating the release) added to Gitea Actions. - ### Google Play channel -Play is a third channel alongside F-Droid and the Codeberg download, and it is -the only one that gets a different artifact and a different signature. +**Artifact.** Play gets an **App Bundle** (`bundleRelease`), never the APK. It is +built from the same source and signing config at the very end of the `release` +job, `continue-on-error`, and handed to the `play` job as a workflow artifact +(via a commit-pinned fork of `upload-artifact`/`download-artifact`; the official +v4 actions refuse any non-github.com forge). The APK and the F-Droid +reproducibility guarantee are untouched. -**Artifact.** Play takes an **App Bundle** (`bundleRelease`), not the APK. It is -a second output of the same source and the same signing config — never a -repackage of the published APK, which stays untouched so the F-Droid -reproducibility guarantee is unaffected. The bundle is built at the very **end** -of the `release` job, after everything else has shipped, and both it and the -handoff to the `play` job are `continue-on-error` — nothing Play-related may -take down a release that is already published. The handoff uses a patched -`upload-artifact`/`download-artifact` fork pinned to a commit: the official v4 -actions read any non-github.com forge as an unsupported GHES instance and refuse -to run on Gitea (go-gitea/gitea#36024). `dependenciesInfo` stays disabled for -the bundle too; Play's "app dependencies" report is optional and re-enabling it -would break reproducibility. +**Signature.** Play App Signing treats the app key as the **upload key** only +and re-signs with Google's key. A Play install and an F-Droid/Codeberg install +therefore have **different signatures** and cannot update each other; switching +channels means uninstalling. Losing the app key is recoverable for Play (upload +key reset) but not for F-Droid. -**Signature — read this before assuming an update path exists.** Play App -Signing is mandatory for new apps, and Google generates and holds the app -signing key. The release keystore in CI is registered only as the **upload -key**: Play verifies uploads with it, then re-signs with Google's key before -delivery. Consequences, accepted deliberately: +**fastlane** is only the Play Developer API client (`supply`), never the build. +The `deploy` lane uploads the bundle plus `changelogs/.txt` for +every locale that has one. It never touches the listing; that is the `listing` +lane's job (see [Store listings](#store-listings-single-source-of-truth)). -- A Play install and an F-Droid install have **different signatures** and - **cannot update each other**. Switching channels requires uninstall + - reinstall, which loses nothing (all data lives in the system calendar - provider) but must be stated wherever both channels are advertised. -- Losing the upload key is **recoverable** — request an upload-key reset in the - Play Console. Losing the app key still is not, for F-Droid. +**Track.** Uploads go straight to `production` at full rollout, because the release +branch review is already the gate. Set the `PLAY_TRACK` variable (e.g. +`internal`) to stage, or `PLAY_RELEASE_STATUS=draft` to hold it unpublished. -**Build and signing are not fastlane's job.** Gradle does both, exactly as -before. fastlane appears only as the Play Developer API client (`supply`), -because the store listing already lives in `fastlane/metadata/android/` — the -same tree the official F-Droid repo harvests. One metadata source, two stores. +**First-time setup.** +1. Create the app in the Play Console with **English (United States) – en-US** + as the default language, so Play and F-Droid fall back to the same locale. +2. Upload the first AAB **by hand**; the API can't create an app's first + release. Enrol in Play App Signing on the way. +3. Create a Google Cloud service account, grant it release permissions for the + app in the Play Console, and store its JSON key as + `PLAY_SERVICE_ACCOUNT_JSON`. +4. Push the listing once with the `listing` lane (see + [Store listings](#store-listings-single-source-of-truth)), so every locale + that has a changelog also exists on Play before `deploy` sends its + "What's New". +5. Rehearse with `PLAY_DRY_RUN=true`, then clear it. -**What gets uploaded per release:** the AAB, plus the per-version "What's New" -from `fastlane/metadata/android/en-US/changelogs/.txt` — the -hand-written summary from step 3, which is why it must stay **under 500 -characters**: Play rejects a longer one. Listing text is -**not** touched — an accidental overwrite of a live listing triggers a Play -policy review. Sync it deliberately with `bundle exec fastlane listing`. - -**Screenshots and graphics are skipped**, because the committed assets satisfy -F-Droid but not Play: - -| Asset | Committed | Play requires | -| --- | --- | --- | -| `phoneScreenshots/*.png` | 1280×2856, 32-bit RGBA | long edge ≤ 2× short edge (so ≤ 2560), 24-bit PNG, no alpha | -| `icon.png` | 512×512, 24-bit RGB | 512×512, 32-bit PNG | -| `featureGraphic.png` | *missing* | required, exactly 1024×500 | - -Until those are fixed, Play's graphics are managed by hand in the console. Then -pass `images:true` to the `listing` lane. - -**Track.** Uploads go straight to `production` at a full rollout -(`PLAY_RELEASE_STATUS=completed`). The merge to `main` is already the human -gate — a release only gets there after on-device review — so a second manual -promotion in the Play Console added delay without adding a decision. Set the -`PLAY_TRACK` repo variable (e.g. `internal`) to stage a release instead, or -`PLAY_RELEASE_STATUS=draft` to hold it unpublished. +One-time setup: the Codeberg repo's **Releases** unit must be enabled and a +`CODEBERG_RELEASE_TOKEN` secret (Codeberg access token, `write:repository` scope, +since it pushes the tag as well as creating the release) added to Gitea Actions. ### Manual re-sign / recovery @@ -206,7 +255,7 @@ rotation or repo recovery without publishing a new app version. ## Two forges, one repo -**Codeberg (`jlmakiola/clockula`) is canonical** — git, issues, PRs, tags and +**Codeberg (`jlmakiola/clockula`) is canonical** for git, issues, PRs, tags and releases. The self-hosted Gitea instance is build infrastructure: it holds the signing key, publishes the F-Droid repo, and runs the release pipeline. Codeberg push-mirrors `main` and tags to Gitea, and a bumped `versionName` arriving there @@ -221,14 +270,13 @@ first that exists; Gitea doesn't know `.forgejo/` at all: | `.forgejo/workflows/` | Codeberg | `ci.yaml`, `translations.yaml` | **none** | | `.gitea/workflows/` | Gitea | `release.yaml`, `renovate.yml` | signing key, F-Droid, Hetzner, bot tokens | -The line is drawn at **secrets, not at CI-vs-release**. That's what makes fork +The line is drawn at **secrets, not at CI-vs-release**, which makes fork PRs safe: everything a contributor can trigger lives in `.forgejo/` and can reference no secret. Renovate stays on the Gitea runner *even though it opens -PRs on Codeberg* — it talks to Codeberg's API rather than moving its token onto +PRs on Codeberg*: it talks to Codeberg's API instead of moving its token onto the contributor-facing runner. `detect` (and the Renovate job) additionally -carries a -`github.repository_owner == 'makiolaj'` guard, because the directory split only -holds while `.forgejo/` is non-empty — empty it and Codeberg would fall back to +carries a `github.repository_owner == 'makiolaj'` guard, because the directory split only +holds while `.forgejo/` is non-empty; empty it and Codeberg would fall back to `.gitea/` and run the release pipeline on the contributor-facing runner. Two consequences worth remembering: @@ -243,7 +291,7 @@ Two consequences worth remembering: `floret-kit` is a submodule of this repo and follows the same move: `.gitmodules` points at `https://codeberg.org/jlmakiola/floret-kit.git`, so a clone resolves -without reaching the personal Gitea instance. The Gitea copy is **kept** — every +without reaching the personal Gitea instance. The Gitea copy is **kept**: every existing tag records the old submodule URL, so rebuilds of past releases still resolve. @@ -251,43 +299,94 @@ resolve. ## Secrets (Gitea → repo Settings → Actions → Secrets) -The workflow fails loudly if the F-Droid ones are missing — it will **never** +The workflow fails loudly if the F-Droid ones are missing. It will **never** auto-generate a repo key (that would rotate the repo fingerprint and break every user's pinned repo). | Secret | Purpose | | --- | --- | -| `KEYSTORE_BASE64`, `KEY_PASSWORD`, `KEY_ALIAS` | **App** signing key — signs the APK. Losing it means existing installs can't be updated. | +| `KEYSTORE_BASE64`, `KEY_PASSWORD`, `KEY_ALIAS` | **App** signing key. Signs the APK. Losing it means existing installs can't be updated. | | `FDROID_KEYSTORE_BASE64` | **F-Droid repo** signing key (`keystore.p12`, base64). Signs the repo index. | -| `FDROID_CONFIG_BASE64` | F-Droid `config.yml` (base64) — repo metadata + keystore passwords. | +| `FDROID_CONFIG_BASE64` | F-Droid `config.yml` (base64): repo metadata + keystore passwords. | | `HETZNER_HOST`, `HETZNER_USER`, `HETZNER_PASS` | Upload target for the F-Droid repo. | | `GITHUB_TOKEN` | Provided by Gitea Actions; used to create the release + attach assets. | -| `CODEBERG_RELEASE_TOKEN` | Codeberg access token (`write:repository` scope) — pushes the tag to Codeberg, creates the release there and uploads the APK/checksum. If unset the step skips; if set and failing, the release fails. | -| `PLAY_SERVICE_ACCOUNT_JSON` | Google Cloud service-account key (full JSON) with Play Console access — uploads the AAB. If unset, the `play` job skips cleanly, which is the expected state for the whole pre-1.0 run. | -| `RENOVATE_TOKEN` | Codeberg bot-account token — repo read/write + PR scope on `jlmakiola/clockula`. Used only by `renovate.yml`. | +| `CODEBERG_RELEASE_TOKEN` | Codeberg access token (`write:repository` scope). Pushes the tag to Codeberg, creates the release there and uploads the APK/checksum. If unset the step skips; if set and failing, the release fails. | +| `PLAY_SERVICE_ACCOUNT_JSON` | Google Cloud service-account key (full JSON) with Play Console release access. Uploads the AAB. If unset, the `play` job skips cleanly. | +| `RENOVATE_TOKEN` | Codeberg bot-account token with repo read/write + PR scope on `jlmakiola/clockula`. Used only by `renovate.yml`. | | `GITHUB_COM_TOKEN` | Read-only github.com PAT (no scopes). Without it Renovate's changelog lookups hit the 60/h anonymous rate limit and PRs arrive with empty release notes. | The app key signs APKs; the repo key signs the index (its fingerprint is what -users pin). Neither key nor `config.yml` is ever uploaded to the server — they +users pin). Neither key nor `config.yml` is ever uploaded to the server. They live only in CI secrets and are reconstructed in-runner (nginx serves only `repo/`). ---- +### Variables (Gitea → repo Settings → Actions → Variables) -## F-Droid metadata (single source of truth) - -Store-listing text lives in **`fastlane/metadata/android//`** — the same -tree the official F-Droid repo harvests from source. At release time -`scripts/fastlane_to_fdroid_localized.sh` transforms it into the F-Droid repo's -"localized" layout, so there is no second copy to maintain. The app-level control -file (`Categories`/`License`/links) stays in -`fdroid-metadata/de.jeanlucmakiola.clockula.yml`. Per-version changelogs are -seeded into `fastlane/.../en-US/changelogs/.txt` by -`scripts/sync_changelog_to_fastlane.sh` (step 3 above) and carried across by the -transform. +| Variable | Default | Purpose | +| --- | --- | --- | +| `PLAY_TRACK` | `production` | Play track for the bundle; `internal`/`alpha`/`beta` to stage. | +| `PLAY_RELEASE_STATUS` | `completed` | `completed`, `draft`, `inProgress` or `halted`. | +| `PLAY_DRY_RUN` | `false` | `true` validates the Play edit and discards it. | --- +## Store listings (single source of truth) + +Everything both stores show (title, short and full description, "What's New", +icon, feature graphic, screenshots) lives in +**`fastlane/metadata/android//`**. Nothing is edited in the Play Console +or kept in a second copy for F-Droid. + +``` +fastlane/metadata/android/ + en-US/ fallback of both stores; holds every graphic + title.txt ≤ 30 chars + short_description.txt ≤ 80 chars + full_description.txt ≤ 4000 chars + changelogs/.txt ≤ 500 chars + images/ + icon.png 512×512, 32-bit PNG + featureGraphic.png 1024×500, no alpha + phoneScreenshots/ 2–8, no alpha, 320–3840 px, long side ≤ 2× short + sevenInchScreenshots/ optional, same rules + tenInchScreenshots/ optional, same rules + en-GB/, de-DE/, pt-BR/, … text only; one per shipped values-* language +``` + +`en-US` is the fallback for both stores: F-Droid always uses it, and it is the +Play app's default language. So text and graphics committed there cover every +locale without its own. Graphics are committed **once, under `en-US`**; add them +to another locale only when they actually differ (e.g. localized screenshots). + +A raw phone capture (1080×2400, 20:9) breaks Play's 2:1 aspect cap. Frame or +crop screenshots to at most 2:1, e.g. 1080×2160. + +**Validation.** `scripts/check_store_listing.py` checks the whole tree against +Play's limits (the stricter of the two stores) and runs on every PR. Add +`--complete` to also require what a live Play listing needs: icon, feature +graphic, ≥ 2 phone screenshots and this version's `en-US` changelog. + +**F-Droid.** The official repo harvests the tree from the tagged source. The +self-hosted repo gets it through `scripts/fastlane_to_fdroid_localized.sh` at +release time. The app-level control file (`Categories`/`License`/links) stays in +`fdroid-metadata/de.jeanlucmakiola.clockula.yml`. + +**Play.** `fastlane/Fastfile`'s `listing` lane pushes text and graphics +(`supply`, only changed images are re-uploaded). It is run by hand, never by the +release pipeline, because overwriting a live listing triggers a Play review: + +```sh +bundle install +SUPPLY_JSON_KEY=/path/to/play-service-account.json \ + bundle exec fastlane listing dry_run:true # validate, discard +SUPPLY_JSON_KEY=/path/to/play-service-account.json \ + bundle exec fastlane listing +``` + +The lane refuses to run unless `check_store_listing.py --complete` passes. +If the Play app's default language is ever not `en-US`, set +`PLAY_DEFAULT_LOCALE` and the lane copies the graphics into that locale. + ## Crash deobfuscation Each release attaches `mapping-.txt.gz` (the R8 mapping) to its Gitea diff --git a/fastlane/Appfile b/fastlane/Appfile index 45ce113..3a2c45a 100644 --- a/fastlane/Appfile +++ b/fastlane/Appfile @@ -1,7 +1,4 @@ -# fastlane is used in this repo ONLY as a Google Play Developer API client -# (see fastlane/Fastfile). It never builds and never signs. - -# Reconstructed in CI from the PLAY_SERVICE_ACCOUNT_JSON secret; gitignored. +# fastlane is only the Play Developer API client here (see Fastfile). json_key_file(ENV["SUPPLY_JSON_KEY"] || "play-service-account.json") package_name("de.jeanlucmakiola.clockula") diff --git a/fastlane/Fastfile b/fastlane/Fastfile index afdde8a..644facd 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -1,87 +1,75 @@ -# Google Play publishing only. +# Google Play: release upload and listing sync. # -# Building and signing are deliberately NOT fastlane's job: release.yaml drives -# Gradle directly so the release build stays F-Droid-reproducible (`vcsInfo`, -# `dependenciesInfo` and the AGP metadata block are disabled in -# app/build.gradle.kts and guarded by scripts/check_reproducible_release.sh). -# Interposing fastlane there would add a layer that can inject Gradle flags into -# the one build whose byte-for-byte output is verified by a third party. -# -# What fastlane IS here for is `supply` — the Play Developer API client — because -# the store listing already lives in fastlane/metadata/android, which the -# official F-Droid repo harvests from the tagged source tree. One metadata tree, -# two stores. +# fastlane/metadata/android is the single source for both stores: the official +# F-Droid repo harvests it from the tagged tree, the self-hosted repo gets it +# through scripts/fastlane_to_fdroid_localized.sh, and this pushes it to Play. +# Building and signing stay with Gradle so the release build remains +# F-Droid-reproducible. + +require "fileutils" +require "tmpdir" default_platform(:android) -# fastlane runs a lane body with the working directory set to `fastlane/`, not -# the project root, so a relative artifact path from the caller has to be -# resolved explicitly. Absolute paths pass through untouched. -def project_path(path) - File.expand_path(path, File.expand_path("..", FastlaneCore::FastlaneFolder.path || ".")) -end +ROOT = File.expand_path("..", __dir__) +METADATA = File.join(ROOT, "fastlane/metadata/android") + +# Graphics are committed once, under en-US (F-Droid's fallback). Play falls back +# to its *default language*, en-US for this app; if that ever differs +# (PLAY_DEFAULT_LOCALE), the listing lane copies the graphics across. +IMAGE_LOCALE = "en-US" platform :android do desc "Upload an already-built, already-signed AAB to Play" lane :deploy do |options| - aab = project_path(options[:aab] || "app/build/outputs/bundle/release/app-release.aab") + aab = File.expand_path(options[:aab] || "app/build/outputs/bundle/release/app-release.aab", ROOT) UI.user_error!("AAB not found at #{aab}") unless File.exist?(aab) supply( aab: aab, + metadata_path: METADATA, track: options[:track] || "production", release_status: options[:release_status] || "completed", - - # The APK belongs to F-Droid and the Codeberg download; Play only ever - # gets the bundle. Never let the uploader reach for the other artifact. + # The APK belongs to F-Droid and the Codeberg download. skip_upload_apk: true, - - # Listing text is synced deliberately via the `listing` lane, not on every - # release. Overwriting a live listing by accident triggers a Play policy - # review, which is not a quick revert. + # The listing goes through the `listing` lane, deliberately; overwriting + # a live listing by accident triggers a Play review. skip_upload_metadata: true, - - # "What's New" DOES track every release. - # scripts/sync_changelog_to_fastlane.sh writes - # fastlane/metadata/android/en-US/changelogs/.txt from - # CHANGELOG.md, so Play shows exactly the text F-Droid does. - skip_upload_changelogs: false, - - # See the `listing` lane for why the committed images can't go to Play yet. skip_upload_images: true, skip_upload_screenshots: true, - - # Dry run: validate the edit against the API and roll it back instead of - # committing it. Used for the first end-to-end rehearsal. + # "What's New" tracks every release, from every locale's + # changelogs/.txt — the same files F-Droid shows. + skip_upload_changelogs: false, validate_only: options[:dry_run].to_s == "true", ) end - desc "Sync the store listing text (and, once the assets qualify, the images)" + desc "Push listing text and graphics from the repo to Play" + desc " dry_run:true validate against the API and discard the edit" lane :listing do |options| - # Deliberate and manual: `bundle exec fastlane listing`. Not wired into the - # release pipeline. - # - # Images stay off by default because the committed assets do not satisfy - # Play today, even though F-Droid accepts all of them: - # - # * phoneScreenshots are 1280x2856. Play caps the long edge at twice the - # short edge (2560 here), so every screenshot is rejected. - # * those PNGs are 32-bit RGBA. Play wants 24-bit PNG, no alpha. - # * icon.png is 512x512 but 24-bit RGB. Play's icon wants 32-bit PNG. - # * there is no featureGraphic.png. Play requires one, exactly 1024x500. - # - # Until that is fixed, the Play listing's graphics are managed by hand in the - # console. Pass images:true once the assets qualify. - upload_images = options[:images].to_s == "true" + sh("python3", File.join(ROOT, "scripts/check_store_listing.py"), "--complete") - supply( - skip_upload_aab: true, - skip_upload_apk: true, - skip_upload_metadata: false, - skip_upload_changelogs: true, - skip_upload_images: !upload_images, - skip_upload_screenshots: !upload_images, - ) + default_locale = ENV["PLAY_DEFAULT_LOCALE"] || IMAGE_LOCALE + + Dir.mktmpdir("play-listing") do |staged| + FileUtils.cp_r("#{METADATA}/.", staged) + src = File.join(staged, IMAGE_LOCALE, "images") + dst = File.join(staged, default_locale, "images") + FileUtils.cp_r(src, dst) if default_locale != IMAGE_LOCALE && !File.exist?(dst) + + supply( + metadata_path: staged, + skip_upload_aab: true, + skip_upload_apk: true, + skip_upload_metadata: false, + # "What's New" belongs to a release on a track, not to the listing. + skip_upload_changelogs: true, + skip_upload_images: false, + skip_upload_screenshots: false, + # Only re-upload graphics whose content changed. + sync_image_upload: true, + validate_only: options[:dry_run].to_s == "true", + ) + end end end diff --git a/renovate.json5 b/renovate.json5 index a036746..b0d1348 100644 --- a/renovate.json5 +++ b/renovate.json5 @@ -52,9 +52,16 @@ prConcurrentLimit: 5, prHourlyLimit: 0, - // Cadence is owned by the Gitea Actions cron (.gitea/workflows/renovate.yml, - // Mondays) — no internal `schedule` here, so the two don't double-gate and - // silently skip a run. + // New branches/PRs only in the Monday window (UTC), which brackets the weekly + // cron in .gitea/workflows/renovate.yml with slack for a late start — keep + // the two in step, or the sweep lands outside the window and finds nothing. + // The same workflow also runs on every push to main; those runs fall outside + // the window and, via the default updateNotScheduled: true, only maintain + // existing branches — chiefly rebasing PRs a merge just conflicted. + schedule: ["* 4-6 * * 1"], + // Rebase only on conflict, not every time main moves (automerge is off, so + // this matches "auto"; spelled out because the on-merge trigger relies on it). + rebaseWhen: "conflicted", // Workflows are split by forge and neither directory is .github: CI lives in // .forgejo/workflows (Codeberg) and the release/renovate jobs in diff --git a/scripts/check_reproducible_release.sh b/scripts/check_reproducible_release.sh index 118361f..9696cc0 100755 --- a/scripts/check_reproducible_release.sh +++ b/scripts/check_reproducible_release.sh @@ -17,6 +17,8 @@ # 3. dependenciesInfo { includeInApk = false } — else AGP embeds a "Dependency # metadata" block (id 0x504b4453) in the APK Signing Block, which F-Droid's # binary scanner rejects as an extra signing block. +# 4. jniLibs { keepDebugSymbols += "**/*.so" } — else AGP strips prebuilt .so +# files only when an NDK is installed, so the bytes depend on the host. set -euo pipefail APP="app/build.gradle.kts" @@ -62,6 +64,17 @@ else fail=1 fi +# 4. Prebuilt native libs must not be stripped. AGP strips them only if an NDK is +# installed, so the output would depend on the build host. +if grep -Pzoq 'jniLibs\s*\{[^}]*keepDebugSymbols\s*\+=\s*"\*\*/\*\.so"' "$APP"; then + echo "OK: jniLibs keepDebugSymbols — native libs ship unstripped on every host." +else + echo "ERROR: '$APP' is missing 'jniLibs { keepDebugSymbols += \"**/*.so\" }'." >&2 + echo " AGP strips .so files only when an NDK is present, so a from-source" >&2 + echo " rebuild would not match the published APK." >&2 + fail=1 +fi + if [ "$fail" -ne 0 ]; then echo >&2 echo "Reproducible-release invariant(s) violated — official F-Droid publishing would" >&2 diff --git a/scripts/check_store_listing.py b/scripts/check_store_listing.py new file mode 100644 index 0000000..75de010 --- /dev/null +++ b/scripts/check_store_listing.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 +"""Validate fastlane/metadata/android against what BOTH stores accept. + +The fastlane tree is the single source for the F-Droid listing (official repo +harvests it, the self-hosted repo gets it via fastlane_to_fdroid_localized.sh) +and for the Play listing (`bundle exec fastlane listing`). F-Droid accepts +nearly anything; Play rejects a lot at upload time. Checking against Play's +rules here is what keeps the graphics shippable to both from the same files. + + scripts/check_store_listing.py validate what exists + scripts/check_store_listing.py --complete also require everything a Play + listing needs to go live + +SDK- and dependency-free (PNG/JPEG headers are parsed by hand) so CI can run it +without setup. +""" +import re +import struct +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +META = ROOT / "fastlane/metadata/android" +RES = ROOT / "app/src/main/res" + +# Play's limits; F-Droid truncates or recommends the same. +TEXT_LIMITS = {"title.txt": 30, "short_description.txt": 80, "full_description.txt": 4000} +CHANGELOG_LIMIT = 500 + +# The fallback locale of both stores: F-Droid always, Play as the app's default +# language. It carries the full text and every graphic. +REQUIRED_LOCALES = ("en-US",) +IMAGE_LOCALE = "en-US" + +SCREENSHOT_DIRS = ("phoneScreenshots", "sevenInchScreenshots", "tenInchScreenshots", + "tvScreenshots", "wearScreenshots") +# Play policy on the title: no ranking, price or promotional terms. +TITLE_BANNED = re.compile(r"\b(best|top|#1|no\.? ?1|free|sale|discount|new|hot|download now)\b", re.I) + +errors, warnings = [], [] + + +def err(path, msg): + errors.append(f"{path.relative_to(ROOT)}: {msg}") + + +def warn(path, msg): + warnings.append(f"{path.relative_to(ROOT)}: {msg}") + + +def image_info(path): + """(format, width, height, has_alpha) or None if unreadable.""" + data = path.read_bytes() + if data[:8] == b"\x89PNG\r\n\x1a\n": + w, h, _depth, color = struct.unpack(">IIBB", data[16:26]) + has_alpha = color in (4, 6) or b"tRNS" in data[:data.find(b"IDAT")] + return "png", w, h, has_alpha + if data[:2] == b"\xff\xd8": + i = 2 + while i + 9 < len(data): + if data[i] != 0xFF: + i += 1 + continue + marker = data[i + 1] + seg_len = struct.unpack(">H", data[i + 2:i + 4])[0] + if 0xC0 <= marker <= 0xCF and marker not in (0xC4, 0xC8, 0xCC): + h, w = struct.unpack(">HH", data[i + 5:i + 9]) + return "jpeg", w, h, False + i += 2 + seg_len + return None + + +def check_image(path, *, size=None, alpha=None, max_mb, screenshot=False): + info = image_info(path) + if info is None: + err(path, "not a PNG or JPEG") + return + fmt, w, h, has_alpha = info + if size and (w, h) != size: + err(path, f"is {w}x{h}, Play requires exactly {size[0]}x{size[1]}") + if alpha is True and (fmt != "png" or not has_alpha): + err(path, "must be a 32-bit PNG (with alpha channel)") + if alpha is False and has_alpha: + err(path, "has an alpha channel; Play wants 24-bit PNG or JPEG") + if screenshot: + short, long_ = sorted((w, h)) + if short < 320 or long_ > 3840: + err(path, f"is {w}x{h}; each side must be 320–3840 px") + if long_ > 2 * short: + err(path, f"is {w}x{h}; the long side may be at most twice the short side") + if short < 1080: + warn(path, f"is {w}x{h}; Play only features screenshots at 1080 px or more") + mb = path.stat().st_size / 1_000_000 + if mb > max_mb: + err(path, f"is {mb:.1f} MB, over Play's {max_mb} MB limit") + + +def check_images(locale_dir): + images = locale_dir / "images" + if not images.is_dir(): + return + if locale_dir.name != IMAGE_LOCALE: + warn(images, f"graphics belong in {IMAGE_LOCALE} only; both stores fall back to it") + for f in images.iterdir(): + if f.is_file() and f.stem not in ("icon", "featureGraphic", "promoGraphic", "tvBanner"): + err(f, "unknown image; neither store will pick it up") + icon = images / "icon.png" + if icon.exists(): + check_image(icon, size=(512, 512), alpha=True, max_mb=1) + for name, size in (("featureGraphic", (1024, 500)), ("promoGraphic", (180, 120)), + ("tvBanner", (1280, 720))): + for f in images.glob(f"{name}.*"): + check_image(f, size=size, alpha=False, max_mb=15) + for d in SCREENSHOT_DIRS: + shots = sorted(p for p in (images / d).glob("*") if p.is_file()) + if len(shots) > 8: + err(images / d, f"{len(shots)} screenshots; Play takes at most 8") + for s in shots: + check_image(s, alpha=False, max_mb=8, screenshot=True) + + +def version_code(): + """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(): + langs = set() + for d in RES.glob("values-*"): + if (d / "strings.xml").exists(): + m = re.fullmatch(r"values-([a-z]{2,3})(?:-r([A-Z]{2}))?", d.name) + if m: + langs.add((m.group(1), m.group(2))) + return langs + + +def main(): + complete = "--complete" in sys.argv[1:] + current = version_code() + locales = sorted(d for d in META.iterdir() if d.is_dir()) + names = {d.name for d in locales} + + for loc in REQUIRED_LOCALES: + if loc not in names: + err(META / loc, "missing; this locale is a store fallback and must exist") + + for d in locales: + has_text = any((d / f).exists() for f in TEXT_LIMITS) + for fname, limit in TEXT_LIMITS.items(): + f = d / fname + if not f.exists(): + if has_text or d.name in REQUIRED_LOCALES: + err(f, "missing (a locale with any listing text needs all three files)") + continue + text = f.read_text(encoding="utf-8").strip() + if not text: + err(f, "empty") + elif len(text) > limit: + err(f, f"{len(text)} chars, limit {limit}") + if fname == "title.txt" and TITLE_BANNED.search(text): + warn(f, "promotional term in the title; Play policy forbids these") + for f in sorted((d / "changelogs").glob("*.txt")): + n = len(f.read_text(encoding="utf-8").strip()) + if f.stem == str(current) and n > CHANGELOG_LIMIT: + err(f, f"{n} chars, limit {CHANGELOG_LIMIT}; Play would reject this release") + elif n > CHANGELOG_LIMIT: + warn(f, f"{n} chars, over {CHANGELOG_LIMIT} (already published)") + check_images(d) + + for lang, region in sorted(shipped_languages(), key=str): + match = f"{lang}-{region}" if region else lang + if not any(n == match or n.startswith(f"{lang}-") for n in names): + warn(META, f"app ships values-{lang}{'-r' + region if region else ''} " + "but there is no store locale for it") + + if complete: + images = META / IMAGE_LOCALE / "images" + if not (images / "icon.png").exists(): + err(images / "icon.png", "required for Play") + if not list(images.glob("featureGraphic.*")): + 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 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") + + for w in warnings: + print(f"warning: {w}") + for e in errors: + print(f"ERROR: {e}", file=sys.stderr) + if errors: + return 1 + print(f"Store listing OK ({len(locales)} locales, {len(warnings)} warnings).") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/fastlane_to_fdroid_localized.sh b/scripts/fastlane_to_fdroid_localized.sh index 5cda2e0..0cabb83 100755 --- a/scripts/fastlane_to_fdroid_localized.sh +++ b/scripts/fastlane_to_fdroid_localized.sh @@ -14,8 +14,8 @@ # short_description.txt -> summary.txt # full_description.txt -> description.txt # title.txt -> name.txt -# images/icon.png -> icon.png -# images/phoneScreenshots/* -> phoneScreenshots/* +# images/{icon,featureGraphic,promoGraphic,tvBanner}.* -> same name +# images/Screenshots/* -> Screenshots/* # changelogs/.txt -> changelogs/.txt # (changelogs are seeded into the fastlane tree by # scripts/sync_changelog_to_fastlane.sh.) @@ -32,11 +32,14 @@ for locdir in "$SRC"/*/; do [ -f "$locdir/short_description.txt" ] && cp "$locdir/short_description.txt" "$dst/summary.txt" [ -f "$locdir/full_description.txt" ] && cp "$locdir/full_description.txt" "$dst/description.txt" [ -f "$locdir/title.txt" ] && cp "$locdir/title.txt" "$dst/name.txt" - [ -f "$locdir/images/icon.png" ] && cp "$locdir/images/icon.png" "$dst/icon.png" - if [ -d "$locdir/images/phoneScreenshots" ]; then - mkdir -p "$dst/phoneScreenshots" - cp "$locdir"images/phoneScreenshots/* "$dst/phoneScreenshots/" - fi + for img in "$locdir"images/{icon,featureGraphic,promoGraphic,tvBanner}.*; do + cp "$img" "$dst/" + done + for shots in "$locdir"images/*Screenshots/; do + t="$(basename "$shots")" + mkdir -p "$dst/$t" + cp "$shots"* "$dst/$t/" + done # Per-version changelogs live in the same fastlane tree (see # scripts/sync_changelog_to_fastlane.sh) and map straight across. if [ -d "$locdir/changelogs" ]; then diff --git a/scripts/publish_codeberg_release.sh b/scripts/publish_codeberg_release.sh new file mode 100755 index 0000000..4a99245 --- /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="clockula_${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 f6d0e93..8c294c8 100755 --- a/scripts/sync_changelog_to_fastlane.sh +++ b/scripts/sync_changelog_to_fastlane.sh @@ -1,41 +1,58 @@ #!/usr/bin/env bash -# Write the current version's CHANGELOG.md section into the fastlane changelog -# file that F-Droid harvests: fastlane/metadata/android/en-US/changelogs/.txt -# (en-US is F-Droid's fallback locale, so it covers every language). +# Ensure the current version's "What's New" exists at +# fastlane/metadata/android/en-US/changelogs/.txt, which both F-Droid and +# Google Play publish. # -# Run this when cutting a release (after editing CHANGELOG.md and bumping -# versionName in app/build.gradle.kts) and COMMIT the result, so the OFFICIAL -# F-Droid repo — which reads the changelog from the tagged source tree — shows -# this version's "What's New". The self-hosted release pipeline also runs it so -# its changelog never depends on the file having been committed. Idempotent. -# -# Extraction matches the awk used for the Gitea release notes so all three -# (release notes, self-hosted changelog, official changelog) stay in sync. +# A COMMITTED file wins and is never rewritten: it is a hand-written summary +# under 500 characters (Play's cap), not a copy of the CHANGELOG.md section. +# Only when the file is missing is it generated from that section, so the +# self-hosted release pipeline always has something to publish. The other +# locales' changelogs are written by hand and never touched here. 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} CL_DIR="fastlane/metadata/android/en-US/changelogs" mkdir -p "$CL_DIR" OUT="$CL_DIR/${VERSION_CODE}.txt" -awk -v ver="$VERSION" ' - $0 ~ "^## \\[" ver "\\]" { flag = 1; next } - /^## \[/ { flag = 0 } - flag' CHANGELOG.md > "$OUT" -# Trim leading blank lines (same as the pipeline did). -sed -i -e '/./,$!d' "$OUT" -if [ ! -s "$OUT" ]; then - echo "See CHANGELOG.md for $VERSION." > "$OUT" +if [ -s "$OUT" ]; then + ACTION="Kept" +else + ACTION="Generated" + awk -v ver="$VERSION" ' + $0 ~ "^## \\[" ver "\\]" { flag = 1; next } + /^## \[/ { flag = 0 } + flag' CHANGELOG.md > "$OUT" + # Trim leading blank lines (same as the pipeline did). + sed -i -e '/./,$!d' "$OUT" + [ -s "$OUT" ] || echo "See CHANGELOG.md for $VERSION." > "$OUT" fi -CHARS=$(wc -m < "$OUT" | tr -d ' ') -echo "Wrote $OUT (version $VERSION, code $VERSION_CODE, ${CHARS} chars)" -if [ "$CHARS" -gt 500 ]; then - echo " note: >500 chars — F-Droid may truncate this changelog in-client." >&2 +# Counted like check_store_listing.py: surrounding whitespace doesn't count. +CHARS=$(python3 -c 'import sys; print(len(open(sys.argv[1], encoding="utf-8").read().strip()))' "$OUT") +echo "$ACTION $OUT (version $VERSION, code $VERSION_CODE, ${CHARS} chars)" + +# Hard limit, enforced in CI (.forgejo/workflows/ci.yaml) and before a release. +if [ "$CHARS" -gt "$MAX_CHARS" ]; then + cat >&2 < 1000001, 1.0.0 -> 1000099. +# +# 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 + +if [ -n "$BETA" ]; then + 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 )) +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