Versioning rework, a dev channel, and the miniplayer gap #129
@@ -2,10 +2,31 @@ name: release
|
|||||||
|
|
||||||
# Builds and pushes the minstrel container image to the Gitea registry.
|
# Builds and pushes the minstrel container image to the Gitea registry.
|
||||||
#
|
#
|
||||||
|
# push to dev → :dev (freshly-built dev APK bundled)
|
||||||
# push to main → :main and :latest (latest-release APK bundled)
|
# push to main → :main and :latest (latest-release APK bundled)
|
||||||
# push tag vYYYY.MM.DD.HHMM → :vYYYY.MM.DD.HHMM and :latest (fresh APK bundled)
|
# push tag vYYYY.MM.DD.HHMM → :vYYYY.MM.DD.HHMM and :latest (fresh APK bundled)
|
||||||
# workflow_dispatch → manual trigger (same rules based on the ref)
|
# workflow_dispatch → manual trigger (same rules based on the ref)
|
||||||
#
|
#
|
||||||
|
# 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.
|
# 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
|
# `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
|
# the tagged commit's UTC timestamp — so there is no mismatch to reconcile
|
||||||
@@ -48,7 +69,7 @@ name: release
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main, dev]
|
||||||
tags: ['v*']
|
tags: ['v*']
|
||||||
paths-ignore:
|
paths-ignore:
|
||||||
- 'docs/**'
|
- 'docs/**'
|
||||||
@@ -65,8 +86,11 @@ concurrency:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
android-release:
|
android-release:
|
||||||
name: Build signed APK (tag releases only)
|
name: Build signed APK (releases and dev)
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
# 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
|
runs-on: flutter-ci
|
||||||
container:
|
container:
|
||||||
image: git.fabledsword.com/bvandeusen/ci-android:36
|
image: git.fabledsword.com/bvandeusen/ci-android:36
|
||||||
@@ -91,6 +115,7 @@ jobs:
|
|||||||
outputs:
|
outputs:
|
||||||
version_name: ${{ steps.ver.outputs.name }}
|
version_name: ${{ steps.ver.outputs.name }}
|
||||||
version_code: ${{ steps.ver.outputs.code }}
|
version_code: ${{ steps.ver.outputs.code }}
|
||||||
|
channel: ${{ steps.ver.outputs.channel }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -115,7 +140,18 @@ jobs:
|
|||||||
# unverifiable until a release is already running.
|
# unverifiable until a release is already running.
|
||||||
out="$(ci/version.sh HEAD)"
|
out="$(ci/version.sh HEAD)"
|
||||||
printf '%s\n' "${out}" >> "$GITHUB_OUTPUT"
|
printf '%s\n' "${out}" >> "$GITHUB_OUTPUT"
|
||||||
echo "::notice::APK $(printf '%s' "${out}" | tr '\n' ' ')"
|
|
||||||
|
# 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
|
# 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 —
|
# Release" below resolves the release by tag and fails if it is absent —
|
||||||
@@ -127,6 +163,7 @@ jobs:
|
|||||||
# the release together, so this passes). A bare `git push origin vX` is the
|
# the release together, so this passes). A bare `git push origin vX` is the
|
||||||
# case this catches.
|
# case this catches.
|
||||||
- name: Release must exist for this tag
|
- name: Release must exist for this tag
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
shell: bash
|
shell: bash
|
||||||
working-directory: ${{ github.workspace }}
|
working-directory: ${{ github.workspace }}
|
||||||
env:
|
env:
|
||||||
@@ -190,6 +227,10 @@ jobs:
|
|||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
- name: Attach APK to gitea Release
|
- 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
|
shell: bash
|
||||||
env:
|
env:
|
||||||
CI_TOKEN: ${{ secrets.CI_TOKEN }}
|
CI_TOKEN: ${{ secrets.CI_TOKEN }}
|
||||||
@@ -298,6 +339,15 @@ jobs:
|
|||||||
echo "args=-t ${IMAGE}:${VERSION} -t ${IMAGE}:latest" >> "$GITHUB_OUTPUT"
|
echo "args=-t ${IMAGE}:${VERSION} -t ${IMAGE}:latest" >> "$GITHUB_OUTPUT"
|
||||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
echo "::notice::Release build: ${VERSION} + latest"
|
echo "::notice::Release build: ${VERSION} + latest"
|
||||||
|
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.
|
||||||
|
echo "args=-t ${IMAGE}:dev" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "version=dev" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "::notice::Dev-branch build: :dev"
|
||||||
else
|
else
|
||||||
# Main is the protected, post-PR-merge branch. Treat it as the
|
# Main is the protected, post-PR-merge branch. Treat it as the
|
||||||
# rolling stable channel — every main push moves :latest.
|
# rolling stable channel — every main push moves :latest.
|
||||||
@@ -316,9 +366,12 @@ jobs:
|
|||||||
| docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin
|
| docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin
|
||||||
|
|
||||||
- name: Download signed APK artifact
|
- name: Download signed APK artifact
|
||||||
# Tag pushes only — android-release just produced this. Non-tag
|
# Tag and dev pushes — android-release just produced this. Only `main`
|
||||||
# builds take the "Bundle latest release APK" path below instead.
|
# takes the "Bundle latest release APK" path below, because it is the
|
||||||
if: steps.guard.outputs.ready == 'true' && startsWith(github.ref, 'refs/tags/v')
|
# 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,
|
# Consuming half of the pair — never actions/download-artifact. Same fork,
|
||||||
# same reason: upstream's client-side GHES check rejects this hostname
|
# same reason: upstream's client-side GHES check rejects this hostname
|
||||||
# before it connects. bvandeusen/download-artifact mirrors
|
# before it connects. bvandeusen/download-artifact mirrors
|
||||||
@@ -338,22 +391,26 @@ jobs:
|
|||||||
path: client/
|
path: client/
|
||||||
|
|
||||||
- name: Stage bundled APK + version sidecar
|
- name: Stage bundled APK + version sidecar
|
||||||
if: steps.guard.outputs.ready == 'true' && startsWith(github.ref, 'refs/tags/v')
|
if: >-
|
||||||
|
steps.guard.outputs.ready == 'true' &&
|
||||||
|
(startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/dev')
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
# Both pulled from android-release's outputs so the sidecar the
|
# All three pulled from android-release's outputs so the sidecar the
|
||||||
# server hands clients matches exactly what is baked into the APK
|
# server hands clients matches exactly what is baked into the APK
|
||||||
# they are comparing against.
|
# they are comparing against.
|
||||||
APK_VERSION_NAME: ${{ needs.android-release.outputs.version_name }}
|
APK_VERSION_NAME: ${{ needs.android-release.outputs.version_name }}
|
||||||
APK_VERSION_CODE: ${{ needs.android-release.outputs.version_code }}
|
APK_VERSION_CODE: ${{ needs.android-release.outputs.version_code }}
|
||||||
|
APK_CHANNEL: ${{ needs.android-release.outputs.channel }}
|
||||||
run: |
|
run: |
|
||||||
set -euxo pipefail
|
set -euxo pipefail
|
||||||
# The artifact lands as `app-release.apk` (the original Gradle
|
# The artifact lands as `app-release.apk` (the original Gradle
|
||||||
# output name). The Dockerfile COPYs client/* into /app/client/
|
# output name). The Dockerfile COPYs client/* into /app/client/
|
||||||
# and the server reads minstrel.apk + minstrel.apk.version.
|
# and the server reads minstrel.apk + minstrel.apk.version.
|
||||||
mv client/app-release.apk client/minstrel.apk
|
mv client/app-release.apk client/minstrel.apk
|
||||||
printf '{"name":"%s","code":%s,"channel":"stable"}\n' \
|
printf '{"name":"%s","code":%s,"channel":"%s"}\n' \
|
||||||
"${APK_VERSION_NAME}" "${APK_VERSION_CODE}" > client/minstrel.apk.version
|
"${APK_VERSION_NAME}" "${APK_VERSION_CODE}" "${APK_CHANNEL}" \
|
||||||
|
> client/minstrel.apk.version
|
||||||
cat client/minstrel.apk.version
|
cat client/minstrel.apk.version
|
||||||
ls -lh client/
|
ls -lh client/
|
||||||
|
|
||||||
@@ -365,7 +422,7 @@ jobs:
|
|||||||
# that build actually recorded rather than something re-derived here.
|
# that build actually recorded rather than something re-derived here.
|
||||||
# Degrades to an empty client/ (404 update channel) — never a wrong
|
# Degrades to an empty client/ (404 update channel) — never a wrong
|
||||||
# version — if no release or APK asset can be resolved.
|
# version — if no release or APK asset can be resolved.
|
||||||
if: steps.guard.outputs.ready == 'true' && !startsWith(github.ref, 'refs/tags/v')
|
if: steps.guard.outputs.ready == 'true' && github.ref == 'refs/heads/main'
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
CI_TOKEN: ${{ secrets.CI_TOKEN }}
|
CI_TOKEN: ${{ secrets.CI_TOKEN }}
|
||||||
|
|||||||
@@ -115,8 +115,13 @@ Image tags (`git.fabledsword.com/bvandeusen/minstrel:<tag>`):
|
|||||||
- `:latest` — the newest blessed image. Moves on every `main` push **and** every release. Recommended for most operators.
|
- `:latest` — the newest blessed image. Moves on every `main` push **and** every release. Recommended for most operators.
|
||||||
- `:vYYYY.MM.DD.HHMM` — immutable release tags, never moved or deleted. Pin one for a deployment you don't want changing under you. The tag is the build's own version name with a `v` in front, derived from the tagged commit's UTC timestamp, so two releases can never collide and a re-cut is simply a new tag.
|
- `:vYYYY.MM.DD.HHMM` — immutable release tags, never moved or deleted. Pin one for a deployment you don't want changing under you. The tag is the build's own version name with a `v` in front, derived from the tagged commit's UTC timestamp, so two releases can never collide and a re-cut is simply a new tag.
|
||||||
- `:main` — the rolling post-merge tip. Same image as `:latest` at push time; choose it if you want to track `main` explicitly rather than the release line.
|
- `:main` — the rolling post-merge tip. Same image as `:latest` at push time; choose it if you want to track `main` explicitly rather than the release line.
|
||||||
|
- `:dev` — the rolling test channel, rebuilt on every push to `dev` and carrying its own freshly-built Android APK. Run this when you want to try something before it ships. It moves constantly, has no per-commit tag to pin, and its only recovery path is forward — if a `:dev` image is broken, the fix is the next push, not a rollback.
|
||||||
|
|
||||||
Every `:latest` and every `:vYYYY.MM.DD.HHMM` bundles the current signed Android APK, so the in-app update channel is always live. Database migrations run automatically at startup; rollbacks require restoring a Postgres dump.
|
Every `:latest`, `:vYYYY.MM.DD.HHMM` and `:dev` bundles a signed Android APK, so the in-app update channel is always live. All of them are signed with the same key, so a phone can move between the stable and dev channels without uninstalling — point it at a `:dev` server and the in-app updater offers that channel's build.
|
||||||
|
|
||||||
|
The app reports which channel it is on alongside its version, and decides whether an update is available using the build's ordering key rather than its displayed name — the same value Android installs by, so an offer it makes is one the platform will accept.
|
||||||
|
|
||||||
|
Database migrations run automatically at startup; rollbacks require restoring a Postgres dump.
|
||||||
|
|
||||||
Releases before 2026-09-10 use the older per-day `:vYYYY.MM.DD` shape. Those tags still exist and still work — they are simply not extended.
|
Releases before 2026-09-10 use the older per-day `:vYYYY.MM.DD` shape. Those tags still exist and still work — they are simply not extended.
|
||||||
|
|
||||||
|
|||||||
@@ -177,3 +177,66 @@ func TestReleaseWorkflow_UsesTheSharedDerivation(t *testing.T) {
|
|||||||
t.Error("release.yml derives a commit count again — that is not monotonic across branches")
|
t.Error("release.yml derives a commit count again — that is not monotonic across branches")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// devArm returns the branch of "Compute image tags" that handles refs/heads/dev.
|
||||||
|
func devArm(t *testing.T, yaml string) string {
|
||||||
|
t.Helper()
|
||||||
|
const marker = `elif [[ "${GITHUB_REF}" == "refs/heads/dev" ]]; then`
|
||||||
|
i := strings.Index(yaml, marker)
|
||||||
|
if i < 0 {
|
||||||
|
t.Fatal("no refs/heads/dev arm in Compute image tags — the dev channel is not wired")
|
||||||
|
}
|
||||||
|
rest := yaml[i+len(marker):]
|
||||||
|
if j := strings.Index(rest, "\n else"); j >= 0 {
|
||||||
|
return rest[:j]
|
||||||
|
}
|
||||||
|
return rest
|
||||||
|
}
|
||||||
|
|
||||||
|
// The worst regression this wiring can produce: a dev push that also moves
|
||||||
|
// :latest would ship untested code to every stable operator, silently, on the
|
||||||
|
// next pull. Nothing else in the suite would notice — the build stays green
|
||||||
|
// and the image is valid, it is simply the wrong audience.
|
||||||
|
func TestDevChannel_PublishesDevAloneAndNeverLatest(t *testing.T) {
|
||||||
|
body, err := os.ReadFile(filepath.Join(repoRoot(t), ".gitea", "workflows", "release.yml"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
arm := devArm(t, string(body))
|
||||||
|
|
||||||
|
if !strings.Contains(arm, "${IMAGE}:dev") {
|
||||||
|
t.Errorf("dev arm does not publish :dev\n%s", arm)
|
||||||
|
}
|
||||||
|
if strings.Contains(arm, ":latest") {
|
||||||
|
t.Errorf("dev arm moves :latest — that ships dev code to every stable operator\n%s", arm)
|
||||||
|
}
|
||||||
|
// Rule 145: a rolling channel gets no commit-addressable tag.
|
||||||
|
if strings.Contains(arm, "GITHUB_SHA") {
|
||||||
|
t.Errorf("dev arm publishes a per-commit tag; a rolling channel should not\n%s", arm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The two bundling paths must stay mutually exclusive. If the rebundle step's
|
||||||
|
// condition were relaxed back to "not a tag", a dev push would run BOTH: stage
|
||||||
|
// its freshly-built APK, then overwrite it with the previous release's. The
|
||||||
|
// image would still build and the sidecar would still parse — it would just
|
||||||
|
// quietly serve stale art to the channel whose whole job is being current.
|
||||||
|
func TestDevChannel_RebundlePathIsMainOnly(t *testing.T) {
|
||||||
|
body, err := os.ReadFile(filepath.Join(repoRoot(t), ".gitea", "workflows", "release.yml"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
yaml := string(body)
|
||||||
|
|
||||||
|
i := strings.Index(yaml, "- name: Bundle latest release APK")
|
||||||
|
if i < 0 {
|
||||||
|
t.Fatal("no 'Bundle latest release APK' step")
|
||||||
|
}
|
||||||
|
step := yaml[i:]
|
||||||
|
if j := strings.Index(step, "\n - name:"); j >= 0 {
|
||||||
|
step = step[:j]
|
||||||
|
}
|
||||||
|
if !strings.Contains(step, "github.ref == 'refs/heads/main'") {
|
||||||
|
t.Errorf("the rebundle step is not gated to main; a dev push would overwrite its own APK\n%s", step)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user