test-go / test (push) Successful in 1m43s
test-web / test (push) Successful in 1m13s
test-go / integration (push) Successful in 4m12s
release / Build signed APK (releases and dev) (push) Successful in 5m11s
release / Build + push container image (push) Successful in 38s
release / Verify release artifacts (tag releases only) (push) Skipped
The image tag map was the inverse of family rules 145 and 147 on every count: it published :vYYYY.MM.DD.HHMM that nobody pinned, published :main that rule 147 says should not exist, and published no commit-addressable image at all — so the rollback unit the rule names did not exist in this repo. A bad main push had nothing to roll back to but the previous release tag, which may be many commits back. The whole map is now: dev → :dev main → :latest + :<sha> tag → :latest A release refreshes the channel and mints nothing else. The tag build rebuilds the SAME SOURCE as main's build minutes earlier, differing only in which APK is baked in, so rule 145's immutability clause applies directly: move the channel tag, never re-push a commit-addressable one. :latest has to move here rather than waiting for the next main push, or the channel would carry the previous release's APK indefinitely — a channel that cannot refresh itself (rule 146). Two consequences that are not optional: The verify job asserted the :<version> image existed. With version tags gone that would fail every release for a tag nothing mints. Re-pointed at the :<sha> image rather than deleted — deleting it is the tempting way to make a failing guard go green, and it earns its keep twice now: it still catches an image push that silently did not happen, and it additionally proves the ordering, since a tag cut on a commit whose main build never completed has no rollback target. The server's self-reported version was the literal string "main" or "dev". That was survivable while :vYYYY.MM.DD.HHMM existed to identify a build; with version tags gone it is the ONLY thing that says which build is running, and two dev images months apart were indistinguishable. It now carries the derived name from ci/version.sh on every lane, with the channel as a sibling field (rule 149) rather than folded into the string. Surfaced at /healthz and beside the version in Settings. Guards added for each arm of the policy, and every one was falsified against the specific regression it names before committing. That caught two real bugs in the guards themselves: stepBody cut at the next `- name:`, which returns an EMPTY body for the last step in a job and made the assertions pass vacuously, and its replacement cut at any blank line followed by indentation, which truncated a step mid-run-block. The helper now refuses an empty body outright. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
634 lines
32 KiB
YAML
634 lines
32 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 → :latest + :<sha> (latest-release APK bundled)
|
|
# push tag vYYYY.MM.DD.HHMM → :latest (fresh APK bundled)
|
|
# workflow_dispatch → manual trigger (same rules based on the ref)
|
|
#
|
|
# That is the whole tag map, and it is family rule 145 + 147 as written.
|
|
#
|
|
# :<sha> on main is the ROLLBACK UNIT — every production commit addressable
|
|
# without a release ceremony. It is minted only on main, where rollback is
|
|
# actually worth having: merges are gated (rule 2) so they number in the dozens
|
|
# per year, while on dev they would be one per push, forever, for a channel
|
|
# whose entire contract is that it moves.
|
|
#
|
|
# There are NO :<version> image tags. This repo published :vYYYY.MM.DD.HHMM
|
|
# until 2026-09-10 and it was the inverse of the rule on both counts — minting
|
|
# a version tag nobody pinned while the rollback unit the rule names did not
|
|
# exist here at all. Git and the build's own self-reported version answer
|
|
# "which build is this"; a third name for the same thing is upkeep for a model
|
|
# we do not run. Operator, 2026-09-10: "only things like the APK need that kind
|
|
# of versioning for their update process."
|
|
#
|
|
# There is no :main either. :latest tracks main's tip with no gate between them
|
|
# (rule 147), so a second name for the same image sends readers looking for a
|
|
# distinction that does not exist.
|
|
#
|
|
# 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:
|
|
# Full history, and rule 149 names this specifically: any job that
|
|
# DERIVES the version name needs it, because a shallow clone changes
|
|
# what git-derived values resolve to WITHOUT failing — a too-low
|
|
# value, silently, with every lane green.
|
|
#
|
|
# This job was depth-1 while it took the version from GITHUB_REF. It
|
|
# now runs ci/version.sh itself, because with :<version> image tags
|
|
# gone the server's self-reported version is the only thing that says
|
|
# which build an image is.
|
|
fetch-depth: 0
|
|
|
|
- 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: |
|
|
set -euo pipefail
|
|
|
|
# THE VERSION, and it is derived the same way on every ref — the
|
|
# branch decides the CHANNEL, never the version (family rule 149).
|
|
#
|
|
# This used to be three different things: the literal string "main"
|
|
# on main, "dev" on dev, and the tag name on a tag. None of them
|
|
# ordered, and the first two were the same string forever — two dev
|
|
# images eight weeks apart were indistinguishable in the UI. That
|
|
# mattered little while :vYYYY.MM.DD.HHMM existed to identify a
|
|
# build; with version image tags gone, this IS how an operator tells
|
|
# which build a container is running.
|
|
#
|
|
# `sed -n s///p` rather than `grep`: it exits 0 when nothing matches,
|
|
# so the empty check below is actually reachable. A grep here would
|
|
# kill the step at the assignment under the runner's pipefail — the
|
|
# exact bug that took down the first main build after the version
|
|
# rework.
|
|
VERSION="$(ci/version.sh HEAD | sed -n 's/^name=//p')"
|
|
if [ -z "${VERSION}" ]; then
|
|
echo "::error::could not derive a build version from ci/version.sh"
|
|
exit 1
|
|
fi
|
|
|
|
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
|
|
# A release refreshes the CHANNEL and mints nothing else.
|
|
#
|
|
# The tag build exists to produce the signed APK and attach it to
|
|
# the release; the image it rebuilds is the SAME SOURCE as the main
|
|
# build minutes earlier, differing only in which APK is baked in.
|
|
# Rule 145 is explicit about that case: when the same source is
|
|
# rebuilt with different contents, publish the moving channel tag
|
|
# and never a commit-addressable one.
|
|
#
|
|
# :latest must move here rather than waiting for the next main
|
|
# push, or the channel would carry the PREVIOUS release's APK
|
|
# indefinitely — a channel that cannot refresh itself (rule 146).
|
|
CHANNEL=stable
|
|
echo "args=-t ${IMAGE}:latest" >> "$GITHUB_OUTPUT"
|
|
echo "::notice::Release build ${VERSION}: refreshing :latest around the new APK"
|
|
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.
|
|
CHANNEL=dev
|
|
echo "args=-t ${IMAGE}:dev" >> "$GITHUB_OUTPUT"
|
|
echo "::notice::Dev-branch build ${VERSION}: :dev"
|
|
else
|
|
# The production line: :latest tracks main's tip (rule 147) and
|
|
# :<sha> is the rollback unit (rule 145). Full 40-char SHA, matching
|
|
# the family's other repos, so a rollback target is addressable
|
|
# straight from the commit anyone is reading.
|
|
CHANNEL=stable
|
|
echo "args=-t ${IMAGE}:latest -t ${IMAGE}:${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
|
|
echo "::notice::Main-branch build ${VERSION}: :latest + :${GITHUB_SHA}"
|
|
fi
|
|
|
|
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
|
echo "channel=${CHANNEL}" >> "$GITHUB_OUTPUT"
|
|
|
|
- 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. That
|
|
# degradation only actually works because the greps below carry
|
|
# `|| true`; under the runner's default pipefail a non-matching grep
|
|
# kills the step instead of falling through to the empty-case branch.
|
|
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
|
|
# `|| true` on every one of these, and it is load-bearing rather
|
|
# than defensive habit. The runner already invokes this shell as
|
|
# `bash -e -o pipefail`, so a pipeline whose grep matches NOTHING
|
|
# exits non-zero even though `head` succeeded — and the step dies at
|
|
# the assignment, before ever reaching the `if` written to handle the
|
|
# empty case. Every "degrades gracefully" branch below is unreachable
|
|
# without this.
|
|
TAG="$(printf '%s' "${REL_JSON}" | grep -oP '"tag_name":\s*"\K[^"]+' | head -1)" || true
|
|
APK_URL="$(printf '%s' "${REL_JSON}" | grep -oP '"browser_download_url":\s*"\K[^"]+' | grep -E '\.apk$' | head -1)" || true
|
|
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)" || true
|
|
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 }}" \
|
|
--build-arg MINSTREL_CHANNEL="${{ steps.tags.outputs.channel }}" \
|
|
--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 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.
|
|
#
|
|
# This asserted `:${TAG}` — the :vYYYY.MM.DD.HHMM image — until
|
|
# 2026-09-10. Version image tags are no longer published (rule 145), so
|
|
# that assertion would now fail every release for a tag nothing mints.
|
|
# The rollback target it was really protecting is the :<sha> image, which
|
|
# main's own build published for this same commit before the tag was cut.
|
|
#
|
|
# Checking it here earns its keep twice over: it still catches an image
|
|
# push that silently did not happen, and it additionally proves the
|
|
# ORDERING — a tag cut on a commit whose main build never completed has
|
|
# no rollback target, and that is worth failing on rather than
|
|
# discovering during an incident.
|
|
- name: Rollback image must exist for the tagged commit
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
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}:${GITHUB_SHA}" > /dev/null 2>&1; then
|
|
echo "::error::image ${IMAGE}:${GITHUB_SHA} does not exist — this commit has no rollback target."
|
|
echo "::error::That image is published by the MAIN build of this commit, not by the tag build. If main's build never ran or failed, fix that first; a release whose commit cannot be rolled back to is the thing this check exists to refuse."
|
|
exit 1
|
|
fi
|
|
echo "::notice::rollback target verified: ${IMAGE}:${GITHUB_SHA}"
|