Merge pull request 'Main builds and bundles its own signed APK, so a merge ships the app without a release (#6465)' (#157) from dev into main
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m33s
release / go (push) Successful in 1m51s
release / android (push) Successful in 5m24s
release / integration (push) Successful in 5m29s
release / Build signed APK (push) Successful in 5m34s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 38s
release / Verify release artifacts (tag releases only) (push) Skipped
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m33s
release / go (push) Successful in 1m51s
release / android (push) Successful in 5m24s
release / integration (push) Successful in 5m29s
release / Build signed APK (push) Successful in 5m34s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 38s
release / Verify release artifacts (tag releases only) (push) Skipped
This commit was merged in pull request #157.
This commit is contained in:
+38
-101
@@ -3,8 +3,8 @@ 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 dev → :dev (freshly-built dev APK bundled)
|
||||||
# push to main → :latest + :<sha> (latest-release APK bundled)
|
# push to main → :latest + :<sha> (freshly-built stable APK bundled)
|
||||||
# push tag vYYYY.MM.DD.HHMM → :latest (fresh APK bundled)
|
# push tag vYYYY.MM.DD.HHMM → :latest (fresh APK bundled, and attached to the Release)
|
||||||
# workflow_dispatch → manual trigger (same rules based on the ref)
|
# workflow_dispatch → manual trigger (same rules based on the ref)
|
||||||
#
|
#
|
||||||
# That is the whole tag map.
|
# That is the whole tag map.
|
||||||
@@ -41,6 +41,12 @@ name: release
|
|||||||
# image in the same run from the same commit, and the channel cannot go
|
# image in the same run from the same commit, and the channel cannot go
|
||||||
# stale against its own artifact.
|
# stale against its own artifact.
|
||||||
#
|
#
|
||||||
|
# Every branch build carries the app built from its own commit, so a merge to
|
||||||
|
# main is all it takes to put a new app in front of stable phones: `:latest`
|
||||||
|
# serves it from /api/client/apk and the in-app update offers it. A release
|
||||||
|
# is an optional bookmark — a named point in history with the APK attached
|
||||||
|
# for download — never a step the app has to wait for.
|
||||||
|
#
|
||||||
# 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
|
||||||
@@ -56,20 +62,14 @@ name: release
|
|||||||
# :latest is updated by every main push AND every tag push, so it always
|
# :latest is updated by every main push AND every tag push, so it always
|
||||||
# reflects the newest blessed image.
|
# reflects the newest blessed image.
|
||||||
#
|
#
|
||||||
# APK pipeline: on tag pushes the android-release job builds + signs the
|
# APK pipeline: on every push — dev, main and tags alike — the
|
||||||
# Android APK and uploads it as a workflow artifact. The image-release
|
# android-release job builds + signs the Android APK and uploads it as a
|
||||||
# job declares `needs: android-release`, so the docker image cannot
|
# workflow artifact. The image-release job declares `needs: android-release`
|
||||||
# start building until the APK is guaranteed-ready — no polling, no
|
# and requires its success, so the docker image cannot start building until
|
||||||
# race, no silent-failure mode. Attaching the APK to the gitea Release is
|
# the APK is guaranteed-ready — no polling, no race, no silent-failure mode,
|
||||||
# its own job (release-assets), behind the test gate below.
|
# and no image that moves a channel without an app inside it. Attaching the
|
||||||
#
|
# APK to the gitea Release is its own job (release-assets), tags only, behind
|
||||||
# :latest always carries an APK. Because every main push also moves
|
# the test gate below.
|
||||||
# :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.
|
|
||||||
#
|
#
|
||||||
# THE GATE. Every verifying lane lives in this file —
|
# THE GATE. Every verifying lane lives in this file —
|
||||||
# Go (vet, lint, short tests), the Postgres integration suite, the web app
|
# Go (vet, lint, short tests), the Postgres integration suite, the web app
|
||||||
@@ -400,11 +400,14 @@ jobs:
|
|||||||
# ------------------------------------------------------------ artifacts --
|
# ------------------------------------------------------------ artifacts --
|
||||||
|
|
||||||
android-release:
|
android-release:
|
||||||
name: Build signed APK (releases and dev)
|
name: Build signed APK
|
||||||
# Also builds on `dev`, which is what makes a test channel possible at
|
# Builds on every ref, so each channel's image carries the app from its
|
||||||
# all: without it the only way to get a build onto a phone is to cut a
|
# own commit: `dev` for the test channel, `main` for stable phones, and a
|
||||||
# release, which quietly turns `main` into the staging area.
|
# tag for the copy attached to its Release.
|
||||||
if: startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/dev'
|
if: >-
|
||||||
|
startsWith(github.ref, 'refs/tags/v')
|
||||||
|
|| github.ref == 'refs/heads/dev'
|
||||||
|
|| github.ref == 'refs/heads/main'
|
||||||
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
|
||||||
@@ -601,9 +604,9 @@ jobs:
|
|||||||
path: release-apk/
|
path: release-apk/
|
||||||
|
|
||||||
- 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
|
# Tag releases only. A branch build has no Release to hang assets on
|
||||||
# does not need one — the :dev image bundles the APK, and the server
|
# and does not need one — its image bundles the APK, and the server
|
||||||
# serves it from /api/client/apk like any other.
|
# serves it from /api/client/apk.
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
CI_TOKEN: ${{ secrets.CI_TOKEN }}
|
CI_TOKEN: ${{ secrets.CI_TOKEN }}
|
||||||
@@ -667,10 +670,9 @@ jobs:
|
|||||||
|
|
||||||
image-release:
|
image-release:
|
||||||
name: Build + push container image
|
name: Build + push container image
|
||||||
# Every lane must have SUCCEEDED, each named here. Then the
|
# Every lane must have SUCCEEDED, each named here, and so must the APK
|
||||||
# APK: tag and dev pushes build one and it must have succeeded; main
|
# build: every ref bundles the APK built from its own commit, so an
|
||||||
# pushes skip android-release and bundle the latest release's APK
|
# image without one would move a channel with no app for phones to take.
|
||||||
# instead, so for main alone a skipped android-release is expected.
|
|
||||||
needs: [go, integration, web, android, govulncheck, android-release]
|
needs: [go, integration, web, android, govulncheck, android-release]
|
||||||
if: >-
|
if: >-
|
||||||
${{
|
${{
|
||||||
@@ -680,8 +682,7 @@ jobs:
|
|||||||
&& needs.web.result == 'success'
|
&& needs.web.result == 'success'
|
||||||
&& needs.android.result == 'success'
|
&& needs.android.result == 'success'
|
||||||
&& needs.govulncheck.result == 'success'
|
&& needs.govulncheck.result == 'success'
|
||||||
&& (needs.android-release.result == 'success'
|
&& needs.android-release.result == 'success' }}
|
||||||
|| (needs.android-release.result == 'skipped' && github.ref == 'refs/heads/main')) }}
|
|
||||||
runs-on: go-ci
|
runs-on: go-ci
|
||||||
container:
|
container:
|
||||||
image: git.fabledsword.com/bvandeusen/ci-go:1.26
|
image: git.fabledsword.com/bvandeusen/ci-go:1.26
|
||||||
@@ -747,9 +748,10 @@ jobs:
|
|||||||
# publish the moving channel tag and never a commit-addressable
|
# publish the moving channel tag and never a commit-addressable
|
||||||
# one.
|
# one.
|
||||||
#
|
#
|
||||||
# :latest must move here rather than waiting for the next main
|
# :latest moves here so the server hands out the same APK build
|
||||||
# push, or the channel would carry the PREVIOUS release's APK
|
# that is attached to the Release: a phone that installed from the
|
||||||
# indefinitely — a channel that cannot refresh itself.
|
# Release page and one that updated in-app hold one build, with one
|
||||||
|
# ordering key. Cut a release on main's tip, as :latest follows it.
|
||||||
CHANNEL=stable
|
CHANNEL=stable
|
||||||
echo "args=-t ${IMAGE}:latest" >> "$GITHUB_OUTPUT"
|
echo "args=-t ${IMAGE}:latest" >> "$GITHUB_OUTPUT"
|
||||||
echo "::notice::Release build ${VERSION}: refreshing :latest around the new APK"
|
echo "::notice::Release build ${VERSION}: refreshing :latest around the new APK"
|
||||||
@@ -782,12 +784,8 @@ 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 and dev pushes — android-release just produced this. Only `main`
|
# android-release just produced this, on every ref.
|
||||||
# takes the "Bundle latest release APK" path below, because it is the
|
if: steps.guard.outputs.ready == 'true'
|
||||||
# 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: stock download-artifact, which works here for
|
# Consuming half of the pair: stock download-artifact, which works here for
|
||||||
# the same reason as the upload (gitea/runner 3.x edits the GHES refusal
|
# the same reason as the upload (gitea/runner 3.x edits the GHES refusal
|
||||||
# out of the bundle). v8 runs on node24, which every CI-runner image
|
# out of the bundle). v8 runs on node24, which every CI-runner image
|
||||||
@@ -798,9 +796,7 @@ jobs:
|
|||||||
path: client/
|
path: client/
|
||||||
|
|
||||||
- name: Stage bundled APK + version sidecar
|
- name: Stage bundled APK + version sidecar
|
||||||
if: >-
|
if: steps.guard.outputs.ready == 'true'
|
||||||
steps.guard.outputs.ready == 'true' &&
|
|
||||||
(startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/dev')
|
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
# All three pulled from android-release's outputs so the sidecar the
|
# All three pulled from android-release's outputs so the sidecar the
|
||||||
@@ -821,65 +817,6 @@ jobs:
|
|||||||
cat client/minstrel.apk.version
|
cat client/minstrel.apk.version
|
||||||
ls -lh client/
|
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.
|
|
||||||
# Re-deriving it from the tagged commit would keep the formula in two
|
|
||||||
# files that must stay in step, and could only ever recover the name —
|
|
||||||
# the ordering key is build-time minutes and exists nowhere 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
|
- name: Build and push
|
||||||
if: steps.guard.outputs.ready == 'true'
|
if: steps.guard.outputs.ready == 'true'
|
||||||
# --pull: the Dockerfile's base images are floating tags (golang:1.26,
|
# --pull: the Dockerfile's base images are floating tags (golang:1.26,
|
||||||
|
|||||||
@@ -272,73 +272,41 @@ func TestDevChannel_PublishesDevAloneAndNeverLatest(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The two bundling paths must stay mutually exclusive. If the rebundle step's
|
// A merge to main is enough to put a new app in front of stable phones: main
|
||||||
// condition were relaxed back to "not a tag", a dev push would run BOTH: stage
|
// builds the signed APK from its own commit and bakes it into :latest, as dev
|
||||||
// its freshly-built APK, then overwrite it with the previous release's. The
|
// does for :dev. The regression to guard is main falling back to a tag-only
|
||||||
// image would still build and the sidecar would still parse — it would just
|
// APK build, which leaves every merged app change waiting on a release while
|
||||||
// quietly serve stale art to the channel whose whole job is being current.
|
// each build stays green.
|
||||||
func TestDevChannel_RebundlePathIsMainOnly(t *testing.T) {
|
func TestEveryBranchShipsTheAppBuiltFromItsOwnCommit(t *testing.T) {
|
||||||
body, err := os.ReadFile(filepath.Join(repoRoot(t), ".gitea", "workflows", "release.yml"))
|
jobs := releaseJobs(t)
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
yaml := string(body)
|
|
||||||
|
|
||||||
i := strings.Index(yaml, "- name: Bundle latest release APK")
|
build := strings.Join(strings.Fields(jobs["android-release"].If), " ")
|
||||||
|
for _, ref := range []string{"refs/heads/main", "refs/heads/dev", "refs/tags/v"} {
|
||||||
|
if !strings.Contains(build, ref) {
|
||||||
|
t.Errorf("android-release does not build on %s, so that ref's image carries no app of its own\n if: %s", ref, build)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
image := strings.Join(strings.Fields(jobs["image-release"].If), " ")
|
||||||
|
if !strings.Contains(image, "needs.android-release.result == 'success'") || strings.Contains(image, "'skipped'") {
|
||||||
|
t.Errorf("image-release can publish without a freshly-built APK\n if: %s", image)
|
||||||
|
}
|
||||||
|
|
||||||
|
yaml := releaseYAML(t)
|
||||||
|
if strings.Contains(yaml, "releases/latest") {
|
||||||
|
t.Error("release.yml fetches the latest release; an image bundling a release's APK ships an app older than its own commit")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both bundling steps run on every ref. Sliced from image-release on,
|
||||||
|
// because release-assets has a step of the same name.
|
||||||
|
i := strings.Index(yaml, "\n image-release:")
|
||||||
if i < 0 {
|
if i < 0 {
|
||||||
t.Fatal("no 'Bundle latest release APK' step")
|
t.Fatal("no image-release job")
|
||||||
}
|
}
|
||||||
step := yaml[i:]
|
for _, name := range []string{"Download signed APK artifact", "Stage bundled APK + version sidecar"} {
|
||||||
if j := strings.Index(step, "\n - name:"); j >= 0 {
|
step := stepBody(t, yaml[i:], name)
|
||||||
step = step[:j]
|
if strings.Contains(step, "github.ref") {
|
||||||
}
|
t.Errorf("%q is gated by ref, so some ref's image ships without its APK\n%s", name, step)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The runner invokes `shell: bash` as `bash -e -o pipefail`. That makes any
|
|
||||||
// command substitution whose grep matches NOTHING fatal at the assignment —
|
|
||||||
// pipefail reports the grep's non-zero status even though `head` succeeded —
|
|
||||||
// so the step dies before reaching the `if` written to handle the empty case.
|
|
||||||
//
|
|
||||||
// This is not hypothetical. It took down the first `main` build after the
|
|
||||||
// version rework: the newest release predated sidecar assets, its
|
|
||||||
// `.apk.version` grep matched nothing, and the step aborted instead of
|
|
||||||
// falling through to the name-only branch that exists precisely for it. The
|
|
||||||
// same latent hazard sat on the other two assignments and had simply never
|
|
||||||
// fired, because their greps always matched.
|
|
||||||
//
|
|
||||||
// Pins the property that makes every "degrades gracefully" branch in that
|
|
||||||
// step reachable at all.
|
|
||||||
func TestBundleStep_GrepsCannotKillTheStep(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]
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, line := range strings.Split(step, "\n") {
|
|
||||||
trimmed := strings.TrimSpace(line)
|
|
||||||
if strings.HasPrefix(trimmed, "#") || !strings.Contains(trimmed, "grep") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// Only assignments from a command substitution can abort the step.
|
|
||||||
if !strings.Contains(trimmed, `="$(`) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !strings.HasSuffix(trimmed, "|| true") {
|
|
||||||
t.Errorf("this assignment dies under pipefail when its grep matches nothing, "+
|
|
||||||
"skipping the empty-case branch below it:\n %s", trimmed)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -376,7 +344,7 @@ func TestReleaseBuild_RefreshesTheChannelAndMintsNothingElse(t *testing.T) {
|
|||||||
arm, _, _ := imageTagArms(t, releaseYAML(t))
|
arm, _, _ := imageTagArms(t, releaseYAML(t))
|
||||||
|
|
||||||
if !strings.Contains(arm, "${IMAGE}:latest") {
|
if !strings.Contains(arm, "${IMAGE}:latest") {
|
||||||
t.Errorf("tag arm does not refresh :latest — the channel would keep serving the PREVIOUS release's APK until someone pushed to main\n%s", arm)
|
t.Errorf("tag arm does not refresh :latest — the server would hand out a different APK build from the one attached to the Release\n%s", arm)
|
||||||
}
|
}
|
||||||
if strings.Contains(arm, "GITHUB_SHA") {
|
if strings.Contains(arm, "GITHUB_SHA") {
|
||||||
t.Errorf("tag arm mints a :<sha> image; that would re-push main's immutable rollback target with different bundled contents\n%s", arm)
|
t.Errorf("tag arm mints a :<sha> image; that would re-push main's immutable rollback target with different bundled contents\n%s", arm)
|
||||||
|
|||||||
Reference in New Issue
Block a user