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" 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" 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 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",