test-go / test (push) Successful in 1m11s
release / Build signed APK (releases and dev) (push) Successful in 5m0s
test-go / integration (push) Successful in 5m55s
release / Build + push container image (push) Successful in 1m52s
release / Verify release artifacts (tag releases only) (push) Skipped
There was no test channel at all. release.yml ran only on main and tags, so no :dev image existed and no APK was produced outside a release — the only way to get a build onto a phone was to ship one, which made `main` the staging area by default. A push to dev now builds a signed APK, bundles it, and publishes :dev. Signed with the SAME key as release builds, deliberately. A differently signed APK cannot install over the stable app, so anyone moving between channels would have to uninstall and lose their local data. Same key means both directions work. :dev is published ALONE, with no per-commit tag. A rolling channel is rolling by definition; a commit-addressable image for it would be a rollback target nobody ever pulls, kept forever. Recovery on dev is to fix forward, and that is a deliberate trade rather than an omission. The channel is derived from the REF, not the commit, which is why it is computed in the workflow and not in ci/version.sh. The same commit built on dev and on main reports the same version NAME and differs only in the channel field — that separation is the entire point of keeping the three values apart. What this repo deliberately does NOT get: a cross-repo dispatch to refresh the channel when its bundled APK is rebuilt. That mechanism exists elsewhere in the family because the app and server live in separate repos, and a channel that can only be refreshed by an unrelated commit is not a channel. Minstrel is a monorepo — one push builds the APK and the image in the same run from the same commit, so the channel cannot go stale against its own artifact. The requirement is met structurally; copying the mechanism would add a moving part to fix a problem that does not exist here. Two guards, for the two ways this wiring can fail quietly: A dev push must never move :latest. That would ship untested code to every stable operator on their next pull, with the build green and the image perfectly valid — just the wrong audience. Nothing else in the suite would notice. The two bundling paths must stay mutually exclusive. The rebundle step is now gated to main specifically, not to "not a tag": under the looser condition a dev push would run BOTH steps, staging its fresh APK and then overwriting it with the previous release's. The image still builds, the sidecar still parses, and the channel whose whole job is being current quietly serves stale art. Both falsified against the regressions they name before committing. Scribe task #3819, milestone #390. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
548 lines
26 KiB
YAML
548 lines
26 KiB
YAML
name: release
|
|
|
|
# Builds and pushes the minstrel container image to the Gitea registry.
|
|
#
|
|
# push to dev → :dev (freshly-built dev APK bundled)
|
|
# 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)
|
|
#
|
|
# The dev channel exists so testing a build does not require shipping one.
|
|
# Before it, the only way to get an APK onto a phone was to cut a release,
|
|
# which made `main` the staging area by default. `:dev` carries its own
|
|
# freshly-built APK, signed with the SAME key as release builds — a different
|
|
# key cannot install over the stable app, so anyone crossing channels would
|
|
# have to uninstall and lose their data.
|
|
#
|
|
# :dev is published ALONE, with no per-commit tag. A rolling channel is
|
|
# rolling by definition; a commit-addressable image for it would be a
|
|
# rollback target nobody ever pulls, kept forever. Recovery on dev is to fix
|
|
# forward.
|
|
#
|
|
# Note what this repo does NOT need: a cross-repo dispatch to refresh the
|
|
# channel when its bundled APK is rebuilt. That mechanism exists elsewhere in
|
|
# the family because the app and the server live in separate repos. Minstrel
|
|
# is a monorepo — one push builds the APK and the image in the same run from
|
|
# the same commit, so the channel cannot go stale against its own artifact.
|
|
# The requirement is satisfied structurally; copying the mechanism would add
|
|
# a moving part to fix a problem that does not exist here.
|
|
#
|
|
# 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, dev]
|
|
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 (releases and dev)
|
|
# Also builds on `dev`, which is what makes a test channel possible at
|
|
# all. Without it the only way to get a build onto a phone was to cut a
|
|
# release, which quietly turns `main` into the staging area.
|
|
if: startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/dev'
|
|
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 }}
|
|
channel: ${{ steps.ver.outputs.channel }}
|
|
|
|
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
|
|
# The derivation lives in ci/version.sh, not here, so it can be
|
|
# executed by a test on every push. Anything inline in this file is
|
|
# unverifiable until a release is already running.
|
|
out="$(ci/version.sh HEAD)"
|
|
printf '%s\n' "${out}" >> "$GITHUB_OUTPUT"
|
|
|
|
# The channel is a property of the LANE, not of the commit, which is
|
|
# why it is derived here rather than in version.sh. Same commit built
|
|
# on dev and on main reports the same NAME and differs only here —
|
|
# that is the whole point of separating the two values.
|
|
if [ "${GITHUB_REF}" = "refs/heads/dev" ]; then
|
|
channel=dev
|
|
else
|
|
channel=stable
|
|
fi
|
|
echo "channel=${channel}" >> "$GITHUB_OUTPUT"
|
|
echo "::notice::APK $(printf '%s' "${out}" | tr '\n' ' ') channel=${channel}"
|
|
|
|
# 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
|
|
if: startsWith(github.ref, 'refs/tags/v')
|
|
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
|
|
# Tag releases only. A dev build has no Release to hang assets on and
|
|
# does not need one — the :dev image bundles the APK, and the server
|
|
# serves it from /api/client/apk like any other.
|
|
if: startsWith(github.ref, 'refs/tags/v')
|
|
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"
|
|
elif [[ "${GITHUB_REF}" == "refs/heads/dev" ]]; then
|
|
# The rolling test channel, and :dev ALONE — deliberately no
|
|
# per-commit tag. A rolling channel is rolling by definition, so a
|
|
# commit-addressable image here would be a rollback target nobody
|
|
# has ever pulled, accumulating in the registry forever. Recovery
|
|
# on dev is to fix forward.
|
|
echo "args=-t ${IMAGE}:dev" >> "$GITHUB_OUTPUT"
|
|
echo "version=dev" >> "$GITHUB_OUTPUT"
|
|
echo "::notice::Dev-branch build: :dev"
|
|
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 and dev pushes — android-release just produced this. Only `main`
|
|
# takes the "Bundle latest release APK" path below, because it is the
|
|
# one ref that moves a channel without building an APK of its own.
|
|
if: >-
|
|
steps.guard.outputs.ready == 'true' &&
|
|
(startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/dev')
|
|
# 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') || github.ref == 'refs/heads/dev')
|
|
shell: bash
|
|
env:
|
|
# All three 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 }}
|
|
APK_CHANNEL: ${{ needs.android-release.outputs.channel }}
|
|
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":"%s"}\n' \
|
|
"${APK_VERSION_NAME}" "${APK_VERSION_CODE}" "${APK_CHANNEL}" \
|
|
> 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' && github.ref == 'refs/heads/main'
|
|
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}"
|