From e368b82f0a45ce1921362f58afc7b11fb7020348 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 7 Aug 2026 08:23:11 -0400 Subject: [PATCH 1/2] ci(release): fail loudly when a tag release ends up without its APK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v2026.08.07 had to be re-cut, and the tag build's android-release job never started — no job log was written at all, so all eight steps reported `failure` with none executed and image-release showed `skipped`. The run was red, but the release PAGE rendered fine and main's own push build had already moved :latest, so the code was deployable and nothing looked obviously wrong. What was actually missing — the attached APK and the immutable :vYYYY.MM.DD image — is easy to skim past, and I nearly did. This cannot prevent that. The cause was a runner failing to launch a container, not anything in this file, and it did not reproduce on an unchanged re-run. What this does is make the CONSEQUENCE legible: an incomplete release now fails with a named error instead of eight mystery step failures, and the message says to re-run the run rather than delete and re-create the tag. `if: always()` is load-bearing — the job has to report precisely when the jobs above did not succeed. Correcting the record while here: I first blamed this on the workflow's `cancel-in-progress` concurrency block. That was wrong. Cancellation needs a NEWER run in the same group, and there was exactly one run on the tag ref (total_count 632 -> 633 on release creation); the main-push runs sit in a different group. Plausible mechanism, unchecked precondition. Validated locally: YAML parses, `bash -n` clean, and the asset-parsing logic unit-checked against a release with an APK, one with no assets, and one with a non-APK asset. --- .gitea/workflows/release.yml | 55 ++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 577b5b45..8d1dc317 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -322,3 +322,58 @@ jobs: docker buildx build \ --build-arg MINSTREL_VERSION="${{ steps.tags.outputs.version }}" \ --push ${{ steps.tags.outputs.args }} . + + # Verifies a tag release actually ended up complete, and names the specific + # thing that's missing if not. + # + # Added 2026-08-07 after v2026.08.07 was re-cut. The android-release job never + # started — no log was written at all — so all eight of its steps reported + # `failure` with none executed and image-release showed `skipped`. The run was + # red, but the *release page rendered fine*, and `main`'s own push build had + # already moved `:latest`, so the code was deployable and nothing looked + # obviously wrong. The release was simply missing its APK and its immutable + # `:vYYYY.MM.DD` image, which is easy to skim past. + # + # This job cannot prevent that (the cause was a runner failing to launch, not + # anything in this file). What it does is turn an incomplete release into an + # explicit, named error instead of eight mystery step failures — so the + # consequence is legible without having to infer it. + # + # `if: always()` is the whole point: it has to report precisely when the jobs + # above did NOT succeed. + verify-release: + name: Verify release artifacts (tag releases only) + needs: [android-release, image-release] + if: ${{ always() && startsWith(github.ref, 'refs/tags/v') }} + runs-on: go-ci + container: + image: git.fabledsword.com/bvandeusen/ci-go:1.26 + + steps: + - name: Release must have an APK attached + shell: bash + env: + CI_TOKEN: ${{ secrets.CI_TOKEN }} + run: | + set -euo pipefail + TAG="${GITHUB_REF#refs/tags/}" + REPO="${GITHUB_REPOSITORY}" + + REL_JSON="$(curl -fsSL \ + -H "Authorization: token ${CI_TOKEN}" \ + "https://git.fabledsword.com/api/v1/repos/${REPO}/releases/tags/${TAG}" || true)" + if [ -z "${REL_JSON}" ]; then + echo "::error::no release found for ${TAG} — the tag exists but nothing was published" + exit 1 + fi + + APK="$(printf '%s' "${REL_JSON}" \ + | grep -oP '"browser_download_url":\s*"\K[^"]+' \ + | grep -E '\.apk$' | head -1 || true)" + if [ -z "${APK}" ]; then + echo "::error::release ${TAG} has NO APK attached — in-app update will offer nothing, and the bundled-APK path on future :latest builds has no source." + echo "::error::Fix by RE-RUNNING this workflow run. Do NOT delete and re-create the tag; if it fails again the runner never started the container, and the evidence is in act_runner on the host (Gitea will hold no job log)." + exit 1 + fi + + echo "::notice::${TAG} verified — APK attached: ${APK}" From a254cb2273c5e1cc661a2ea19abb504997a8282f Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 7 Aug 2026 08:27:09 -0400 Subject: [PATCH 2/2] ci(release): close the verify blind spot, check preconditions before the build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auditing the gating turned up two problems. verify-release only checked the APK. Because it runs with `always()`, it runs even when image-release FAILED — so android succeeding while the image push died would have reported "verified" on a release with no immutable :vYYYY.MM.DD image. That is exactly half of what was missing when v2026.08.07 had to be re-cut, so the guard would have caught the incident we had and waved through its mirror image. Now checks the image too, via docker manifest inspect. "Attach APK to gitea Release" resolves the release by tag and fails if it is absent — but it is the LAST step, so a bare `git push origin vX` built an APK for several minutes before discovering it had nowhere to put it. Same check now runs immediately after version computation: seconds, not minutes. Releases created through the API create tag and release together and pass it. The rest of the gating audits clean, and one part is worth not "fixing": image-release's `if: !failure() && !cancelled()` looks odd next to `needs: [android-release]` but is correct. On main pushes android-release is SKIPPED, and a skipped dependency is not success() — so the obvious `if: success()` would silently stop main from ever publishing :latest. Steps 4/5 vs 6 are mutually exclusive on the tag context, and every image step gates on the Dockerfile+go.mod guard. Validated: YAML parses, and `bash -n` over every run: block in all three jobs is clean. --- .gitea/workflows/release.yml | 48 +++++++++++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 8d1dc317..6e89d541 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -98,6 +98,31 @@ jobs: echo "code=${COMMIT_COUNT}" >> "$GITHUB_OUTPUT" echo "::notice::APK version: ${VERSION_NAME} (code=${COMMIT_COUNT})" + # 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 — + # but that is the final step, so a tag pushed without a release built an + # APK for several minutes first and only then discovered it had nowhere to + # put it. Same check, seconds in instead of minutes. + # + # Releases are normally created through the API (which creates the tag and + # the release together, so this passes). A bare `git push origin vX` is the + # case this catches. + - name: Release must exist for this tag + shell: bash + working-directory: ${{ github.workspace }} + env: + CI_TOKEN: ${{ secrets.CI_TOKEN }} + run: | + set -euo pipefail + TAG="${GITHUB_REF#refs/tags/}" + if ! curl -fsSL -o /dev/null \ + -H "Authorization: token ${CI_TOKEN}" \ + "https://git.fabledsword.com/api/v1/repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}"; then + echo "::error::no release exists for ${TAG}. Create the release (which creates the tag) rather than pushing a bare tag — otherwise there is nothing to attach the APK to." + exit 1 + fi + echo "::notice::release found for ${TAG}" + - name: Cache Gradle dirs uses: actions/cache@v4 with: @@ -376,4 +401,25 @@ jobs: exit 1 fi - echo "::notice::${TAG} verified — APK attached: ${APK}" + echo "::notice::APK attached: ${APK}" + + # The other half. Checking only the APK would report success on a release + # whose image push failed — which is precisely the second thing that was + # missing when v2026.08.07 had to be re-cut. `always()` on this job means + # it runs even when image-release failed, so without this the guard would + # cheerfully verify an incomplete release. + - name: Immutable image tag must exist + shell: bash + run: | + set -euo pipefail + TAG="${GITHUB_REF#refs/tags/}" + IMAGE="git.fabledsword.com/bvandeusen/minstrel" + + echo "${{ secrets.CI_TOKEN }}" \ + | docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin + + if ! docker manifest inspect "${IMAGE}:${TAG}" > /dev/null 2>&1; then + echo "::error::image ${IMAGE}:${TAG} was never pushed — the release tag has no immutable image, so there is nothing to pin or roll back to. Re-run this workflow run." + exit 1 + fi + echo "::notice::image verified: ${IMAGE}:${TAG}"