android / Build + lint + test (push) Successful in 4m19s
versionCode was `git rev-list --count HEAD`, and build.gradle.kts called it
"monotonic forever". It is not, and that claim was sitting directly above
the bug it denied.
A commit count runs ahead on `dev`. So a dev build carried a HIGHER code
than the `main` release meant to supersede it, and Android refuses that
install as a downgrade — a channel you can enter and cannot leave without
uninstalling and losing local data.
Two clocks now, and the split is deliberate even though it reads like an
inconsistency:
The NAME answers "is this the same code?", so it derives from COMMIT time
and reads identically on every lane building 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.
The ORDERING KEY answers "may this be installed over that?", so it must be
monotonic BY CONSTRUCTION: minutes since 2020-01-01. Commit time fails
here for the mirror-image reason — rebuild an older commit and it goes
DOWN, which on a phone is a refused install rather than a confusing label.
The non-tag :latest path reconstructed the bundled APK's name with the old
formula, so it is moved to the same commit-timestamp derivation. That
duplication is temporary: once the tag becomes `v<version-name>` it
collapses to `${TAG#v}` with nothing left to keep in step.
Verified locally by running the derivations rather than reasoning about
them: HEAD yields 2026.09.09.1828; the key yields 3519456 against ~1895
from the old scheme, inside int32 with ~4000 years of headroom; a commit
at 00:42 UTC yields "0042", not "42". The workflow now asserts the emitted
shape too — a malformed name builds, signs and publishes happily and only
surfaces as an update nobody is offered, which nobody reports.
That local check is the only verification this commit gets. release.yml
triggers on main and tags only, so nothing on `dev` executes the new
derivation; CI here proves the Gradle file still parses and nothing else.
Also confirms the migration constraint recorded in milestone #390: this
commit would name a release 2026.09.09.1828, which is LOWER than the
installed 2026.09.09.1895 under name comparison. The first new-scheme
release must be cut on a later calendar day, or existing installs will
never be offered it.
Step 1 of 5 — Scribe task #3808, milestone #390.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
474 lines
22 KiB
YAML
474 lines
22 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 from the tagged commit's timestamp
|
|
# (the same derivation 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:
|
|
# 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 }}
|
|
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 and reconstruct the bundled APK's
|
|
# exact versionName from its timestamp (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 from the tagged commit's
|
|
# timestamp — the same derivation android-release uses — 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
|
|
# Reconstruct the bundled APK's name with the SAME derivation
|
|
# android-release uses — commit timestamp of the tagged commit. The
|
|
# two must agree exactly: this string is what the server hands
|
|
# clients to compare against what is installed, so a mismatch here
|
|
# is an update offered forever or never offered at all.
|
|
#
|
|
# This duplication is temporary. Once the tag itself becomes
|
|
# `v<version-name>`, this whole block collapses to `${TAG#v}` with
|
|
# nothing to recompute and nothing to keep in step.
|
|
COMMIT_TS="$(git log --format=%ct -1 "${TAG}" 2>/dev/null || true)"
|
|
if [ -z "${COMMIT_TS}" ]; then
|
|
echo "::notice::could not resolve commit timestamp for ${TAG} (tag not fetched?) — skipping APK bundle"; exit 0
|
|
fi
|
|
VERSION_NAME="$(date -u -d "@${COMMIT_TS}" +%Y.%m.%d.%H%M)"
|
|
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}"
|