Auditing the gating turned up two problems. verify-release only checked the APK. Because it runs with `always()`, it runs even when image-release FAILED — so android succeeding while the image push died would have reported "verified" on a release with no immutable :vYYYY.MM.DD image. That is exactly half of what was missing when v2026.08.07 had to be re-cut, so the guard would have caught the incident we had and waved through its mirror image. Now checks the image too, via docker manifest inspect. "Attach APK to gitea Release" resolves the release by tag and fails if it is absent — but it is the LAST step, so a bare `git push origin vX` built an APK for several minutes before discovering it had nowhere to put it. Same check now runs immediately after version computation: seconds, not minutes. Releases created through the API create tag and release together and pass it. The rest of the gating audits clean, and one part is worth not "fixing": image-release's `if: !failure() && !cancelled()` looks odd next to `needs: [android-release]` but is correct. On main pushes android-release is SKIPPED, and a skipped dependency is not success() — so the obvious `if: success()` would silently stop main from ever publishing :latest. Steps 4/5 vs 6 are mutually exclusive on the tag context, and every image step gates on the Dockerfile+go.mod guard. Validated: YAML parses, and `bash -n` over every run: block in all three jobs is clean.
426 lines
19 KiB
YAML
426 lines
19 KiB
YAML
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})"
|
|
|
|
# Checked BEFORE the expensive work, not after it. "Attach APK to gitea
|
|
# Release" below resolves the release by tag and fails if it is absent —
|
|
# but that is the final step, so a tag pushed without a release built an
|
|
# APK for several minutes first and only then discovered it had nowhere to
|
|
# put it. Same check, seconds in instead of minutes.
|
|
#
|
|
# Releases are normally created through the API (which creates the tag and
|
|
# the release together, so this passes). A bare `git push origin vX` is the
|
|
# case this catches.
|
|
- name: Release must exist for this tag
|
|
shell: bash
|
|
working-directory: ${{ github.workspace }}
|
|
env:
|
|
CI_TOKEN: ${{ secrets.CI_TOKEN }}
|
|
run: |
|
|
set -euo pipefail
|
|
TAG="${GITHUB_REF#refs/tags/}"
|
|
if ! curl -fsSL -o /dev/null \
|
|
-H "Authorization: token ${CI_TOKEN}" \
|
|
"https://git.fabledsword.com/api/v1/repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}"; then
|
|
echo "::error::no release exists for ${TAG}. Create the release (which creates the tag) rather than pushing a bare tag — otherwise there is nothing to attach the APK to."
|
|
exit 1
|
|
fi
|
|
echo "::notice::release found for ${TAG}"
|
|
|
|
- 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
|
|
# Mirrored action, never actions/upload-artifact — @v4+ refuses on the
|
|
# hostname, @v3 uploads something Gitea will never serve back. This is
|
|
# the producing half of a pair: image-release downloads `minstrel-apk`
|
|
# below with the matching download-artifact mirror. Both must stay on
|
|
# the v4 protocol — mixing a v3 upload with a v4 download (or the
|
|
# reverse) yields an empty listing, not an error. See Scribe 2255 / 2270.
|
|
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
|
|
with:
|
|
name: minstrel-apk
|
|
path: android/app/build/outputs/apk/release/app-release.apk
|
|
# error, not the default warn: image-release hard-depends on this
|
|
# artifact existing, so an empty upload must fail here, not there.
|
|
if-no-files-found: error
|
|
|
|
- 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')
|
|
# Consuming half of the pair — never actions/download-artifact. Same fork,
|
|
# same reason: upstream's client-side GHES check rejects this hostname
|
|
# before it connects. bvandeusen/download-artifact mirrors
|
|
# code.forgejo.org/forgejo/download-artifact.
|
|
#
|
|
# SHA below is that fork's `v6` tag. Match on @actions/artifact, NOT on
|
|
# the action's own version number — the two actions release on unrelated
|
|
# cadences, and download v5 would pair a ^2.3.2 client with this file's
|
|
# ^4.0.0 uploader. v6 is the tag whose bundled library major (^4.0.0) is
|
|
# the same one proven against this instance by the upload side.
|
|
# Deliberately NOT v7: it moves to node24 and upstream requires runner
|
|
# >= 2.327.1 for it, which act_runner does not claim to satisfy.
|
|
# Pinned, not tagged — the mirror auto-syncs every 8h.
|
|
uses: https://git.fabledsword.com/bvandeusen/download-artifact@8d4e9521a5f7e5f8b6351f341f719f9f45a92a3a
|
|
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 }} .
|
|
|
|
# Verifies a tag release actually ended up complete, and names the specific
|
|
# thing that's missing if not.
|
|
#
|
|
# Added 2026-08-07 after v2026.08.07 was re-cut. The android-release job never
|
|
# started — no log was written at all — so all eight of its steps reported
|
|
# `failure` with none executed and image-release showed `skipped`. The run was
|
|
# red, but the *release page rendered fine*, and `main`'s own push build had
|
|
# already moved `:latest`, so the code was deployable and nothing looked
|
|
# obviously wrong. The release was simply missing its APK and its immutable
|
|
# `:vYYYY.MM.DD` image, which is easy to skim past.
|
|
#
|
|
# This job cannot prevent that (the cause was a runner failing to launch, not
|
|
# anything in this file). What it does is turn an incomplete release into an
|
|
# explicit, named error instead of eight mystery step failures — so the
|
|
# consequence is legible without having to infer it.
|
|
#
|
|
# `if: always()` is the whole point: it has to report precisely when the jobs
|
|
# above did NOT succeed.
|
|
verify-release:
|
|
name: Verify release artifacts (tag releases only)
|
|
needs: [android-release, image-release]
|
|
if: ${{ always() && startsWith(github.ref, 'refs/tags/v') }}
|
|
runs-on: go-ci
|
|
container:
|
|
image: git.fabledsword.com/bvandeusen/ci-go:1.26
|
|
|
|
steps:
|
|
- name: Release must have an APK attached
|
|
shell: bash
|
|
env:
|
|
CI_TOKEN: ${{ secrets.CI_TOKEN }}
|
|
run: |
|
|
set -euo pipefail
|
|
TAG="${GITHUB_REF#refs/tags/}"
|
|
REPO="${GITHUB_REPOSITORY}"
|
|
|
|
REL_JSON="$(curl -fsSL \
|
|
-H "Authorization: token ${CI_TOKEN}" \
|
|
"https://git.fabledsword.com/api/v1/repos/${REPO}/releases/tags/${TAG}" || true)"
|
|
if [ -z "${REL_JSON}" ]; then
|
|
echo "::error::no release found for ${TAG} — the tag exists but nothing was published"
|
|
exit 1
|
|
fi
|
|
|
|
APK="$(printf '%s' "${REL_JSON}" \
|
|
| grep -oP '"browser_download_url":\s*"\K[^"]+' \
|
|
| grep -E '\.apk$' | head -1 || true)"
|
|
if [ -z "${APK}" ]; then
|
|
echo "::error::release ${TAG} has NO APK attached — in-app update will offer nothing, and the bundled-APK path on future :latest builds has no source."
|
|
echo "::error::Fix by RE-RUNNING this workflow run. Do NOT delete and re-create the tag; if it fails again the runner never started the container, and the evidence is in act_runner on the host (Gitea will hold no job log)."
|
|
exit 1
|
|
fi
|
|
|
|
echo "::notice::APK attached: ${APK}"
|
|
|
|
# The other half. Checking only the APK would report success on a release
|
|
# whose image push failed — which is precisely the second thing that was
|
|
# missing when v2026.08.07 had to be re-cut. `always()` on this job means
|
|
# it runs even when image-release failed, so without this the guard would
|
|
# cheerfully verify an incomplete release.
|
|
- name: Immutable image tag must exist
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
TAG="${GITHUB_REF#refs/tags/}"
|
|
IMAGE="git.fabledsword.com/bvandeusen/minstrel"
|
|
|
|
echo "${{ secrets.CI_TOKEN }}" \
|
|
| docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin
|
|
|
|
if ! docker manifest inspect "${IMAGE}:${TAG}" > /dev/null 2>&1; then
|
|
echo "::error::image ${IMAGE}:${TAG} was never pushed — the release tag has no immutable image, so there is nothing to pin or roll back to. Re-run this workflow run."
|
|
exit 1
|
|
fi
|
|
echo "::notice::image verified: ${IMAGE}:${TAG}"
|