name: release # Builds and pushes the minstrel container image to the Gitea registry. # # push to main → :main and :latest (latest-release APK bundled) # push tag vYYYY.MM.DD → :vYYYY.MM.DD and :latest (freshly-built APK bundled) # workflow_dispatch → manual trigger (same rules based on the ref) # # Release model: per-day CalVer tags (no trailing patch digit). The day's # tag is intentionally mutable — if a second release happens the same day, # move the tag with `git push -f origin vYYYY.MM.DD` and the image tag of # the same name gets overwritten. :latest is updated by every main push # AND every tag push, so it always reflects the newest blessed image. # # APK pipeline: on tag pushes the android-release job builds + signs the # Android APK and uploads it as a workflow artifact. The image-release # job declares `needs: android-release`, so the docker image cannot # start building until the APK is guaranteed-ready — no polling, no # race, no silent-failure mode. Asset attachment to the gitea Release # happens in the same android-release job, so the Release-page download # link and the in-image bundled APK are both populated atomically. # # :latest always carries an APK. Because every main push also moves # :latest (not just tags), a main build with no APK would silently strip # the in-app update channel off :latest until the next release. So on # non-tag builds image-release pulls the MOST RECENT release's signed APK # and reconstructs its exact versionName (tag + commit-count, the same # formula android-release bakes in) for the version sidecar — no rebuild, # just rebundle. Tag builds keep bundling their own freshly-built APK. # # Android testing (lint + detekt + unit tests, debug APK upload on main) # lives in android.yml and runs independently on every push. on: push: branches: [main] tags: ['v*'] paths-ignore: - 'docs/**' - '**/*.md' workflow_dispatch: # Force-moving the per-day tag (or rapidly re-pushing to main) should # supersede the in-flight build — the operator explicitly wants the # later commit to win. concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: android-release: name: Build signed APK (tag releases only) if: startsWith(github.ref, 'refs/tags/v') runs-on: flutter-ci container: image: git.fabledsword.com/bvandeusen/ci-android:36 defaults: run: working-directory: android env: JAVA_TOOL_OPTIONS: "--enable-native-access=ALL-UNNAMED" # PKCS12 keystores collapse store + key password into a single # value; both env vars map to one secret. build.gradle reads them # separately to stay format-agnostic. ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} # Job outputs propagate the computed release version to image-release # so the bundled sidecar file matches what's baked into the APK — # otherwise the server would report a different version string than # the installed client and the update banner could thrash. outputs: version_name: ${{ steps.ver.outputs.name }} version_code: ${{ steps.ver.outputs.code }} steps: - name: Checkout uses: actions/checkout@v4 with: # fetch-depth: 0 retrieves full history; default shallow clone # would return 1 for `git rev-list --count HEAD`, breaking the # iteration suffix. fetch-depth: 0 - name: Compute release version id: ver shell: bash working-directory: ${{ github.workspace }} run: | set -euo pipefail TAG="${GITHUB_REF#refs/tags/v}" COMMIT_COUNT=$(git rev-list --count HEAD) VERSION_NAME="${TAG}.${COMMIT_COUNT}" echo "name=${VERSION_NAME}" >> "$GITHUB_OUTPUT" echo "code=${COMMIT_COUNT}" >> "$GITHUB_OUTPUT" echo "::notice::APK version: ${VERSION_NAME} (code=${COMMIT_COUNT})" - name: Cache Gradle dirs uses: actions/cache@v4 with: path: | ~/.gradle/caches ~/.gradle/wrapper ~/.kotlin key: gradle-${{ runner.os }}-${{ hashFiles('android/gradle/wrapper/gradle-wrapper.properties', 'android/gradle/libs.versions.toml', 'android/**/*.gradle.kts') }} restore-keys: | gradle-${{ runner.os }}- - name: Make gradlew executable run: chmod +x ./gradlew - name: Decode signing keystore env: ANDROID_KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_B64 }} shell: bash run: | if [ -z "${ANDROID_KEYSTORE_B64}" ]; then echo "::error::ANDROID_KEYSTORE_B64 missing"; exit 1 fi KEYSTORE_PATH="${RUNNER_TEMP}/minstrel-release.keystore" echo "${ANDROID_KEYSTORE_B64}" | base64 -d > "${KEYSTORE_PATH}" echo "ANDROID_KEYSTORE_PATH=${KEYSTORE_PATH}" >> "${GITHUB_ENV}" - name: Build release APK run: | ./gradlew assembleRelease \ -PMINSTREL_VERSION_NAME=${{ steps.ver.outputs.name }} \ -PMINSTREL_VERSION_CODE=${{ steps.ver.outputs.code }} - name: Upload APK as workflow artifact # @v3 because Gitea Actions emulates GHES and the v2 artifact # backend used by upload-artifact@v4 errors with GHESNotSupportedError. uses: actions/upload-artifact@v3 with: name: minstrel-apk path: android/app/build/outputs/apk/release/app-release.apk - name: Attach APK to gitea Release shell: bash env: CI_TOKEN: ${{ secrets.CI_TOKEN }} run: | set -euxo pipefail TAG="${GITHUB_REF#refs/tags/}" REPO="${GITHUB_REPOSITORY}" APK_PATH="app/build/outputs/apk/release/app-release.apk" ls -lh "${APK_PATH}" RELEASE_JSON="$(curl -fsSL \ -H "Authorization: token ${CI_TOKEN}" \ "https://git.fabledsword.com/api/v1/repos/${REPO}/releases/tags/${TAG}")" RELEASE_ID="$(printf '%s' "${RELEASE_JSON}" | grep -oP '"id":\s*\K[0-9]+' | head -1)" if [ -z "${RELEASE_ID}" ]; then echo "::error::release for ${TAG} not found"; exit 1 fi echo "release_id=${RELEASE_ID}" UPLOAD_HTTP=$(curl -sS -L -o /tmp/upload.out -w '%{http_code}' \ -H "Authorization: token ${CI_TOKEN}" \ -F "attachment=@${APK_PATH}" \ "https://git.fabledsword.com/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=minstrel-${TAG}.apk") echo "upload_http=${UPLOAD_HTTP}" cat /tmp/upload.out || true echo if [ "${UPLOAD_HTTP}" -lt 200 ] || [ "${UPLOAD_HTTP}" -ge 300 ]; then echo "::error::APK upload returned HTTP ${UPLOAD_HTTP}" exit 1 fi image-release: name: Build + push container image # `needs:` waits for android-release. For tag pushes android-release # runs and must succeed before this job starts — guaranteeing the # APK artifact is present. For main pushes android-release is # skipped; the `if: ...` below lets this job run anyway and the # download/copy steps gate themselves on the tag context. needs: [android-release] if: ${{ !failure() && !cancelled() }} runs-on: go-ci container: image: git.fabledsword.com/bvandeusen/ci-go:1.26 env: IMAGE: git.fabledsword.com/bvandeusen/minstrel steps: - name: Checkout uses: actions/checkout@v4 with: # Full history + tags so non-tag :latest builds can resolve the # latest release tag's commit count and reconstruct the bundled # APK's exact versionName (see "Bundle latest release APK" below). fetch-depth: 0 fetch-tags: true - name: Detect buildable project id: guard shell: bash run: | if [ -f Dockerfile ] && [ -f go.mod ]; then echo "ready=true" >> "$GITHUB_OUTPUT" else echo "ready=false" >> "$GITHUB_OUTPUT" echo "::notice::No Dockerfile + go.mod yet — release build skipped" fi - name: Compute image tags id: tags if: steps.guard.outputs.ready == 'true' shell: bash run: | if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then VERSION="${GITHUB_REF#refs/tags/}" echo "args=-t ${IMAGE}:${VERSION} -t ${IMAGE}:latest" >> "$GITHUB_OUTPUT" echo "version=${VERSION}" >> "$GITHUB_OUTPUT" echo "::notice::Release build: ${VERSION} + latest" else # Main is the protected, post-PR-merge branch. Treat it as the # rolling stable channel — every main push moves :latest. # Pinned consumers can target :vYYYY.MM.DD; everyone else # gets the newest main. echo "args=-t ${IMAGE}:main -t ${IMAGE}:latest" >> "$GITHUB_OUTPUT" echo "version=main" >> "$GITHUB_OUTPUT" echo "::notice::Main-branch build: :main + :latest" fi - name: Registry login if: steps.guard.outputs.ready == 'true' shell: bash run: | echo "${{ secrets.CI_TOKEN }}" \ | docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin - name: Download signed APK artifact # Tag pushes only — android-release just produced this. Non-tag # builds take the "Bundle latest release APK" path below instead. if: steps.guard.outputs.ready == 'true' && startsWith(github.ref, 'refs/tags/v') uses: actions/download-artifact@v3 with: name: minstrel-apk path: client/ - name: Stage bundled APK + version sidecar if: steps.guard.outputs.ready == 'true' && startsWith(github.ref, 'refs/tags/v') shell: bash env: # Pulled from android-release.outputs.version_name so the # sidecar string the server hands clients matches the # versionName baked into the APK they're comparing against. APK_VERSION_NAME: ${{ needs.android-release.outputs.version_name }} run: | set -euxo pipefail # The artifact lands as `app-release.apk` (the original Gradle # output name). The Dockerfile COPYs client/* into /app/client/ # and the server reads minstrel.apk + minstrel.apk.version. mv client/app-release.apk client/minstrel.apk echo "${APK_VERSION_NAME}" > client/minstrel.apk.version ls -lh client/ - name: Bundle latest release APK (non-tag :latest builds) # Main pushes don't build an APK, but they DO move :latest — so # without this the in-app update channel would vanish from :latest # until the next tag. Pull the most-recent release's signed APK and # reconstruct its exact versionName (${TAG#v}.$(git rev-list --count # TAG) — identical to android-release's formula) so the version # sidecar the server hands clients matches the installed build. # Degrades to an empty client/ (404 update channel) — never a wrong # version — if no release / APK asset / tag-count can be resolved. if: steps.guard.outputs.ready == 'true' && !startsWith(github.ref, 'refs/tags/v') shell: bash env: CI_TOKEN: ${{ secrets.CI_TOKEN }} run: | set -eu REPO="${GITHUB_REPOSITORY}" REL_JSON="$(curl -fsSL -H "Authorization: token ${CI_TOKEN}" \ "https://git.fabledsword.com/api/v1/repos/${REPO}/releases/latest" || true)" if [ -z "${REL_JSON}" ]; then echo "::notice::no published release — image ships without bundled APK"; exit 0 fi TAG="$(printf '%s' "${REL_JSON}" | grep -oP '"tag_name":\s*"\K[^"]+' | head -1)" APK_URL="$(printf '%s' "${REL_JSON}" | grep -oP '"browser_download_url":\s*"\K[^"]+' | grep -E '\.apk$' | head -1)" if [ -z "${TAG}" ] || [ -z "${APK_URL}" ]; then echo "::notice::latest release '${TAG:-?}' has no APK asset — image ships without bundled APK"; exit 0 fi COUNT="$(git rev-list --count "${TAG}" 2>/dev/null || true)" if [ -z "${COUNT}" ]; then echo "::notice::could not resolve commit count for ${TAG} (tag not fetched?) — skipping APK bundle"; exit 0 fi VERSION_NAME="${TAG#v}.${COUNT}" curl -fsSL -H "Authorization: token ${CI_TOKEN}" -o client/minstrel.apk "${APK_URL}" echo "${VERSION_NAME}" > client/minstrel.apk.version echo "::notice::bundled release APK ${TAG} as version ${VERSION_NAME}" ls -lh client/ - name: Build and push if: steps.guard.outputs.ready == 'true' run: | docker buildx build \ --build-arg MINSTREL_VERSION="${{ steps.tags.outputs.version }}" \ --push ${{ steps.tags.outputs.args }} .