Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
259 changes: 259 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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.<versionCode> ($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 "<!-- Draft. Group by area and rewrite before use. -->"
} > "notes/github-release-notes-${NAME}.md"
{
echo "<!-- Play Console 'What's new'. Short, plain, feature-first, no version numbers."
echo " Written by hand — this file is a placeholder, not a draft. -->"
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"
111 changes: 111 additions & 0 deletions .github/workflows/tag.yml
Original file line number Diff line number Diff line change
@@ -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-<version>-<code>.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 }}') }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Title quotes break tag step

Low Severity

The optional title input is interpolated into PowerShell single-quoted strings. An apostrophe in the title (for example a “What’s new” release name) closes the string early and fails the tag step after Play upload.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 82e4221. Configure here.

$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"
Loading