From 9268579c5f1297e6a025baf8c57cac7632003e82 Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 20 Jul 2026 19:00:04 +0000 Subject: [PATCH] ci(release): make the release tag-driven (no push to protected main) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The push-to-main release model kept failing: the job builds the signed APK fine, but the final `git push origin HEAD:main` (version-bump commit) is rejected by main's branch protection — "pre-receive hook declined / Internal Server Error" — across runs #187, #194. main is deliberately protected (allowlist push, required approvals, required status checks), which is fundamentally incompatible with a CI job pushing a fresh commit to it. Flip the trigger: the workflow now runs on pushing a `v*` tag (or via workflow_dispatch with a tag input). The tag *is* the release input, so: - version/versionCode are derived from the tag name (no version-planning engine); - app/build.gradle.kts is set for the build only, never committed back; - no `git push` to main, no tag creation, no REGISTRY_USER needed — only REGISTRY_TOKEN, to create the Gitea release + upload the APK/SHA256SUMS. To cut a release now: `git tag v0.1.0 && git push origin v0.1.0`. Keeps the speed fixes from #13 (trimmed setup-android, no Gradle cache, timeout-minutes). Changelog is still generated from conventional-commit subjects since the previous tag. Co-Authored-By: Claude --- .gitea/workflows/release.yml | 162 ++++++++++------------------------- 1 file changed, 44 insertions(+), 118 deletions(-) diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index ad328d4..7f75623 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -1,37 +1,23 @@ -# Automated build + release for the Runic Gateway Android app. +# Automated release for the Runic Gateway Android app. # -# Trigger: every push to `main` (i.e. every merged PR). +# Trigger: pushing a version tag `v*` (e.g. `v0.1.0`). Tag-driven on purpose — the +# build never has to push to protected `main`; the tag *is* the release input. # -# Flow (two conceptual halves, kept separate on purpose) — mirrors link/'s engine: +# To cut a release: +# git tag v0.1.0 && git push origin v0.1.0 +# (or create the tag from the Gitea UI). Re-build/re-release an existing tag via +# the workflow_dispatch input below. # -# ┌── RELEASE ENGINE (language-agnostic) ─────────────────────────────┐ -# │ reads: latest v* git tag + conventional-commit subjects │ -# │ produces: next version, changelog, and (at the end) the release │ -# └───────────────────────────────────────────────────────────────────┘ -# ┌── ANDROID ADAPTER (the only Android-specific part) ───────────────┐ -# │ consumes: the version │ -# │ produces: the artifact (a signed release APK + SHA256SUMS) │ -# └───────────────────────────────────────────────────────────────────┘ -# -# Version bump (conventional commits since the last v* tag): -# feat!: / BREAKING CHANGE -> major feat: -> minor fix|perf: -> patch -# nothing releasable -> no release is cut -# (first ever run, no tag) -> releases the current build.gradle.kts version as-is -# versionName is the semver; versionCode is derived major*10000+minor*100+patch so -# it is deterministic + monotonic (PLAN.md §10). The bump is committed back to -# app/build.gradle.kts, then tagged. +# versionName = the tag without its leading `v`; versionCode = major*10000 + +# minor*100 + patch (deterministic + monotonic, PLAN.md §10). Both are injected +# into app/build.gradle.kts for the build only — nothing is committed back to main. # # Prerequisites (Settings -> Actions -> Secrets on RunicGateway/Android-app): -# REGISTRY_USER — Gitea username the token below belongs to -# REGISTRY_TOKEN — Gitea access token with `write:repository` (push the bump -# commit + tag and create the release) +# REGISTRY_TOKEN — Gitea access token with `write:repository` (create the release) # ANDROID_KEYSTORE_BASE64 — base64 of the release .jks (single line) # ANDROID_KEYSTORE_PASSWORD — keystore password # ANDROID_KEY_ALIAS — key alias (e.g. runicgateway) # ANDROID_KEY_PASSWORD — key password (== store password for a PKCS12 keystore) -# Also: `main` must accept a direct push from the REGISTRY_USER account (disable -# branch protection for it, or add it as an exception) — the bump commit lands on -# main. The bump commit carries `[skip ci]`, so it does not re-trigger this workflow. # # Runner handling matches pr-checks.yml (self-hosted `ubuntu-latest`): the container # lacks git/curl/unzip and can't reach api.adoptium.net, so we apt-install the base @@ -42,11 +28,16 @@ name: Release APK on: push: - branches: [main] - workflow_dispatch: {} + tags: + - 'v*' + workflow_dispatch: + inputs: + tag: + description: 'Existing v* tag to (re)build and release' + required: true concurrency: - group: release-apk + group: release-apk-${{ github.event.inputs.tag || github.ref_name }} cancel-in-progress: false env: @@ -61,10 +52,6 @@ jobs: # the self-hosted runner) instead of hanging forever and — because concurrency # is `cancel-in-progress: false` — blocking every later release behind it. timeout-minutes: 30 - # Don't loop on our own bump commit (belt-and-suspenders with [skip ci]). - # Quoted because the expression contains a colon (`chore(release):`), which an - # unquoted YAML scalar would misparse as a mapping value. - if: "${{ !contains(github.event.head_commit.message, 'chore(release): bump version') }}" steps: - name: Install base tools + JDK 17 run: | @@ -72,70 +59,46 @@ jobs: apt-get install -y git curl unzip jq openjdk-17-jdk-headless echo "JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64" >> "$GITHUB_ENV" - - name: Check out full history (need tags + commit log for the bump) + - name: Check out the release tag (full history for the changelog) uses: actions/checkout@v4 with: + ref: ${{ github.event.inputs.tag || github.ref_name }} fetch-depth: 0 - # ── RELEASE ENGINE: decide the next version + changelog ────────────── - - name: Plan the release (version + changelog) + # ── Derive version + changelog straight from the tag ───────────────── + - name: Plan the release (version + changelog from the tag) id: plan run: | set -euo pipefail mkdir -p dist git fetch --tags --force >/dev/null 2>&1 || true - # Current committed version (the `?: "x.y.z"` default in build.gradle.kts). - MANIFEST_VERSION="$(sed -nE 's/.*\?: "([0-9]+\.[0-9]+\.[0-9]+)".*/\1/p' "${GRADLE_MODULE}/build.gradle.kts" | head -1)" - LAST_TAG="$(git describe --tags --match 'v*' --abbrev=0 2>/dev/null || true)" - if [ -n "$LAST_TAG" ]; then RANGE="${LAST_TAG}..HEAD"; else RANGE="HEAD"; fi + TAG="${{ github.event.inputs.tag || github.ref_name }}" + case "$TAG" in + v[0-9]*) : ;; + *) echo "::error::expected a v* version tag, got '$TAG'"; exit 1 ;; + esac + VERSION="${TAG#v}" - SUBJECTS="$(git log --no-merges --format='%s' $RANGE || true)" - BODIES="$(git log --no-merges --format='%B' $RANGE || true)" - - BUMP=none - if echo "$BODIES" | grep -qE 'BREAKING[ -]CHANGE' ; then BUMP=major; fi - if echo "$SUBJECTS" | grep -qE '^[a-z]+(\([^)]+\))?!:' ; then BUMP=major; fi - if [ "$BUMP" = none ] && echo "$SUBJECTS" | grep -qE '^feat(\([^)]+\))?:' ; then BUMP=minor; fi - if [ "$BUMP" = none ] && echo "$SUBJECTS" | grep -qE '^(fix|perf)(\([^)]+\))?:'; then BUMP=patch; fi - - bump() { # -> bumped - IFS=. read -r MA MI PA <<< "$1" - case "$2" in - major) echo "$((MA+1)).0.0" ;; - minor) echo "${MA}.$((MI+1)).0" ;; - patch) echo "${MA}.${MI}.$((PA+1))" ;; - esac - } - - RELEASE=true - if [ -z "$LAST_TAG" ]; then - VERSION="$MANIFEST_VERSION" # first release: ship what's committed - elif [ "$BUMP" = none ]; then - RELEASE=false # no feat/fix/breaking since last tag - VERSION="${LAST_TAG#v}" - else - VERSION="$(bump "${LAST_TAG#v}" "$BUMP")" - fi - - if git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then - echo "Tag v${VERSION} already exists — nothing to release." - RELEASE=false - fi - - # Derive a deterministic, monotonic versionCode from the semver. + # versionCode: deterministic + monotonic from the semver (PLAN.md §10). IFS=. read -r MA MI PA <<< "$VERSION" + : "${MA:=0}"; : "${MI:=0}"; : "${PA:=0}" VERSION_CODE=$(( MA*10000 + MI*100 + PA )) + # Changelog: conventional-commit subjects since the previous v* tag. + PREV_TAG="$(git describe --tags --match 'v*' --abbrev=0 "${TAG}^" 2>/dev/null || true)" + if [ -n "$PREV_TAG" ]; then RANGE="${PREV_TAG}..${TAG}"; else RANGE="${TAG}"; fi + SUBJECTS="$(git log --no-merges --format='%s' $RANGE || true)" + { - echo "## Runic Gateway Android v${VERSION}" + echo "## Runic Gateway Android ${TAG}" echo FEATS="$(echo "$SUBJECTS" | grep -E '^feat' || true)" FIXES="$(echo "$SUBJECTS" | grep -E '^(fix|perf)' || true)" [ -n "$FEATS" ] && { echo "### Features"; echo "$FEATS" | sed 's/^/- /'; echo; } [ -n "$FIXES" ] && { echo "### Fixes"; echo "$FIXES" | sed 's/^/- /'; echo; } echo "### All changes" - if [ -n "$LAST_TAG" ]; then echo "Since ${LAST_TAG}:"; fi + if [ -n "$PREV_TAG" ]; then echo "Since ${PREV_TAG}:"; fi echo "$SUBJECTS" | sed 's/^/- /' echo echo "---" @@ -144,14 +107,11 @@ jobs: echo "version=${VERSION}" >> "$GITHUB_OUTPUT" echo "versionCode=${VERSION_CODE}" >> "$GITHUB_OUTPUT" - echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT" - echo "release=${RELEASE}" >> "$GITHUB_OUTPUT" - echo "bump=${BUMP}" >> "$GITHUB_OUTPUT" - echo "==> release=${RELEASE} version=${VERSION} code=${VERSION_CODE} bump=${BUMP} last_tag=${LAST_TAG:-}" + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + echo "==> tag=${TAG} version=${VERSION} code=${VERSION_CODE} prev_tag=${PREV_TAG:-}" - # ── ANDROID ADAPTER: SDK + signing keystore ────────────────────────── + # ── SDK + signing keystore ─────────────────────────────────────────── - name: Set up Android SDK - if: ${{ steps.plan.outputs.release == 'true' }} uses: android-actions/setup-android@v3 with: # Only put cmdline-tools on PATH. The action's default package set drags in @@ -161,13 +121,11 @@ jobs: packages: '' - name: Install Android SDK packages - if: ${{ steps.plan.outputs.release == 'true' }} run: | set +o pipefail yes | sdkmanager "platform-tools" "platforms;android-35" "build-tools;35.0.0" - name: Decode signing keystore - if: ${{ steps.plan.outputs.release == 'true' }} env: ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} run: | @@ -179,9 +137,8 @@ jobs: printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > "${RUNNER_TEMP}/release.jks" echo "ANDROID_KEYSTORE_FILE=${RUNNER_TEMP}/release.jks" >> "$GITHUB_ENV" - # ── ANDROID ADAPTER: set the version, gate, build the signed APK ───── - - name: Set the app version to match the release - if: ${{ steps.plan.outputs.release == 'true' }} + # ── Set the version, build the signed APK ──────────────────────────── + - name: Set the app version to match the tag run: | set -euo pipefail VERSION="${{ steps.plan.outputs.version }}" @@ -192,7 +149,6 @@ jobs: grep -nE "versionCode = |versionName = " "${GRADLE_MODULE}/build.gradle.kts" - name: Unit tests + signed release APK - if: ${{ steps.plan.outputs.release == 'true' }} env: ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} @@ -203,7 +159,6 @@ jobs: ./gradlew --no-daemon :${GRADLE_MODULE}:testDebugUnitTest :${GRADLE_MODULE}:assembleRelease - name: Package APK + SHA256SUMS - if: ${{ steps.plan.outputs.release == 'true' }} run: | set -euo pipefail SRC="${GRADLE_MODULE}/build/outputs/apk/release/app-release.apk" @@ -212,37 +167,8 @@ jobs: ( cd dist && sha256sum "runic-gateway-${{ steps.plan.outputs.version }}.apk" > SHA256SUMS ) ls -l dist && cat dist/SHA256SUMS - # ── RELEASE ENGINE: commit the bump, tag, push ─────────────────────── - - name: Commit version bump and push tag - if: ${{ steps.plan.outputs.release == 'true' }} - env: - REGISTRY_USER: ${{ secrets.REGISTRY_USER }} - REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} - run: | - set -euo pipefail - TAG="${{ steps.plan.outputs.tag }}" - # Secrets can arrive with a trailing newline; a stray CR/LF corrupts the - # remote URL / auth header. Strip line breaks before use. - CI_USER="$(printf '%s' "${REGISTRY_USER}" | tr -d '\r\n')" - CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')" - git config user.name "android-app-ci" - git config user.email "ci@whitlocktech.com" - git remote set-url origin \ - "https://${CI_USER}:${CI_TOKEN}@${GITEA_HOST}/${REPO}.git" - - git add "${GRADLE_MODULE}/build.gradle.kts" - if ! git diff --cached --quiet; then - git commit -m "chore(release): bump version to ${TAG} [skip ci]" - git push origin "HEAD:main" - else - echo "Version unchanged (first release) — no bump commit needed." - fi - git tag "${TAG}" - git push origin "${TAG}" - - # ── RELEASE ENGINE: create the Gitea release + upload assets ───────── + # ── Create the Gitea release + upload assets (no push to main) ─────── - name: Create Gitea release and upload assets - if: ${{ steps.plan.outputs.release == 'true' }} env: REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} run: |