fastlane chdirs into fastlane/ before running a lane, so the relative dist/app-release.aab the release pipeline passed resolved to fastlane/dist/app-release.aab and the 2.17.1 upload aborted. The lane now expands relative paths against the project root, and the workflow hands it an absolute path.
655 lines
31 KiB
YAML
655 lines
31 KiB
YAML
name: Release — F-Droid repo + Gitea/Codeberg release + Play
|
|
|
|
# A release is cut by merging a release branch into main with a bumped
|
|
# versionName (see docs/RELEASING.md). This workflow reads that versionName and,
|
|
# if no matching tag exists yet, runs tests, builds + signs the APK, publishes
|
|
# it to the F-Droid repo, creates the vX.Y.Z tag + Gitea release, and mirrors
|
|
# that release to Codeberg with the signed APK + a SHA-256 checksum as a
|
|
# direct-download channel — the tag is an output of the pipeline, not its
|
|
# trigger. Ordinary merges (no version bump) fall through `detect` and do
|
|
# nothing.
|
|
#
|
|
# 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.
|
|
#
|
|
# A manual workflow_dispatch (from a branch) runs the re-sign-only recovery
|
|
# path: it re-signs the existing F-Droid index with the repo key and re-uploads,
|
|
# without building an APK or creating a release. Used for key rotation / repo
|
|
# recovery.
|
|
on:
|
|
push:
|
|
branches: [main]
|
|
workflow_dispatch:
|
|
|
|
concurrency:
|
|
group: release
|
|
cancel-in-progress: false
|
|
|
|
jobs:
|
|
# Cheap gate: resolve the version from the committed build.gradle and decide
|
|
# whether this push actually cuts a new release (no tag for it yet). Keeps the
|
|
# heavy job from running on every merge to main.
|
|
detect:
|
|
# Gitea only. The workflow directory split already keeps this file invisible
|
|
# to Codeberg — Forgejo's lookup is first-match-wins, and .forgejo/workflows
|
|
# exists — but that only holds while .forgejo/ is non-empty. Move the last
|
|
# file out of it and Codeberg would fall back to .gitea/workflows and start
|
|
# running the release pipeline on the contributor-facing runner, with no
|
|
# secrets. repository_owner differs between the two forges regardless of
|
|
# URL, proxy or instance rename, so this closes it permanently.
|
|
if: github.repository_owner == 'makiolaj'
|
|
runs-on: docker
|
|
outputs:
|
|
is_release: ${{ steps.v.outputs.is_release }}
|
|
version: ${{ steps.v.outputs.version }}
|
|
version_code: ${{ steps.v.outputs.version_code }}
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v4
|
|
with:
|
|
submodules: recursive
|
|
|
|
- name: Resolve version and whether it is a new release
|
|
id: v
|
|
env:
|
|
# Tags are read from Codeberg, which is canonical — deliberately NOT
|
|
# from the Gitea API this workflow runs on. The Codeberg -> Gitea sync
|
|
# is a push mirror, i.e. `git push --mirror`, which deletes refs the
|
|
# source does not have. A tag minted here on Gitea is therefore wiped
|
|
# by the next sync (Codeberg does not have it yet) and only reappears
|
|
# once the tag push at the end of this workflow propagates back.
|
|
# Asking Gitea inside that window would report "no tag" for a release
|
|
# that already shipped, and cut it a second time.
|
|
# Public repo, so this read needs no token.
|
|
TAG_API: https://codeberg.org/api/v1/repos/jlmakiola/calendula
|
|
run: |
|
|
set -e
|
|
VERSION=$(grep -oP 'versionName\s*=\s*"\K[^"]+' app/build.gradle.kts)
|
|
if [ -z "$VERSION" ]; then echo "No versionName in app/build.gradle.kts" >&2; exit 1; fi
|
|
MAJOR=$(echo "$VERSION" | cut -d. -f1); MINOR=$(echo "$VERSION" | cut -d. -f2); PATCH=$(echo "$VERSION" | cut -d. -f3)
|
|
MAJOR=${MAJOR:-0}; MINOR=${MINOR:-0}; PATCH=${PATCH:-0}
|
|
VERSION_CODE=$(( MAJOR * 10000 + MINOR * 100 + PATCH ))
|
|
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
|
echo "version_code=$VERSION_CODE" >> "$GITHUB_OUTPUT"
|
|
echo "Resolved version $VERSION (code $VERSION_CODE)"
|
|
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
|
echo "Manual dispatch — re-sign path, not a release."
|
|
echo "is_release=false" >> "$GITHUB_OUTPUT"
|
|
exit 0
|
|
fi
|
|
# A tag for this version already existing means the release shipped on
|
|
# an earlier push; do nothing. Absent => this merge cuts the release.
|
|
#
|
|
# Anything other than a clean 200/404 is treated as fatal rather than
|
|
# as "no tag". A Codeberg outage or a network blip would otherwise
|
|
# read as absent and re-cut a release that has already shipped —
|
|
# republishing to F-Droid and Play. Failing here is recoverable; a
|
|
# duplicate release is not.
|
|
STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$TAG_API/git/refs/tags/v$VERSION" || echo 000)
|
|
case "$STATUS" in
|
|
200)
|
|
echo "Tag v$VERSION already exists on Codeberg — nothing to release."
|
|
echo "is_release=false" >> "$GITHUB_OUTPUT"
|
|
;;
|
|
404)
|
|
echo "No tag for v$VERSION on Codeberg yet — cutting the release."
|
|
echo "is_release=true" >> "$GITHUB_OUTPUT"
|
|
;;
|
|
*)
|
|
echo "Codeberg tag lookup for v$VERSION returned HTTP $STATUS." >&2
|
|
echo "Refusing to guess: treating this as 'no tag' could re-cut a shipped release." >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
|
|
# 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).
|
|
release:
|
|
needs: detect
|
|
if: needs.detect.outputs.is_release == 'true' || github.event_name == 'workflow_dispatch'
|
|
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 }}
|
|
IS_RELEASE: ${{ needs.detect.outputs.is_release }}
|
|
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
|
|
|
|
# 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.
|
|
- name: Pin versionCode to versionName
|
|
if: env.IS_RELEASE == 'true'
|
|
run: |
|
|
set -e
|
|
sed -i "s/versionCode = .*/versionCode = $VERSION_CODE/" app/build.gradle.kts
|
|
grep -E 'versionName|versionCode' app/build.gradle.kts
|
|
|
|
# Test the exact commit being shipped (only on a real release).
|
|
- name: Unit tests
|
|
if: env.IS_RELEASE == 'true'
|
|
run: ./gradlew testDebugUnitTest
|
|
|
|
- name: Setup Android Keystore
|
|
if: env.IS_RELEASE == 'true'
|
|
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
|
|
if: env.IS_RELEASE == 'true'
|
|
run: ./gradlew assembleRelease
|
|
|
|
- name: Setup F-Droid Server Tools
|
|
run: |
|
|
SUDO=""
|
|
if command -v sudo >/dev/null 2>&1; then SUDO="sudo"; fi
|
|
$SUDO apt-get update
|
|
$SUDO apt-get install -y sshpass python3-pip
|
|
pip3 install --break-system-packages --upgrade fdroidserver
|
|
|
|
- name: Fetch existing F-Droid repo from Hetzner
|
|
env:
|
|
HOST: ${{ secrets.HETZNER_HOST }}
|
|
USER: ${{ secrets.HETZNER_USER }}
|
|
PASS: ${{ secrets.HETZNER_PASS }}
|
|
run: |
|
|
set -euo pipefail
|
|
SSH_OPTS="-o StrictHostKeyChecking=no -o ConnectTimeout=20"
|
|
mkdir -p fdroid
|
|
# Pull only the published repo/ (all apps' APKs), any per-app
|
|
# metadata, and the repo icon — enough to rebuild the index without
|
|
# dropping the other apps. The signing key is deliberately NOT pulled
|
|
# from the box; it comes from CI secrets in the next step so it never
|
|
# has to live in the web-served tree.
|
|
sshpass -p "$PASS" scp $SSH_OPTS -r "$USER@$HOST:dev/fdroid/repo" fdroid/ 2>/dev/null || true
|
|
sshpass -p "$PASS" scp $SSH_OPTS -r "$USER@$HOST:dev/fdroid/metadata" fdroid/ 2>/dev/null || true
|
|
sshpass -p "$PASS" scp $SSH_OPTS "$USER@$HOST:dev/fdroid/icon.png" fdroid/ 2>/dev/null || true
|
|
mkdir -p fdroid/repo fdroid/metadata
|
|
|
|
- name: Restore F-Droid signing key and config from secrets
|
|
env:
|
|
FDROID_KEYSTORE_BASE64: ${{ secrets.FDROID_KEYSTORE_BASE64 }}
|
|
FDROID_CONFIG_BASE64: ${{ secrets.FDROID_CONFIG_BASE64 }}
|
|
run: |
|
|
set -euo pipefail
|
|
# Fail loudly if the repo key is not configured. NEVER auto-generate
|
|
# one: a fresh key changes the repo fingerprint and breaks every
|
|
# user's pinned repo.
|
|
if [ -z "${FDROID_KEYSTORE_BASE64:-}" ] || [ -z "${FDROID_CONFIG_BASE64:-}" ]; then
|
|
echo "ERROR: FDROID_KEYSTORE_BASE64 / FDROID_CONFIG_BASE64 secrets are not set." >&2
|
|
echo "Refusing to continue — will not auto-generate a new repo key." >&2
|
|
exit 1
|
|
fi
|
|
echo "$FDROID_KEYSTORE_BASE64" | base64 --decode > fdroid/keystore.p12
|
|
echo "$FDROID_CONFIG_BASE64" | base64 --decode > fdroid/config.yml
|
|
test -s fdroid/keystore.p12
|
|
test -s fdroid/config.yml
|
|
mkdir -p fdroid/repo/icons
|
|
|
|
- name: Copy new APK to repo
|
|
if: env.IS_RELEASE == 'true'
|
|
run: |
|
|
set -e
|
|
mkdir -p fdroid/repo
|
|
cp app/build/outputs/apk/release/app-release.apk "fdroid/repo/calendula_v${VERSION}.apk"
|
|
|
|
# Per-version "What's New": ensure this version's changelog exists in the
|
|
# fastlane tree (committed at release-cut time for the official repo; this
|
|
# regenerates it from CHANGELOG.md so the self-hosted repo never depends on
|
|
# the commit having happened). The transform below then carries it across.
|
|
- name: Ensure this version's changelog is in the fastlane tree
|
|
if: env.IS_RELEASE == 'true'
|
|
run: bash scripts/sync_changelog_to_fastlane.sh
|
|
|
|
- name: Build F-Droid metadata from fastlane (single source of truth)
|
|
run: |
|
|
mkdir -p fdroid/metadata
|
|
# App-level control file (Categories/License/links) for the self-hosted
|
|
# repo's `fdroid update`.
|
|
cp fdroid-metadata/de.jeanlucmakiola.calendula.yml fdroid/metadata/
|
|
# Localized text + graphics + per-version changelogs come from the SAME
|
|
# fastlane tree the official F-Droid repo harvests from source,
|
|
# transformed into the F-Droid repo "localized" layout. One source of
|
|
# truth, both channels.
|
|
bash scripts/fastlane_to_fdroid_localized.sh \
|
|
fastlane/metadata/android \
|
|
fdroid/metadata/de.jeanlucmakiola.calendula
|
|
|
|
- name: Generate F-Droid Index
|
|
run: |
|
|
cd fdroid
|
|
fdroid update -c
|
|
|
|
- name: Upload repo/ to Hetzner
|
|
env:
|
|
HOST: ${{ secrets.HETZNER_HOST }}
|
|
USER: ${{ secrets.HETZNER_USER }}
|
|
PASS: ${{ secrets.HETZNER_PASS }}
|
|
run: |
|
|
set -euo pipefail
|
|
SSH_OPTS="-o StrictHostKeyChecking=no -o ConnectTimeout=20"
|
|
sshpass -p "$PASS" sftp $SSH_OPTS "$USER@$HOST" <<'SFTP'
|
|
-mkdir dev
|
|
-mkdir dev/fdroid
|
|
SFTP
|
|
# Publish the signed repo/ plus metadata/ (descriptions, screenshots,
|
|
# per-version changelogs) so changelog history survives across
|
|
# releases. keystore.p12 and config.yml are NEVER uploaded.
|
|
sshpass -p "$PASS" scp $SSH_OPTS -r fdroid/repo fdroid/metadata "$USER@$HOST:dev/fdroid/"
|
|
|
|
# The APK is published and the index re-signed — now record the release.
|
|
# 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).
|
|
- name: Create tag + Gitea release
|
|
if: env.IS_RELEASE == 'true'
|
|
env:
|
|
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
|
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" <<'PY' > payload.json
|
|
import json, sys
|
|
print(json.dumps({
|
|
"tag_name": sys.argv[1],
|
|
"target_commitish": sys.argv[2],
|
|
"name": sys.argv[1],
|
|
"body": open("release-notes.md").read(),
|
|
"draft": False,
|
|
"prerelease": False,
|
|
}))
|
|
PY
|
|
# Upsert (re-run safe): PATCH if a release for the tag already exists,
|
|
# else POST a new one (which also creates the tag at target_commitish).
|
|
curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" > existing.json
|
|
ID=$(jq -r '.id // empty' existing.json 2>/dev/null || true)
|
|
if [ -n "$ID" ]; then
|
|
CODE=$(curl -s -o response.json -w '%{http_code}' -X PATCH \
|
|
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
|
-d @payload.json "$API/releases/$ID")
|
|
OK=200
|
|
else
|
|
CODE=$(curl -s -o response.json -w '%{http_code}' -X POST \
|
|
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
|
-d @payload.json "$API/releases")
|
|
OK=201
|
|
fi
|
|
cat response.json
|
|
if [ "$CODE" != "$OK" ]; then
|
|
echo "Release upsert failed with HTTP $CODE (expected $OK)" >&2
|
|
exit 1
|
|
fi
|
|
echo "Created/updated release $TAG at $SHA"
|
|
|
|
# Archive the R8 mapping so user crash stacktraces stay deobfuscatable.
|
|
# Attached to the release (it's not an APK, so it fits the no-binaries
|
|
# rule). Best-effort: never fail a release over it.
|
|
- name: Attach R8 mapping to Gitea release
|
|
if: env.IS_RELEASE == 'true'
|
|
continue-on-error: true
|
|
env:
|
|
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
|
run: |
|
|
set -e
|
|
MAP="app/build/outputs/mapping/release/mapping.txt"
|
|
if [ ! -f "$MAP" ]; then echo "No mapping.txt (R8 off?) — skipping."; exit 0; fi
|
|
TAG="v$VERSION"
|
|
ASSET="mapping-${VERSION}.txt.gz"
|
|
gzip -c "$MAP" > "/tmp/$ASSET"
|
|
ID=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty')
|
|
if [ -z "$ID" ]; then echo "Could not resolve release id — skipping."; exit 0; fi
|
|
# Replace any prior asset of the same name (re-run safe).
|
|
OLD=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/$ID/assets" \
|
|
| jq -r --arg n "$ASSET" '.[] | select(.name==$n) | .id')
|
|
[ -n "$OLD" ] && curl -s -X DELETE -H "Authorization: token $TOKEN" "$API/releases/$ID/assets/$OLD" >/dev/null || true
|
|
curl -s -X POST -H "Authorization: token $TOKEN" \
|
|
-F "attachment=@/tmp/$ASSET" \
|
|
"$API/releases/$ID/assets?name=$ASSET" -o /dev/null -w "asset upload HTTP %{http_code}\n"
|
|
|
|
# Mirror the release to the Codeberg mirror as a direct-download channel
|
|
# for users who don't want F-Droid. Gitea already push-mirrors branches +
|
|
# tags to Codeberg, but releases aren't git objects so they don't sync —
|
|
# we create the release there over the API and attach the signed APK plus
|
|
# a SHA-256 checksum. The APK is identical to the F-Droid one (same app
|
|
# key), so this adds no trust surface. Best-effort: a Codeberg outage
|
|
# (it 504s under load) must never fail an already-published F-Droid
|
|
# release. Needs the CODEBERG_RELEASE_TOKEN secret; skips cleanly if unset.
|
|
- name: Publish release to Codeberg
|
|
if: env.IS_RELEASE == 'true'
|
|
continue-on-error: true
|
|
env:
|
|
TOKEN: ${{ secrets.CODEBERG_RELEASE_TOKEN }}
|
|
API: https://codeberg.org/api/v1/repos/jlmakiola/calendula
|
|
SHA: ${{ github.sha }}
|
|
run: |
|
|
set -e
|
|
if [ -z "${TOKEN:-}" ]; then
|
|
echo "CODEBERG_RELEASE_TOKEN not set — skipping Codeberg publish."
|
|
exit 0
|
|
fi
|
|
TAG="v$VERSION"
|
|
APK="app/build/outputs/apk/release/app-release.apk"
|
|
if [ ! -f "$APK" ]; then echo "No release APK found — skipping." >&2; exit 1; fi
|
|
ASSET_APK="calendula_v${VERSION}.apk"
|
|
ASSET_SUM="${ASSET_APK}.sha256"
|
|
cp "$APK" "/tmp/$ASSET_APK"
|
|
( cd /tmp && sha256sum "$ASSET_APK" > "$ASSET_SUM" )
|
|
|
|
# Release notes: reuse the section extracted for the Gitea release,
|
|
# fall back to the CHANGELOG entry if that step's file is gone.
|
|
if [ ! -s release-notes.md ]; then
|
|
awk -v ver="$VERSION" '
|
|
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
|
|
/^## \[/ { flag = 0 }
|
|
flag' CHANGELOG.md > release-notes.md
|
|
sed -i -e '/./,$!d' release-notes.md
|
|
fi
|
|
[ -s release-notes.md ] || echo "_See CHANGELOG.md for ${VERSION}._" > release-notes.md
|
|
# The pipeline creates the tag via the Gitea API, which the push mirror
|
|
# (sync_on_commit only fires on real git pushes) doesn't propagate
|
|
# promptly — so a release POST that carries a target_commitish can
|
|
# outrun the mirror and 500 on a commit/tag Codeberg hasn't received.
|
|
# Push the tag straight to Codeberg so it's guaranteed present, then
|
|
# attach the release to that existing tag with NO target_commitish
|
|
# (which is what triggered the 500).
|
|
git tag -f "$TAG" "$SHA"
|
|
git push -f "https://jlmakiola:${TOKEN}@codeberg.org/jlmakiola/calendula.git" \
|
|
"refs/tags/$TAG"
|
|
python3 - "$TAG" <<'PY' > cb-payload.json
|
|
import json, sys
|
|
print(json.dumps({
|
|
"tag_name": sys.argv[1],
|
|
"name": sys.argv[1],
|
|
"body": open("release-notes.md").read(),
|
|
"draft": False,
|
|
"prerelease": False,
|
|
}))
|
|
PY
|
|
# Create (or update) the release. Codeberg 500s on a POST/GET against a
|
|
# tag it has only just received — the release request outruns the
|
|
# indexing of the ref we pushed a moment ago — so a single attempt kept
|
|
# failing and skipping the mirror even though the very same call
|
|
# succeeds seconds later. Retry with backoff, and PATCH in place if a
|
|
# release already exists (re-run safe). A 5xx body still exits curl 0,
|
|
# so the loop, not `set -e`, controls the flow.
|
|
ID=""
|
|
for attempt in 1 2 3 4 5 6; do
|
|
EXIST=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty' 2>/dev/null || true)
|
|
if [ -n "$EXIST" ]; then
|
|
curl -s -o /dev/null -w "release PATCH HTTP %{http_code}\n" -X PATCH \
|
|
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
|
-d @cb-payload.json "$API/releases/$EXIST"
|
|
ID="$EXIST"; break
|
|
fi
|
|
CODE=$(curl -s -o cb-response.json -w "%{http_code}" -X POST \
|
|
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
|
-d @cb-payload.json "$API/releases")
|
|
echo "release POST attempt $attempt HTTP $CODE"
|
|
ID=$(jq -r '.id // empty' cb-response.json 2>/dev/null || true)
|
|
[ -n "$ID" ] && break
|
|
sleep $((attempt * 10))
|
|
done
|
|
if [ -z "$ID" ]; then echo "Could not resolve Codeberg release id after retries." >&2; exit 1; fi
|
|
|
|
# Attach APK + checksum, replacing any prior asset of the same name.
|
|
for A in "$ASSET_APK" "$ASSET_SUM"; do
|
|
OLD=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/$ID/assets" \
|
|
| jq -r --arg n "$A" '.[] | select(.name==$n) | .id')
|
|
[ -n "$OLD" ] && curl -s -X DELETE -H "Authorization: token $TOKEN" "$API/releases/$ID/assets/$OLD" >/dev/null || true
|
|
curl -s -X POST -H "Authorization: token $TOKEN" \
|
|
-F "attachment=@/tmp/$A" \
|
|
"$API/releases/$ID/assets?name=$A" -o /dev/null -w "asset $A HTTP %{http_code}\n"
|
|
done
|
|
echo "Published $TAG to Codeberg."
|
|
|
|
# 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.
|
|
#
|
|
# 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 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.
|
|
- 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.
|
|
- name: Hand the AAB to the Play job
|
|
if: env.IS_RELEASE == 'true'
|
|
continue-on-error: true
|
|
uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7 # v4
|
|
with:
|
|
name: release-aab-${{ needs.detect.outputs.version }}
|
|
path: app/build/outputs/bundle/release/app-release.aab
|
|
if-no-files-found: error
|
|
retention-days: 14
|
|
|
|
# Google Play channel.
|
|
#
|
|
# 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.
|
|
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.
|
|
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. `internal` by default so a release reaches
|
|
# testers rather than the public, and promotion to production stays a
|
|
# deliberate human action in the Play Console — the same posture as
|
|
# holding UI releases for on-device review. Override with the PLAY_TRACK
|
|
# repo variable once the flow is trusted.
|
|
PLAY_TRACK: ${{ vars.PLAY_TRACK || 'internal' }}
|
|
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.
|
|
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.
|
|
- name: Write the Play service-account key
|
|
id: key
|
|
env:
|
|
PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
|
|
run: |
|
|
set -euo pipefail
|
|
if [ -z "${PLAY_SERVICE_ACCOUNT_JSON:-}" ]; then
|
|
echo "PLAY_SERVICE_ACCOUNT_JSON not set — skipping the Play upload."
|
|
echo "configured=false" >> "$GITHUB_OUTPUT"
|
|
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.
|
|
- name: Download the AAB
|
|
if: steps.key.outputs.configured == 'true'
|
|
uses: https://github.com/ChristopherHX/gitea-download-artifact@75635f32b4c1c41c4b3d64e8f85210112ed4c9c7 # v4
|
|
with:
|
|
name: release-aab-${{ needs.detect.outputs.version }}
|
|
path: dist
|
|
|
|
- name: Install Ruby
|
|
if: steps.key.outputs.configured == 'true'
|
|
run: |
|
|
set -euo pipefail
|
|
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.
|
|
$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
|
|
with:
|
|
path: vendor/bundle
|
|
key: ${{ runner.os }}-gems-${{ hashFiles('Gemfile') }}
|
|
restore-keys: |
|
|
${{ runner.os }}-gems-
|
|
|
|
- name: Install fastlane
|
|
if: steps.key.outputs.configured == 'true'
|
|
run: |
|
|
set -euo pipefail
|
|
gem install bundler --no-document
|
|
bundle config set --local path vendor/bundle
|
|
bundle install --jobs 4
|
|
bundle exec fastlane --version
|
|
|
|
- name: Upload to Play
|
|
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
|
|
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 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" \
|
|
track:"$PLAY_TRACK" \
|
|
release_status:"$PLAY_RELEASE_STATUS" \
|
|
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.
|
|
- name: Shred the service-account key
|
|
if: always()
|
|
run: shred -u play-service-account.json 2>/dev/null || rm -f play-service-account.json
|