Files
minstrel/.gitea/workflows/release.yml
T
bvandeusenandClaude Opus 5 fa0827f668 ci: pin the download mirror to v6, not v5 — match on @actions/artifact
The previous pin matched the two actions by their own version numbers, which
is meaningless: upload-artifact and download-artifact release on unrelated
cadences. upload v5 bundles @actions/artifact ^4.0.0; download v5 bundles
^2.3.2. "v5 and v5" was in fact a mismatched pair.

download v6 is the tag that puts ^4.0.0 on both sides — and ^4.0.0 is the
library major just proven against this instance by the upload side
(thoughtsync run 3094: two artifacts listed, downloaded and extracted
intact). ^2.3.2 has never been exercised here.

Not v7: that major is a runner requirement rather than a feature change. It
moves to runs.using: node24 and upstream requires runner >= 2.327.1 for it,
which act_runner does not claim to satisfy. Everything pinned stays node20.

ci-requirements.md now carries the version/runtime table and the reasoning,
so the next person matches on the library instead of the tag number.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 15:37:43 -04:00

325 lines
14 KiB
YAML

name: release
# Builds and pushes the minstrel container image to the Gitea registry.
#
# push to main → :main and :latest (latest-release APK bundled)
# push tag vYYYY.MM.DD → :vYYYY.MM.DD and :latest (freshly-built APK bundled)
# workflow_dispatch → manual trigger (same rules based on the ref)
#
# Release model: per-day CalVer tags (no trailing patch digit). The day's
# tag is intentionally mutable — if a second release happens the same day,
# move the tag with `git push -f origin vYYYY.MM.DD` and the image tag of
# the same name gets overwritten. :latest is updated by every main push
# AND every tag push, so it always reflects the newest blessed image.
#
# APK pipeline: on tag pushes the android-release job builds + signs the
# Android APK and uploads it as a workflow artifact. The image-release
# job declares `needs: android-release`, so the docker image cannot
# start building until the APK is guaranteed-ready — no polling, no
# race, no silent-failure mode. Asset attachment to the gitea Release
# happens in the same android-release job, so the Release-page download
# link and the in-image bundled APK are both populated atomically.
#
# :latest always carries an APK. Because every main push also moves
# :latest (not just tags), a main build with no APK would silently strip
# the in-app update channel off :latest until the next release. So on
# non-tag builds image-release pulls the MOST RECENT release's signed APK
# and reconstructs its exact versionName (tag + commit-count, the same
# formula android-release bakes in) for the version sidecar — no rebuild,
# just rebundle. Tag builds keep bundling their own freshly-built APK.
#
# Android testing (lint + detekt + unit tests, debug APK upload on main)
# lives in android.yml and runs independently on every push.
on:
push:
branches: [main]
tags: ['v*']
paths-ignore:
- 'docs/**'
- '**/*.md'
workflow_dispatch:
# Force-moving the per-day tag (or rapidly re-pushing to main) should
# supersede the in-flight build — the operator explicitly wants the
# later commit to win.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
android-release:
name: Build signed APK (tag releases only)
if: startsWith(github.ref, 'refs/tags/v')
runs-on: flutter-ci
container:
image: git.fabledsword.com/bvandeusen/ci-android:36
defaults:
run:
working-directory: android
env:
JAVA_TOOL_OPTIONS: "--enable-native-access=ALL-UNNAMED"
# PKCS12 keystores collapse store + key password into a single
# value; both env vars map to one secret. build.gradle reads them
# separately to stay format-agnostic.
ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
# Job outputs propagate the computed release version to image-release
# so the bundled sidecar file matches what's baked into the APK —
# otherwise the server would report a different version string than
# the installed client and the update banner could thrash.
outputs:
version_name: ${{ steps.ver.outputs.name }}
version_code: ${{ steps.ver.outputs.code }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
# fetch-depth: 0 retrieves full history; default shallow clone
# would return 1 for `git rev-list --count HEAD`, breaking the
# iteration suffix.
fetch-depth: 0
- name: Compute release version
id: ver
shell: bash
working-directory: ${{ github.workspace }}
run: |
set -euo pipefail
TAG="${GITHUB_REF#refs/tags/v}"
COMMIT_COUNT=$(git rev-list --count HEAD)
VERSION_NAME="${TAG}.${COMMIT_COUNT}"
echo "name=${VERSION_NAME}" >> "$GITHUB_OUTPUT"
echo "code=${COMMIT_COUNT}" >> "$GITHUB_OUTPUT"
echo "::notice::APK version: ${VERSION_NAME} (code=${COMMIT_COUNT})"
- name: Cache Gradle dirs
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
~/.kotlin
key: gradle-${{ runner.os }}-${{ hashFiles('android/gradle/wrapper/gradle-wrapper.properties', 'android/gradle/libs.versions.toml', 'android/**/*.gradle.kts') }}
restore-keys: |
gradle-${{ runner.os }}-
- name: Make gradlew executable
run: chmod +x ./gradlew
- name: Decode signing keystore
env:
ANDROID_KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_B64 }}
shell: bash
run: |
if [ -z "${ANDROID_KEYSTORE_B64}" ]; then
echo "::error::ANDROID_KEYSTORE_B64 missing"; exit 1
fi
KEYSTORE_PATH="${RUNNER_TEMP}/minstrel-release.keystore"
echo "${ANDROID_KEYSTORE_B64}" | base64 -d > "${KEYSTORE_PATH}"
echo "ANDROID_KEYSTORE_PATH=${KEYSTORE_PATH}" >> "${GITHUB_ENV}"
- name: Build release APK
run: |
./gradlew assembleRelease \
-PMINSTREL_VERSION_NAME=${{ steps.ver.outputs.name }} \
-PMINSTREL_VERSION_CODE=${{ steps.ver.outputs.code }}
- name: Upload APK as workflow artifact
# Mirrored action, never actions/upload-artifact — @v4+ refuses on the
# hostname, @v3 uploads something Gitea will never serve back. This is
# the producing half of a pair: image-release downloads `minstrel-apk`
# below with the matching download-artifact mirror. Both must stay on
# the v4 protocol — mixing a v3 upload with a v4 download (or the
# reverse) yields an empty listing, not an error. See Scribe 2255 / 2270.
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
with:
name: minstrel-apk
path: android/app/build/outputs/apk/release/app-release.apk
# error, not the default warn: image-release hard-depends on this
# artifact existing, so an empty upload must fail here, not there.
if-no-files-found: error
- name: Attach APK to gitea Release
shell: bash
env:
CI_TOKEN: ${{ secrets.CI_TOKEN }}
run: |
set -euxo pipefail
TAG="${GITHUB_REF#refs/tags/}"
REPO="${GITHUB_REPOSITORY}"
APK_PATH="app/build/outputs/apk/release/app-release.apk"
ls -lh "${APK_PATH}"
RELEASE_JSON="$(curl -fsSL \
-H "Authorization: token ${CI_TOKEN}" \
"https://git.fabledsword.com/api/v1/repos/${REPO}/releases/tags/${TAG}")"
RELEASE_ID="$(printf '%s' "${RELEASE_JSON}" | grep -oP '"id":\s*\K[0-9]+' | head -1)"
if [ -z "${RELEASE_ID}" ]; then
echo "::error::release for ${TAG} not found"; exit 1
fi
echo "release_id=${RELEASE_ID}"
UPLOAD_HTTP=$(curl -sS -L -o /tmp/upload.out -w '%{http_code}' \
-H "Authorization: token ${CI_TOKEN}" \
-F "attachment=@${APK_PATH}" \
"https://git.fabledsword.com/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=minstrel-${TAG}.apk")
echo "upload_http=${UPLOAD_HTTP}"
cat /tmp/upload.out || true
echo
if [ "${UPLOAD_HTTP}" -lt 200 ] || [ "${UPLOAD_HTTP}" -ge 300 ]; then
echo "::error::APK upload returned HTTP ${UPLOAD_HTTP}"
exit 1
fi
image-release:
name: Build + push container image
# `needs:` waits for android-release. For tag pushes android-release
# runs and must succeed before this job starts — guaranteeing the
# APK artifact is present. For main pushes android-release is
# skipped; the `if: ...` below lets this job run anyway and the
# download/copy steps gate themselves on the tag context.
needs: [android-release]
if: ${{ !failure() && !cancelled() }}
runs-on: go-ci
container:
image: git.fabledsword.com/bvandeusen/ci-go:1.26
env:
IMAGE: git.fabledsword.com/bvandeusen/minstrel
steps:
- name: Checkout
uses: actions/checkout@v4
with:
# Full history + tags so non-tag :latest builds can resolve the
# latest release tag's commit count and reconstruct the bundled
# APK's exact versionName (see "Bundle latest release APK" below).
fetch-depth: 0
fetch-tags: true
- name: Detect buildable project
id: guard
shell: bash
run: |
if [ -f Dockerfile ] && [ -f go.mod ]; then
echo "ready=true" >> "$GITHUB_OUTPUT"
else
echo "ready=false" >> "$GITHUB_OUTPUT"
echo "::notice::No Dockerfile + go.mod yet — release build skipped"
fi
- name: Compute image tags
id: tags
if: steps.guard.outputs.ready == 'true'
shell: bash
run: |
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
VERSION="${GITHUB_REF#refs/tags/}"
echo "args=-t ${IMAGE}:${VERSION} -t ${IMAGE}:latest" >> "$GITHUB_OUTPUT"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "::notice::Release build: ${VERSION} + latest"
else
# Main is the protected, post-PR-merge branch. Treat it as the
# rolling stable channel — every main push moves :latest.
# Pinned consumers can target :vYYYY.MM.DD; everyone else
# gets the newest main.
echo "args=-t ${IMAGE}:main -t ${IMAGE}:latest" >> "$GITHUB_OUTPUT"
echo "version=main" >> "$GITHUB_OUTPUT"
echo "::notice::Main-branch build: :main + :latest"
fi
- name: Registry login
if: steps.guard.outputs.ready == 'true'
shell: bash
run: |
echo "${{ secrets.CI_TOKEN }}" \
| docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin
- name: Download signed APK artifact
# Tag pushes only — android-release just produced this. Non-tag
# builds take the "Bundle latest release APK" path below instead.
if: steps.guard.outputs.ready == 'true' && startsWith(github.ref, 'refs/tags/v')
# Consuming half of the pair — never actions/download-artifact. Same fork,
# same reason: upstream's client-side GHES check rejects this hostname
# before it connects. bvandeusen/download-artifact mirrors
# code.forgejo.org/forgejo/download-artifact.
#
# SHA below is that fork's `v6` tag. Match on @actions/artifact, NOT on
# the action's own version number — the two actions release on unrelated
# cadences, and download v5 would pair a ^2.3.2 client with this file's
# ^4.0.0 uploader. v6 is the tag whose bundled library major (^4.0.0) is
# the same one proven against this instance by the upload side.
# Deliberately NOT v7: it moves to node24 and upstream requires runner
# >= 2.327.1 for it, which act_runner does not claim to satisfy.
# Pinned, not tagged — the mirror auto-syncs every 8h.
uses: https://git.fabledsword.com/bvandeusen/download-artifact@8d4e9521a5f7e5f8b6351f341f719f9f45a92a3a
with:
name: minstrel-apk
path: client/
- name: Stage bundled APK + version sidecar
if: steps.guard.outputs.ready == 'true' && startsWith(github.ref, 'refs/tags/v')
shell: bash
env:
# Pulled from android-release.outputs.version_name so the
# sidecar string the server hands clients matches the
# versionName baked into the APK they're comparing against.
APK_VERSION_NAME: ${{ needs.android-release.outputs.version_name }}
run: |
set -euxo pipefail
# The artifact lands as `app-release.apk` (the original Gradle
# output name). The Dockerfile COPYs client/* into /app/client/
# and the server reads minstrel.apk + minstrel.apk.version.
mv client/app-release.apk client/minstrel.apk
echo "${APK_VERSION_NAME}" > client/minstrel.apk.version
ls -lh client/
- name: Bundle latest release APK (non-tag :latest builds)
# Main pushes don't build an APK, but they DO move :latest — so
# without this the in-app update channel would vanish from :latest
# until the next tag. Pull the most-recent release's signed APK and
# reconstruct its exact versionName (${TAG#v}.$(git rev-list --count
# TAG) — identical to android-release's formula) so the version
# sidecar the server hands clients matches the installed build.
# Degrades to an empty client/ (404 update channel) — never a wrong
# version — if no release / APK asset / tag-count can be resolved.
if: steps.guard.outputs.ready == 'true' && !startsWith(github.ref, 'refs/tags/v')
shell: bash
env:
CI_TOKEN: ${{ secrets.CI_TOKEN }}
run: |
set -eu
REPO="${GITHUB_REPOSITORY}"
REL_JSON="$(curl -fsSL -H "Authorization: token ${CI_TOKEN}" \
"https://git.fabledsword.com/api/v1/repos/${REPO}/releases/latest" || true)"
if [ -z "${REL_JSON}" ]; then
echo "::notice::no published release — image ships without bundled APK"; exit 0
fi
TAG="$(printf '%s' "${REL_JSON}" | grep -oP '"tag_name":\s*"\K[^"]+' | head -1)"
APK_URL="$(printf '%s' "${REL_JSON}" | grep -oP '"browser_download_url":\s*"\K[^"]+' | grep -E '\.apk$' | head -1)"
if [ -z "${TAG}" ] || [ -z "${APK_URL}" ]; then
echo "::notice::latest release '${TAG:-?}' has no APK asset — image ships without bundled APK"; exit 0
fi
COUNT="$(git rev-list --count "${TAG}" 2>/dev/null || true)"
if [ -z "${COUNT}" ]; then
echo "::notice::could not resolve commit count for ${TAG} (tag not fetched?) — skipping APK bundle"; exit 0
fi
VERSION_NAME="${TAG#v}.${COUNT}"
curl -fsSL -H "Authorization: token ${CI_TOKEN}" -o client/minstrel.apk "${APK_URL}"
echo "${VERSION_NAME}" > client/minstrel.apk.version
echo "::notice::bundled release APK ${TAG} as version ${VERSION_NAME}"
ls -lh client/
- name: Build and push
if: steps.guard.outputs.ready == 'true'
run: |
docker buildx build \
--build-arg MINSTREL_VERSION="${{ steps.tags.outputs.version }}" \
--push ${{ steps.tags.outputs.args }} .