From e57a53a92e694686e4b446b791a80d13a3d52b69 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 10 May 2026 19:44:08 -0400 Subject: [PATCH] ci(server): bundle Android APK into image on tag releases (#397 phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final phase of the in-app update flow. release.yml on tag pushes fetches the APK that flutter.yml is attaching to the same release, drops it into client/ in the build context, and the Dockerfile's COPY client/ /app/client/ bakes it into the image. ### How it sequences flutter.yml and release.yml both trigger on tag pushes and run in parallel on different runners (flutter-ci vs go-ci). flutter.yml typically finishes APK build + release attachment in 2-5 min. release.yml polls the release page for up to 15 min for the APK to appear, then proceeds. If the APK never lands (flutter.yml failure, network hiccup), release.yml emits a warning and ships the image without the bundled APK — server returns 404 from /api/client/version, banner stays hidden, manual download from the release page still works. Graceful degradation, not a blocker. ### Repo shape - client/.gitkeep + client/README.md so the directory exists in git and the COPY in the Dockerfile always finds something to copy - .gitignore excludes client/minstrel.apk + .version so accidental commits don't bloat the repo - Dockerfile: COPY --chown=minstrel:minstrel client/ /app/client/ ### Why polling vs cross-workflow trigger Forgejo Actions' workflow_run support varies by runner version; the polling approach is universally compatible. If/when we standardize on a runner that handles workflow_run cleanly, the polling step can become a `needs:` dependency. Closes #397. Co-Authored-By: Claude Opus 4.7 (1M context) --- .forgejo/workflows/release.yml | 28 +++++++++++++++++++++++++++ .gitignore | 5 +++++ Dockerfile | 13 +++++-------- client/.gitkeep | 0 client/README.md | 35 ++++++++++++++++++++++++++++++++++ 5 files changed, 73 insertions(+), 8 deletions(-) create mode 100644 client/.gitkeep create mode 100644 client/README.md diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 13ebe16d..d7da91b8 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -63,6 +63,34 @@ jobs: echo "${{ secrets.REGISTRY_TOKEN }}" \ | docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin + # In-app update flow (#397): on tag pushes only, fetch the APK + # that flutter.yml is attaching to this same release. flutter.yml + # runs in parallel; poll up to 15 min for the asset to appear. + # On main pushes (or if the APK never lands), client/ stays empty + # and the endpoints return 404 — graceful degradation. + - name: Fetch APK for bundled in-app update channel + if: steps.guard.outputs.ready == 'true' && startsWith(github.ref, 'refs/tags/v') + shell: bash + env: + RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }} + run: | + TAG="${GITHUB_REF#refs/tags/}" + REPO="${GITHUB_REPOSITORY}" + APK_URL="https://git.fabledsword.com/${REPO}/releases/download/${TAG}/minstrel-${TAG}.apk" + echo "Polling ${APK_URL} (up to 15 min for flutter.yml to attach)..." + for i in $(seq 1 30); do + if curl -fsSL -H "Authorization: token ${RELEASE_TOKEN}" \ + -o client/minstrel.apk "$APK_URL"; then + echo "Got APK on attempt $i" + echo "${TAG}" > client/minstrel.apk.version + ls -lh client/ + exit 0 + fi + echo "APK not ready yet (attempt $i/30), sleeping 30s..." + sleep 30 + done + echo "::warning::APK never appeared at ${APK_URL} after 15 min — image will ship without bundled update channel" + - name: Build and push if: steps.guard.outputs.ready == 'true' run: docker buildx build --push ${{ steps.tags.outputs.args }} . diff --git a/.gitignore b/.gitignore index 6cf722a0..1d68ff13 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,11 @@ # Test binary, built with `go test -c` *.test +# Bundled Android APK + version sidecar (#397). Populated by CI for +# tag releases; never committed. README in client/ explains the flow. +client/minstrel.apk +client/minstrel.apk.version + # Output of the go coverage tool, specifically when used with LiteIDE *.out diff --git a/Dockerfile b/Dockerfile index 07b8f17b..34cc41ad 100644 --- a/Dockerfile +++ b/Dockerfile @@ -36,14 +36,11 @@ COPY config.example.yaml /etc/smartmusic/config.yaml RUN mkdir -p /app/data /app/client && chown -R minstrel:minstrel /app WORKDIR /app -# In-app update channel (#397): bundled Android APK + sidecar version -# file. CI populates these on tag releases via a build context COPY; -# leaving the COPY commented during scaffold means the endpoints return -# 404 until the CI sequencing lands. Operators can also drop these in -# at runtime via a volume mount on /app/client. -# -# COPY minstrel.apk /app/client/minstrel.apk -# COPY minstrel.apk.version /app/client/minstrel.apk.version +# In-app update channel (#397). client/ in the build context holds +# minstrel.apk + minstrel.apk.version (populated by release.yml on tag +# pushes; .gitkeep + README otherwise). Endpoints return 404 when the +# APK files aren't present, so non-tag images degrade gracefully. +COPY --chown=minstrel:minstrel client/ /app/client/ USER minstrel EXPOSE 4533 diff --git a/client/.gitkeep b/client/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/client/README.md b/client/README.md new file mode 100644 index 00000000..29e8de6c --- /dev/null +++ b/client/README.md @@ -0,0 +1,35 @@ +# Bundled client assets + +Holds the Android APK (`minstrel.apk` + `minstrel.apk.version`) that the +server serves via `/api/client/version` and `/api/client/apk` for the +in-app update flow (#397). + +## Production + +CI populates this directory on tag releases: +1. `flutter.yml` builds `app-release.apk` and attaches it to the + Forgejo release as `minstrel-.apk`. +2. `release.yml` waits for that asset to appear, downloads it into + this directory as `minstrel.apk`, writes the tag string to + `minstrel.apk.version`, and `docker buildx build` includes both + via `COPY client/ /app/client/`. + +## Development + +Empty directory works fine — the endpoints return 404 and the Flutter +update banner stays hidden ("no update channel" graceful degradation). + +To smoke-test the update flow locally, drop a real APK + a version +file into this directory: + +```sh +cp /path/to/app-release.apk client/minstrel.apk +echo "v0.2.0" > client/minstrel.apk.version +``` + +## Why a directory and not embed.FS? + +The APK is 30-60 MB. Embedding bloats the Go binary and slows +`go build` for everyone, even when the APK isn't being changed. +File-on-disk also lets operators override at runtime via volume +mount on `/app/client/` if they want to ship their own build.