ci(release): port Agendula's 1.1.0 release pipeline, with betas
Brings the workflows, scripts and fastlane setup up to Agendula's
v1.1.0-beta.1 state:
- beta.yaml: a release/** branch carrying X.Y.Z-beta.N publishes a
Gitea + Codeberg pre-release with the signed APK. Never F-Droid or Play.
- scripts/version_info.sh derives versionCode from versionName for every
workflow and CI step. Clockula has no shipped 1.0.x codes, so it uses one
scheme: X*1000000 + Y*10000 + Z*100 + (beta N, or 99 for stable).
- CI checks the committed versionCode, blocks a beta from reaching main,
enforces the 500-char What's New and validates the store listing.
- Shared publish_gitea_release.sh / publish_codeberg_release.sh /
release_notes.sh replace the inline steps in release.yaml.
- Renovate also runs on every push to main to rebase conflicted PRs.
- jniLibs keepDebugSymbols, which the reproducibility guard now requires.
Fixes a bug present in Agendula too: the Codeberg tag lookups used
/git/refs/tags/{tag}, which matches by prefix, so v1.0.0 would have
"existed" as soon as v1.0.0-beta.1 did. That would have refused every
later beta and skipped the stable release. Both now use /tags/{tag}.
This commit is contained in:
@@ -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/<locale>/changelogs/<versionCode>.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 }}"
|
||||
|
||||
@@ -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 <<EOF
|
||||
storePassword=$KEY_PASSWORD
|
||||
keyPassword=$KEY_PASSWORD
|
||||
keyAlias=$KEY_ALIAS
|
||||
storeFile=upload-keystore.jks
|
||||
EOF
|
||||
|
||||
- name: Build release APK
|
||||
run: ./gradlew assembleRelease
|
||||
|
||||
# Notes = a `## [X.Y.Z-beta.N]` section if there is one, else
|
||||
# `## [Unreleased]`. Gitea creates the tag at this commit.
|
||||
- name: Create tag + Gitea pre-release
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||
SHA: ${{ github.sha }}
|
||||
run: |
|
||||
set -e
|
||||
bash scripts/release_notes.sh "$VERSION" > release-notes.md
|
||||
cat release-notes.md
|
||||
TAG="v$VERSION" PRERELEASE=true NOTES_FILE=release-notes.md \
|
||||
MAPPING=app/build/outputs/mapping/release/mapping.txt \
|
||||
bash scripts/publish_gitea_release.sh
|
||||
|
||||
# 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
|
||||
+85
-252
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+242
-143
@@ -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<versionName>` 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<versionName>` 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/<versionCode>.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/<locale>/changelogs/<versionCode>.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<version>.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/<versionCode>.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/<versionCode>.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/<locale>/`** — 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/<versionCode>.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/<locale>/`**. 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/<code>.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-<version>.txt.gz` (the R8 mapping) to its Gitea
|
||||
|
||||
+1
-4
@@ -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")
|
||||
|
||||
+49
-61
@@ -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/<versionCode>.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/<versionCode>.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
|
||||
|
||||
+10
-3
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
@@ -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/<type>Screenshots/* -> <type>Screenshots/*
|
||||
# changelogs/<versionCode>.txt -> changelogs/<versionCode>.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
|
||||
|
||||
Executable
+96
@@ -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/<owner>/<repo>
|
||||
# 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."
|
||||
Executable
+76
@@ -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/<owner>/<repo>
|
||||
# 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
|
||||
Executable
+29
@@ -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
|
||||
@@ -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/<code>.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/<code>.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 <<MSG
|
||||
ERROR: $OUT is $CHARS characters, over the $MAX_CHARS-character limit.
|
||||
|
||||
F-Droid truncates the in-client changelog, so this one would be cut off
|
||||
mid-sentence and Play would reject the upload. Replace it with a hand-written
|
||||
summary under $MAX_CHARS characters and commit it.
|
||||
MSG
|
||||
exit 1
|
||||
fi
|
||||
|
||||
Executable
+77
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env bash
|
||||
# The one place that turns the committed versionName into a versionCode and a
|
||||
# release channel. The release and beta pipelines, the changelog sync and the
|
||||
# store-listing check all read it from here, so the scheme can't drift between
|
||||
# them.
|
||||
#
|
||||
# versionName channel versionCode
|
||||
# X.Y.Z-beta.N beta X*1000000 + Y*10000 + Z*100 + N (N = 1..98)
|
||||
# X.Y.Z stable X*1000000 + Y*10000 + Z*100 + 99
|
||||
#
|
||||
# A beta's code sits below its own stable release and above everything before
|
||||
# it, so a beta install updates in place to the next beta and then to the
|
||||
# stable version: 1.0.0-beta.1 -> 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 <key> 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:-<none>}, 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
|
||||
Reference in New Issue
Block a user