The client compares names while Android installs by versionCode, and the wire had no way to close that gap: the sidecar was one positional line and the endpoint returned a name only. This is the plumbing that makes the ordering key decidable by the client at all. The sidecar is now JSON rather than a grown positional string. That shape was chosen against a specific failure: the obvious growth path was "<name> <code>", which a first-space split silently mangles the moment a third field appears — the code stops parsing as an integer and the reader falls back to name comparison WITHOUT erroring. JSON cannot mistake a new field for an old one. code is a POINTER on both sides, and omitempty on the wire. Absent has to stay distinguishable from zero: a build published before ordering keys were recorded genuinely has no code, and zero would claim it is infinitely old rather than unknown. A malformed sidecar now fails loudly instead of serving a blank version. If an unreadable file produced an empty name, every client would compare against nothing, conclude it was current, and go quiet — "I cannot read this" and "there is nothing newer" would return the same answer, which is the failure mode nobody reports because nobody is offered anything to report. The non-tag :latest path no longer RECONSTRUCTS the bundled APK's version. android-release now publishes the sidecar as a release asset beside the APK, and the image build downloads it. The old reconstruction duplicated a derivation formula across two files, and could only ever recover the name — the ordering key is build-time minutes and exists nowhere once that build ends. Releases predating the sidecar report their name with a null code, which is the honest answer rather than a guessed one. image-release also drops to a shallow checkout: it needed full history and tags only to re-derive versions from the tagged commit, and now touches git for nothing. MINSTREL_VERSION comes from GITHUB_REF. Two things checked rather than assumed. The Android Json sets ignoreUnknownKeys, so the added fields cannot break already-installed apps. It also sets coerceInputValues, which will silently turn a null code into 0 if step 4 declares the field non-nullable — recorded on task #3811, because reading the field declaration alone would never reveal it. Also fixes a stale comment block describing "the Flutter client", deleted in v2026.08.18. Step 3 of 5 — Scribe task #3810, milestone #390. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
524 lines
25 KiB
YAML
524 lines
25 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.HHMM → :vYYYY.MM.DD.HHMM and :latest (fresh APK bundled)
|
|
# workflow_dispatch → manual trigger (same rules based on the ref)
|
|
#
|
|
# Release model: the tag IS the artifact's version name with a `v` in front.
|
|
# `v2026.09.10.1432` and `2026.09.10.1432` are the same string, derived from
|
|
# the tagged commit's UTC timestamp — so there is no mismatch to reconcile
|
|
# between what the tag says and what the APK reports, and nothing to look up
|
|
# when minting one.
|
|
#
|
|
# TAGS ARE IMMUTABLE. Never move, retarget or delete a published tag. A
|
|
# same-day second release is not a collision — HHMM makes every tag unique
|
|
# by construction, so the answer is simply another tag.
|
|
#
|
|
# This block used to say the opposite: that the per-day tag was
|
|
# "intentionally mutable" and that a same-day re-cut should
|
|
# `git push -f origin vYYYY.MM.DD`. That instruction is what the family
|
|
# rulebook now forbids outright, and it has incidents behind it — moving a
|
|
# same-day tag forward once took a published release down with it. Anyone
|
|
# installing from a tag is holding something the tag no longer points at,
|
|
# which is a worse failure than an extra row in the tag list.
|
|
#
|
|
# :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 the version sidecar published beside it — the recorded values, not
|
|
# recomputed ones — so no rebuild is needed, just a 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:
|
|
|
|
# A rapid re-push to main should supersede the in-flight build — the
|
|
# operator explicitly wants the later commit to win. Tags no longer enter
|
|
# into this: they are immutable and unique, so no tag build can ever be
|
|
# superseded by another run on the same ref.
|
|
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:
|
|
# Full history. The version name now reads only the tip commit's
|
|
# timestamp, so a shallow clone would technically serve — but this
|
|
# job derives a value that ships to devices, and a shallow checkout
|
|
# changes what git-derived values resolve to WITHOUT failing. The
|
|
# whole failure class here is a green build carrying a wrong
|
|
# version, so the cheap guarantee is worth keeping.
|
|
fetch-depth: 0
|
|
|
|
- name: Compute release version
|
|
id: ver
|
|
shell: bash
|
|
working-directory: ${{ github.workspace }}
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
# Two different clocks, deliberately. They answer different
|
|
# questions, and using one for both breaks whichever it fits worse.
|
|
#
|
|
# The NAME answers "is this the same code?" — so it derives from
|
|
# COMMIT time and reads identically on every lane that builds this
|
|
# source. A dev build and a main build of one commit must report the
|
|
# same string; build time cannot do that, it prints two numbers for
|
|
# one thing.
|
|
COMMIT_TS=$(git log --format=%ct -1 HEAD)
|
|
VERSION_NAME=$(date -u -d "@${COMMIT_TS}" +%Y.%m.%d.%H%M)
|
|
|
|
# The ORDERING KEY answers "may this be installed over that?" — so it
|
|
# must be monotonic BY CONSTRUCTION. Minutes since 2020-01-01: ~3.5M
|
|
# today, ~525k/year, against a 2^31 ceiling.
|
|
#
|
|
# This replaced `git rev-list --count HEAD`, which was NOT monotonic
|
|
# and was commented as if it were. A commit count runs ahead on `dev`,
|
|
# so a dev build outranked the `main` release that superseded it and
|
|
# Android refused the install as a downgrade — a channel you could
|
|
# enter and not leave without uninstalling.
|
|
#
|
|
# Commit time would be wrong here too, for the mirror-image reason:
|
|
# rebuild an older commit and it goes DOWN, which on a phone is a
|
|
# refused install rather than a merely confusing label.
|
|
VERSION_CODE=$(( ( $(date -u +%s) - 1577836800 ) / 60 ))
|
|
|
|
# Assert the emitted shape at the source. A malformed name still
|
|
# builds, signs and publishes perfectly happily, and only surfaces as
|
|
# an update nobody is ever offered — which nobody reports, because
|
|
# "no update available" and "I cannot read this" look identical.
|
|
if [[ ! "${VERSION_NAME}" =~ ^[0-9]{4}\.[0-9]{2}\.[0-9]{2}\.[0-9]{4}$ ]]; then
|
|
echo "::error::version name '${VERSION_NAME}' is not YYYY.MM.DD.HHMM"
|
|
exit 1
|
|
fi
|
|
|
|
echo "name=${VERSION_NAME}" >> "$GITHUB_OUTPUT"
|
|
echo "code=${VERSION_CODE}" >> "$GITHUB_OUTPUT"
|
|
echo "::notice::APK version: ${VERSION_NAME} (code=${VERSION_CODE})"
|
|
|
|
# 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 }}
|
|
VERSION_NAME: ${{ steps.ver.outputs.name }}
|
|
VERSION_CODE: ${{ steps.ver.outputs.code }}
|
|
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}"
|
|
|
|
# Publish the version sidecar as a release asset next to the APK.
|
|
#
|
|
# This is what lets a later :latest build stop RECONSTRUCTING the
|
|
# bundled APK's version and simply read what was recorded. The
|
|
# ordering key in particular cannot be re-derived after the fact —
|
|
# it is build-time minutes, so once this job ends the value exists
|
|
# nowhere else. Reconstruction could only ever recover the name,
|
|
# and only by duplicating a formula that then has to be kept in
|
|
# step across two files.
|
|
SIDECAR_PATH="/tmp/minstrel.apk.version"
|
|
printf '{"name":"%s","code":%s,"channel":"stable"}\n' \
|
|
"${VERSION_NAME}" "${VERSION_CODE}" > "${SIDECAR_PATH}"
|
|
cat "${SIDECAR_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
|
|
|
|
# Same treatment for the sidecar. Named `.apk.version` so the
|
|
# downloader's `\.apk$` match cannot pick it up by mistake.
|
|
SIDECAR_HTTP=$(curl -sS -L -o /tmp/upload-sidecar.out -w '%{http_code}' \
|
|
-H "Authorization: token ${CI_TOKEN}" \
|
|
-F "attachment=@${SIDECAR_PATH}" \
|
|
"https://git.fabledsword.com/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=minstrel-${TAG}.apk.version")
|
|
echo "sidecar_upload_http=${SIDECAR_HTTP}"
|
|
cat /tmp/upload-sidecar.out || true
|
|
echo
|
|
if [ "${SIDECAR_HTTP}" -lt 200 ] || [ "${SIDECAR_HTTP}" -ge 300 ]; then
|
|
echo "::error::version sidecar upload returned HTTP ${SIDECAR_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:
|
|
# Shallow is fine here. This job used to need full history + tags to
|
|
# re-derive the bundled APK's version from the tagged commit; it now
|
|
# downloads the sidecar the release recorded, and touches git for
|
|
# nothing. MINSTREL_VERSION comes from GITHUB_REF, not from git.
|
|
fetch-depth: 1
|
|
|
|
- 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.HHMM, which never
|
|
# moves; 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:
|
|
# Both pulled from android-release's outputs so the sidecar the
|
|
# server hands clients matches exactly what is baked into the APK
|
|
# they are comparing against.
|
|
APK_VERSION_NAME: ${{ needs.android-release.outputs.version_name }}
|
|
APK_VERSION_CODE: ${{ needs.android-release.outputs.version_code }}
|
|
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
|
|
printf '{"name":"%s","code":%s,"channel":"stable"}\n' \
|
|
"${APK_VERSION_NAME}" "${APK_VERSION_CODE}" > client/minstrel.apk.version
|
|
cat 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
|
|
# the sidecar published beside it, so what the server reports is what
|
|
# that build actually recorded rather than something re-derived here.
|
|
# Degrades to an empty client/ (404 update channel) — never a wrong
|
|
# version — if no release or APK asset 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
|
|
curl -fsSL -H "Authorization: token ${CI_TOKEN}" -o client/minstrel.apk "${APK_URL}"
|
|
|
|
# Take the version the release RECORDED rather than recomputing it.
|
|
# This used to re-derive the name from the tagged commit, which meant
|
|
# the formula lived in two files that had to be kept in step, and it
|
|
# could only ever recover the name — the ordering key is build-time
|
|
# minutes and does not exist anywhere after that build ends.
|
|
SIDECAR_URL="$(printf '%s' "${REL_JSON}" | grep -oP '"browser_download_url":\s*"\K[^"]+' | grep -E '\.apk\.version$' | head -1)"
|
|
if [ -n "${SIDECAR_URL}" ]; then
|
|
curl -fsSL -H "Authorization: token ${CI_TOKEN}" -o client/minstrel.apk.version "${SIDECAR_URL}"
|
|
cat client/minstrel.apk.version
|
|
else
|
|
# Releases published before sidecars were attached. Their name is
|
|
# still recoverable from the tag, but their ordering key genuinely
|
|
# is not — so it is reported ABSENT rather than guessed. A wrong
|
|
# key is an install the platform refuses; an absent one just tells
|
|
# the client to fall back to comparing names, which is exactly
|
|
# what those builds already do.
|
|
echo "::notice::release ${TAG} predates the version sidecar — bundling with name only, no ordering key"
|
|
printf '{"name":"%s","code":null,"channel":"stable"}\n' "${TAG#v}" > client/minstrel.apk.version
|
|
fi
|
|
echo "::notice::bundled release APK from ${TAG}"
|
|
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}"
|