From 0f8234bc1222617cbee551d889f02a716e585ae0 Mon Sep 17 00:00:00 2001 From: Steven Gates Date: Mon, 7 Sep 2026 15:29:34 -0500 Subject: [PATCH 1/4] ci(release): add the bump/build/sign release workflow Run 1 of the two-run release pipeline. Bumps the version, opens and merges its own bump PR, then builds and signs the AAB from the resulting merge sha and publishes it as a run artifact for the owner to upload to Play. Deliberately runs no pre-PR sweep: the bump diff is two lines, and every commit it carries was already swept by its own feature PR. The release-variant build (R8, resource shrinking, the Firebase guard, signing) is the gate this flow adds, because feature PRs only ever build debug. --- .github/workflows/release.yml | 259 ++++++++++++++++++++++++++++++++++ 1 file changed, 259 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..a2dc036e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,259 @@ +name: Release · build signed AAB + +# Run 1 of 2. Bumps the version, merges its own bump PR, builds and signs the AAB from the +# resulting merge sha, and publishes it as a run artifact. The owner uploads that .aab to Play, +# then dispatches tag.yml (run 2) to cut the tag — so the tag keeps meaning "this shipped". +# +# There is deliberately NO pre-PR sweep here. The bump diff is two lines in app/build.gradle.kts; +# nothing in the sweep can be affected by changing an integer, and every commit this release +# carries already passed the sweep on its own feature PR. What feature PRs never cover is the +# RELEASE variant — R8, resource shrinking, the baseline profile, #178's Firebase guard, signing — +# so `bundleRelease` succeeding and `jarsigner -verify` passing is the gate this flow adds. +# +# Secrets required: RELEASE_TOKEN, KEYSTORE_BASE64, KEYSTORE_PASSWORD, KEY_ALIAS, KEY_PASSWORD, +# GOOGLE_SERVICES_JSON. + +on: + workflow_dispatch: + inputs: + dry_run: + description: "Build only — no bump, no PR, no merge. Builds main as it stands." + type: boolean + default: false + +permissions: + contents: read + +concurrency: + group: openloop-release + cancel-in-progress: false + +jobs: + release: + name: Bump · build · sign + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ secrets.RELEASE_TOKEN }} + + steps: + - name: Checkout main + uses: actions/checkout@v6 + with: + ref: main + fetch-depth: 0 + token: ${{ secrets.RELEASE_TOKEN }} + + # Read the version rather than accepting it as input — the owner should never type a + # versionCode, and a typo here ships the wrong number to Play. + - name: Read current version + id: current + run: | + set -euo pipefail + code=$(grep -oP 'versionCode\s*=\s*\K\d+' app/build.gradle.kts) + name=$(grep -oP 'versionName\s*=\s*"\K[^"]+' app/build.gradle.kts) + # The repo's convention since 1.0.47: versionName's last segment == versionCode. If that + # ever breaks, a guessed scheme would be worse than stopping. + if [[ "$name" != "1.0.$code" ]]; then + echo "::error::versionName '$name' does not match 1.0. ($code). The 1:1 mapping broke — decide the scheme by hand before releasing." + exit 1 + fi + next=$((code + 1)) + { + echo "code=$code" + echo "name=$name" + echo "next_code=$next" + echo "next_name=1.0.$next" + } >> "$GITHUB_OUTPUT" + echo "Current $name ($code) → 1.0.$next ($next)" + + - name: Bump, open PR, merge + id: bump + if: ${{ !inputs.dry_run }} + env: + NEXT_CODE: ${{ steps.current.outputs.next_code }} + NEXT_NAME: ${{ steps.current.outputs.next_name }} + PREV_NAME: ${{ steps.current.outputs.name }} + run: | + set -euo pipefail + branch="chore/release-${NEXT_NAME}" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git switch -c "$branch" + + sed -i -E "s/versionCode = [0-9]+/versionCode = ${NEXT_CODE}/" app/build.gradle.kts + sed -i -E "s/versionName = \"[^\"]+\"/versionName = \"${NEXT_NAME}\"/" app/build.gradle.kts + + # A release bump is exactly two lines. Anything else means sed matched something it + # shouldn't have, and that must not reach main unnoticed. + read -r added removed _ < <(git diff --numstat -- app/build.gradle.kts) + if [[ "$added" != "2" || "$removed" != "2" ]]; then + echo "::error::Expected +2/-2 in app/build.gradle.kts, got +${added}/-${removed}." + git diff -- app/build.gradle.kts + exit 1 + fi + + git commit -am "chore(release): bump to ${NEXT_NAME} (versionCode ${NEXT_CODE})" + git push origin "$branch" + + gh pr create --base main --head "$branch" \ + --title "chore(release): bump to ${NEXT_NAME} (versionCode ${NEXT_CODE})" \ + --body "$(printf '%s\n' \ + "\`versionCode\` ${{ steps.current.outputs.code }} → ${NEXT_CODE}, \`versionName\` \"${PREV_NAME}\" → \"${NEXT_NAME}\"." \ + "" \ + "Opened and merged by \`release.yml\` run [\`${GITHUB_RUN_ID}\`](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID})." \ + "" \ + "No pre-PR sweep: the diff is two lines, and every commit this release carries was swept on its own PR. The release-variant build in the same run is the gate." )" + + gh pr merge "$branch" --merge --admin --delete-branch + git fetch origin main --quiet + echo "sha=$(git rev-parse origin/main)" >> "$GITHUB_OUTPUT" + + - name: Resolve build sha (dry run) + id: dry + if: ${{ inputs.dry_run }} + run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + + # Never build a moving branch. Pin to one sha, prove it is on main, and prove the version + # at that sha is the one being released. + - name: Verify and check out the build sha + id: build + env: + SHA: ${{ steps.bump.outputs.sha || steps.dry.outputs.sha }} + run: | + set -euo pipefail + git fetch origin main --quiet + if ! git merge-base --is-ancestor "$SHA" origin/main; then + echo "::error::$SHA is not an ancestor of origin/main — refusing to build it." + exit 1 + fi + git switch --detach "$SHA" + code=$(grep -oP 'versionCode\s*=\s*\K\d+' app/build.gradle.kts) + name=$(grep -oP 'versionName\s*=\s*"\K[^"]+' app/build.gradle.kts) + { + echo "sha=$SHA" + echo "code=$code" + echo "name=$name" + echo "aab=releases/openloop-$name-$code.aab" + } >> "$GITHUB_OUTPUT" + echo "Building $name ($code) from $SHA" + + - name: Set up JDK 17 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: '17' + + - name: Set up Android SDK + uses: android-actions/setup-android@v3 + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + + # keystore.properties and google-services.json are both gitignored, so CI has to + # reconstitute them. Actions masks these values in the log. + - name: Restore signing material + env: + KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }} + KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} + KEY_ALIAS: ${{ secrets.KEY_ALIAS }} + KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} + GOOGLE_SERVICES_JSON: ${{ secrets.GOOGLE_SERVICES_JSON }} + run: | + set -euo pipefail + printf '%s' "$KEYSTORE_BASE64" | base64 -d > "$RUNNER_TEMP/upload.jks" + printf '%s' "$GOOGLE_SERVICES_JSON" > app/google-services.json + { + echo "storeFile=$RUNNER_TEMP/upload.jks" + echo "storePassword=$KEYSTORE_PASSWORD" + echo "keyAlias=$KEY_ALIAS" + echo "keyPassword=$KEY_PASSWORD" + } > keystore.properties + # Prove the material is usable before spending 15 minutes on R8. + keytool -list -keystore "$RUNNER_TEMP/upload.jks" -storepass "$KEYSTORE_PASSWORD" -alias "$KEY_ALIAS" > /dev/null + echo "Keystore opens and contains the alias." + + - name: Build the release bundle + run: ./gradlew :app:bundleRelease --no-daemon --stacktrace + + - name: Verify the signature + id: sign + run: | + set -euo pipefail + built=app/build/outputs/bundle/release/app-release.aab + if ! jarsigner -verify "$built" | grep -q 'jar verified'; then + echo "::error::jarsigner did not report the bundle as verified." + jarsigner -verify -verbose "$built" | tail -20 + exit 1 + fi + mkdir -p releases + cp "$built" "${{ steps.build.outputs.aab }}" + echo "$(jarsigner -verify "$built" | head -1) → ${{ steps.build.outputs.aab }}" + + # Lesson 040: a new native/JNI/reflection dependency can crash only under R8, which nothing + # in this workflow would catch. Detect the condition and say so; don't pretend to verify it. + - name: Lesson 040 — native dependency check + env: + SHA: ${{ steps.build.outputs.sha }} + run: | + set -euo pipefail + prev=$(git tag --sort=-v:refname | head -1) + if [[ -z "$prev" ]]; then + echo "No previous tag — skipping." + elif git diff --quiet "$prev".."$SHA" -- gradle/libs.versions.toml; then + echo "gradle/libs.versions.toml unchanged since $prev — Lesson 040 does not apply." + else + echo "::warning::Dependency catalog changed since $prev. If anything native, JNI-backed or reflection-heavy landed, run the Lesson 040 release-APK device check before uploading." + git diff "$prev".."$SHA" -- gradle/libs.versions.toml + fi + + - name: Draft release notes + env: + NAME: ${{ steps.build.outputs.name }} + SHA: ${{ steps.build.outputs.sha }} + run: | + set -euo pipefail + mkdir -p notes + prev=$(git tag --sort=-v:refname | head -1) + { + echo "# ${NAME}" + echo + echo "## What's new since ${prev:-the beginning}" + echo + if [[ -n "$prev" ]]; then git log "$prev".."$SHA" --oneline --no-merges; fi + echo + echo "" + } > "notes/github-release-notes-${NAME}.md" + { + echo "" + if [[ -n "$prev" ]] && git diff --quiet "$prev".."$SHA" -- app/src; then + echo "NOTE: no app/src changed since ${prev}. This release has nothing user-facing to announce." + fi + } > "notes/play-notes-${NAME}.md" + + - name: Publish the bundle and notes + uses: actions/upload-artifact@v4 + with: + name: openloop-${{ steps.build.outputs.name }} + path: | + ${{ steps.build.outputs.aab }} + notes/ + if-no-files-found: error + retention-days: 30 + + - name: Summary + run: | + { + echo "## ${{ steps.build.outputs.name }} (versionCode ${{ steps.build.outputs.code }})" + echo + echo "| | |" + echo "|---|---|" + echo "| Build sha | \`${{ steps.build.outputs.sha }}\` |" + echo "| Bundle | \`${{ steps.build.outputs.aab }}\` |" + echo "| Signature | verified |" + echo + echo "### Next" + echo + echo "1. Download the artifact above and upload the \`.aab\` to Play Console." + echo "2. Dispatch **Tag release** with version \`${{ steps.build.outputs.name }}\`, sha \`${{ steps.build.outputs.sha }}\`, run id \`${GITHUB_RUN_ID}\`." + } >> "$GITHUB_STEP_SUMMARY" From fbbb682ddbf87507cd679a3909b1b28c9221d9db Mon Sep 17 00:00:00 2001 From: Steven Gates Date: Mon, 7 Sep 2026 15:33:39 -0500 Subject: [PATCH 2/4] ci(release): add the tag/GitHub-release workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run 2 of the two-run release pipeline, dispatched by the owner once the .aab from run 1 is uploaded to Play — so the tag keeps meaning "this shipped", exactly as the manual process does today. Calls scripts/tag-release.ps1 unchanged; pwsh and gh are both preinstalled on ubuntu runners and the script shells out to nothing else. Passing the run id downloads that run's bundle first, so the script's "was this actually built" check is real rather than a warning. --- .github/workflows/tag.yml | 111 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 .github/workflows/tag.yml diff --git a/.github/workflows/tag.yml b/.github/workflows/tag.yml new file mode 100644 index 00000000..cfe3a23a --- /dev/null +++ b/.github/workflows/tag.yml @@ -0,0 +1,111 @@ +name: Release · tag + +# Run 2 of 2. Dispatched by hand AFTER the .aab from run 1 has been uploaded to Play, so the tag +# continues to mean "this shipped" rather than "this built" — the same contract the manual process +# has today (docs/play-store/release-signing-and-aab.md #5). +# +# When the Play upload itself is automated later, this becomes the tail of release.yml and the two +# runs collapse into one. Nothing else about the design has to change. +# +# scripts/tag-release.ps1 runs unmodified: it shells out only to `git` and `gh`, and both — plus +# pwsh — are preinstalled on ubuntu runners. + +on: + workflow_dispatch: + inputs: + version: + description: "versionName to tag, e.g. 1.0.52 (no 'v' prefix)" + required: true + type: string + sha: + description: "Build sha from the release run — never a branch name" + required: true + type: string + title: + description: "Release title. Defaults to the version." + required: false + type: string + run_id: + description: "Release run id. Downloads that run's .aab so the script's bundle check is real." + required: false + type: string + notes: + description: "Use the curated notes from the release run instead of gh --generate-notes" + type: boolean + default: false + +permissions: + contents: read + +concurrency: + group: openloop-release + cancel-in-progress: false + +jobs: + tag: + name: Cut tag and GitHub release + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ secrets.RELEASE_TOKEN }} + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: main + fetch-depth: 0 + token: ${{ secrets.RELEASE_TOKEN }} + + # tag-release.ps1 warns when releases/openloop--.aab is missing. In CI it + # always would be, which turns a real safety check into noise — so pull the artifact the + # release run actually produced and let the check mean something. + - name: Fetch the built bundle + if: ${{ inputs.run_id != '' }} + run: | + set -euo pipefail + gh run download "${{ inputs.run_id }}" --dir artifact + mkdir -p releases notes + find artifact -name '*.aab' -exec cp {} releases/ \; + find artifact -name '*.md' -exec cp {} notes/ \; + ls -l releases/ + + - name: Verify the bundle matches the sha being tagged + if: ${{ inputs.run_id != '' }} + run: | + set -euo pipefail + code=$(git show "${{ inputs.sha }}:app/build.gradle.kts" | grep -oP 'versionCode\s*=\s*\K\d+') + expected="releases/openloop-${{ inputs.version }}-$code.aab" + if [[ ! -f "$expected" ]]; then + echo "::error::$expected is not in run ${{ inputs.run_id }}'s artifacts. Wrong run id, or the bundle was never built." + ls -l releases/ || true + exit 1 + fi + jarsigner -verify "$expected" | grep -q 'jar verified' + echo "$expected is present and still verifies." + + - name: Cut the tag and release + shell: pwsh + run: | + $tagArgs = @( + '-Version', '${{ inputs.version }}' + '-Sha', '${{ inputs.sha }}' + ) + if ('${{ inputs.title }}') { $tagArgs += @('-Title', '${{ inputs.title }}') } + $notes = "notes/github-release-notes-${{ inputs.version }}.md" + if ('${{ inputs.notes }}' -eq 'true') { + if (-not (Test-Path $notes)) { throw "Curated notes requested but $notes is not present — pass run_id, or leave notes off to use --generate-notes." } + $tagArgs += @('-NotesFile', $notes) + } + ./scripts/tag-release.ps1 @tagArgs + + - name: Confirm the release is live + run: | + set -euo pipefail + gh release view "${{ inputs.version }}" --json tagName,url,isDraft \ + --jq '"\(.tagName) → \(.url) (draft: \(.isDraft))"' + { + echo "## ${{ inputs.version }} tagged" + echo + echo "Tag cut at \`${{ inputs.sha }}\`." + gh release view "${{ inputs.version }}" --json url --jq '.url' + } >> "$GITHUB_STEP_SUMMARY" From b0cd6ef283448c64d9954c58b209641a8b1d22e3 Mon Sep 17 00:00:00 2001 From: Steven Gates Date: Mon, 7 Sep 2026 15:38:49 -0500 Subject: [PATCH 3/4] chore(spelling): add elif, numstat, storepass, temurin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four terms the release workflow uses: `git diff --numstat` (the +2/-2 bump guard), setup-java's temurin distribution, keytool's -storepass on the pre-build keystore check, and bash elif in the Lesson 040 branch. Each is load-bearing — dropping them would mean a weaker guard or a worse check, which is the wrong trade against a dictionary entry. The IDE dictionary moves in the same commit so gate 6d stays green. --- cspell.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cspell.json b/cspell.json index ebd94abb..a16e0167 100644 --- a/cspell.json +++ b/cspell.json @@ -147,6 +147,7 @@ "edge-to-edge", "egl", "EINVAL", + "elif", "emma", "emulator", "endgroup", @@ -331,6 +332,7 @@ "nullness", "numpy", "nums", + "numstat", "objdump", "oes", "omxc", @@ -437,6 +439,7 @@ "StateFlow", "stdlib", "stomion", + "storepass", "storetype", "stozo", "stuttery", @@ -455,6 +458,7 @@ "techbloat", "techrt", "telehealth", + "temurin", "testsrc", "testTag", "tflite", From 82e42216b27a93972094d0908f0a27247661d0a9 Mon Sep 17 00:00:00 2001 From: Steven Gates Date: Mon, 7 Sep 2026 15:39:44 -0500 Subject: [PATCH 4/4] chore(spelling): sync IDE dictionary with cspell.json Adds elif, numstat, storepass and temurin so scripts/sync-ide-dictionary.py --check stays green alongside the cspell change. --- .idea/dictionaries/project.xml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.idea/dictionaries/project.xml b/.idea/dictionaries/project.xml index b066a256..15c83bbc 100644 --- a/.idea/dictionaries/project.xml +++ b/.idea/dictionaries/project.xml @@ -124,6 +124,7 @@ edge-to-edge egl einval + elif emma emulator endgroup @@ -308,6 +309,7 @@ nullness numpy nums + numstat objdump oes omxc @@ -414,6 +416,7 @@ stateflow stdlib stomion + storepass storetype stozo stuttery @@ -432,6 +435,7 @@ techbloat techrt telehealth + temurin testsrc testtag tflite