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>
325 lines
14 KiB
YAML
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 }} .
|