diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..256a7f5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# --- Secrets / signing (must never be committed) --- +*.keystore +*.jks +*.p12 +*.pem +*.key +keystore* +*.base64 +key.properties +local.properties + +# --- Build / scratch --- +*.zip +*.tmp +tmp/ +out/ +dist/ + +# --- OS cruft --- +.DS_Store +Thumbs.db +desktop.ini + +# --- Editors / IDEs --- +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# --- Logs --- +*.log \ No newline at end of file diff --git a/Dockerfile.yml b/Dockerfile.yml new file mode 100644 index 0000000..8fd5581 --- /dev/null +++ b/Dockerfile.yml @@ -0,0 +1,48 @@ +# Native linux/arm64 Android build toolchain for CI. +# Builds release artifacts only — no emulator, no test/system images. +# eclipse-temurin publishes a native linux/arm64 manifest, so this runs +# without emulation on an OCI Ampere (aarch64) runner. +FROM eclipse-temurin:21-jdk-noble + +# --- Version pins (bump deliberately; this is what your app repos trust) --- +# cmdline-tools: find the current build number at +# https://developer.android.com/studio#command-line-tools-only +ARG CMDLINE_TOOLS_VERSION=13114758 +ARG BUILD_TOOLS_VERSION=35.0.0 +ARG PLATFORM_VERSION=android-35 + +ENV ANDROID_SDK_ROOT=/opt/android-sdk \ + ANDROID_HOME=/opt/android-sdk \ + DEBIAN_FRONTEND=noninteractive + +# git is needed by some Gradle plugins; unzip/curl for SDK install. +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl unzip git ca-certificates && \ + rm -rf /var/lib/apt/lists/* + +# Install the command-line tools into the canonical "latest" location. +RUN mkdir -p ${ANDROID_SDK_ROOT}/cmdline-tools && \ + curl -fsSL -o /tmp/tools.zip \ + "https://dl.google.com/android/repository/commandlinetools-linux-${CMDLINE_TOOLS_VERSION}_latest.zip" && \ + unzip -q /tmp/tools.zip -d ${ANDROID_SDK_ROOT}/cmdline-tools && \ + mv ${ANDROID_SDK_ROOT}/cmdline-tools/cmdline-tools ${ANDROID_SDK_ROOT}/cmdline-tools/latest && \ + rm /tmp/tools.zip + +ENV PATH=${PATH}:${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin:${ANDROID_SDK_ROOT}/platform-tools + +# Accept licenses and bake the SDK packages into the image so prod builds +# don't depend on Google's endpoint at job time. +# aapt2 and the build-tools binaries ship native arm64 for 35.x, so APK/AAB +# packaging runs natively on aarch64. +RUN yes | sdkmanager --licenses >/dev/null && \ + sdkmanager --install \ + "platform-tools" \ + "platforms;${PLATFORM_VERSION}" \ + "build-tools;${BUILD_TOOLS_VERSION}" && \ + rm -rf ${ANDROID_SDK_ROOT}/.android + +# Sanity: fail the image build if the toolchain isn't actually usable. +RUN java -version && sdkmanager --version && \ + test -d "${ANDROID_SDK_ROOT}/platforms/${PLATFORM_VERSION}" + +WORKDIR /workspace \ No newline at end of file diff --git a/README.md b/README.md index cf9418d..c201dbc 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,94 @@ -# android +# android-builder -Android build \ No newline at end of file +Native `linux/arm64` Android build toolchain for CI, published to the Gitea +container registry at `git.helu.ca/r/android`. + +App repos consume this image to build signed release artifacts. Instrumented +tests are **not** part of this toolchain — by design, CI builds are promotions +of code already tested in Dev (on Apple silicon, where the emulator runs +natively). The OCI Ampere (aarch64) runner has no `/dev/kvm` (the guest VM +boots at EL1, so KVM can't access HYP/EL2), so there's no accelerated emulator +here — and we don't need one. + +## What's in the image + +- Eclipse Temurin JDK 21 (native arm64) +- Android cmdline-tools, platform, and build-tools — **baked in**, so prod + builds don't depend on Google's download endpoint at job time +- `git`, `curl`, `unzip` + +Pinned versions live as `ARG`s at the top of the `Dockerfile`: + +| ARG | Default | Where to check | +| ----------------------- | ----------- | -------------- | +| `CMDLINE_TOOLS_VERSION` | `13114758` | https://developer.android.com/studio#command-line-tools-only | +| `BUILD_TOOLS_VERSION` | `35.0.0` | SDK Manager / release notes | +| `PLATFORM_VERSION` | `android-35`| your app's `compileSdk` | + +`aapt2` and the build-tools binaries ship native arm64 for 35.x, so packaging +runs without emulation. + +## Tagging model + +The whole point of owning this image is that **app repos pin a tag and a new +toolchain never lands in a prod build by accident.** + +- **Immutable** — a git tag like `2026.06` produces `git.helu.ca/r/android:2026.06`. + This is what app repos pin to. +- **Moving** — pushes to `main` (and any tag build) refresh + `git.helu.ca/r/android:latest` for convenience. Don't pin prod builds to this. + +## Cutting a new toolchain + +1. Update the `ARG` defaults in the `Dockerfile` if you're moving SDK/tool + versions. Verify `CMDLINE_TOOLS_VERSION` against the link above — a stale + number 404s the image build. +2. Commit to `main`. The workflow builds and pushes `:latest`; confirm it's green. +3. Tag the release with the year-month you want app repos to reference: + ``` + git tag 2026.06 + git push origin 2026.06 + ``` + This publishes the immutable `git.helu.ca/r/android:2026.06`. +4. Roll app repos forward deliberately by bumping their pinned tag (see below) + — one at a time if you want to validate, all at once if you trust the bump. + +## Using it in an app repo + +Drop `.gitea/workflows/build.yml` (the template alongside this repo) into the +app and pin the toolchain: + +```yaml +jobs: + build: + runs-on: [self-hosted, arm64] + container: + image: git.helu.ca/r/android:2026.06 + credentials: + username: ${{ gitea.actor }} + password: ${{ secrets.GITEA_TOKEN }} +``` + +The build task is selectable: `assembleRelease` (APK, the default) or +`bundleRelease` (AAB — only needed for Google Play distribution). + +### Required secrets in each app repo (or org-level) + +Signing happens at job time; nothing sensitive lives in the repo or the image. + +| Secret | What it is | +| ------------------- | ---------- | +| `KEYSTORE_BASE64` | release keystore, base64-encoded: `base64 -w0 release.keystore` | +| `KEYSTORE_PASSWORD` | keystore password | +| `KEY_ALIAS` | signing key alias | +| `KEY_PASSWORD` | key password | + +`GITEA_TOKEN` needs `write:package` here (to push) and `read:package` in app +repos (to pull). The built-in token usually covers this; if your instance +scopes it tightly, use a PAT. + +## First-run sequencing + +The image must exist in the registry before any app workflow can pull it. +So: cut and push `2026.06` from this repo first, confirm it's in the registry, +*then* the app workflows that pin it will resolve. \ No newline at end of file diff --git a/build.yml b/build.yml new file mode 100644 index 0000000..158b8f0 --- /dev/null +++ b/build.yml @@ -0,0 +1,79 @@ +name: build-release + +# Drop this into any Android app repo at .gitea/workflows/build.yml +# It runs the release build inside the pinned toolchain image and publishes +# the signed artifact. Instrumented tests are NOT run here — by design, +# prod builds are promotions of code already tested in Dev. + +on: + push: + tags: ["v*"] # build prod artifacts on version tags + workflow_dispatch: + inputs: + gradle_task: + description: "Release task" + type: choice + default: assembleRelease # APK. Use bundleRelease for a Play AAB. + options: [assembleRelease, bundleRelease] + +env: + # Pin the toolchain. Bump deliberately when you roll forward. + BUILDER_IMAGE: git.helu.ca/r/android:2026.06 + # Default task for tag-triggered builds (workflow_dispatch overrides via input) + DEFAULT_TASK: assembleRelease + +jobs: + build: + runs-on: [self-hosted, arm64] + container: + image: git.helu.ca/r/android:2026.06 + credentials: + username: ${{ gitea.actor }} + password: ${{ secrets.GITEA_TOKEN }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Resolve gradle task + id: task + run: | + TASK="${{ inputs.gradle_task }}" + [ -z "$TASK" ] && TASK="${DEFAULT_TASK}" + echo "task=$TASK" >> "$GITHUB_OUTPUT" + + # --- Signing --------------------------------------------------------- + # Store the keystore as a base64-encoded Gitea Actions secret and the + # passwords as separate secrets. Nothing sensitive lives in the repo + # or the image. Decode at job time into a path Gradle reads. + # + # Create the base64 secret yourself with: + # base64 -w0 release.keystore (copy output into secret KEYSTORE_BASE64) + # + # Reference KEYSTORE_PASSWORD / KEY_ALIAS / KEY_PASSWORD from your + # signingConfig (or via -Pandroid.injected.signing.* as below). + - name: Decode keystore + run: | + echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > "$RUNNER_TEMP/release.keystore" + + - name: Build release + run: | + ./gradlew --no-daemon ${{ steps.task.outputs.task }} \ + -Pandroid.injected.signing.store.file="$RUNNER_TEMP/release.keystore" \ + -Pandroid.injected.signing.store.password="${{ secrets.KEYSTORE_PASSWORD }}" \ + -Pandroid.injected.signing.key.alias="${{ secrets.KEY_ALIAS }}" \ + -Pandroid.injected.signing.key.password="${{ secrets.KEY_PASSWORD }}" + + # Collects whichever artifact the task produced — apk or aab. + - name: Collect artifact + run: | + mkdir -p out + find app/build/outputs -type f \( -name "*-release.apk" -o -name "*-release.aab" \) \ + -exec cp {} out/ \; + ls -l out + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: release-artifacts + path: out/* + if-no-files-found: error \ No newline at end of file diff --git a/builder-image.yml b/builder-image.yml new file mode 100644 index 0000000..141c2f5 --- /dev/null +++ b/builder-image.yml @@ -0,0 +1,57 @@ +name: build-android-builder-image + +# Builds the Android toolchain image and pushes it to the Gitea registry. +# Push a tag like `2026.06` to cut an immutable toolchain; pushes to main +# refresh the moving `latest` tag. +on: + push: + branches: [main] + tags: ["*"] + workflow_dispatch: + +env: + REGISTRY: git.helu.ca + IMAGE: git.helu.ca/r/android + +jobs: + build-image: + runs-on: [self-hosted, arm64] + steps: + - name: Checkout + uses: actions/checkout@v4 + + # Determine which tags to push: + # - a git tag -> use it verbatim (e.g. 2026.06) + latest + # - main branch -> latest only + - name: Compute tags + id: tags + run: | + if [ "${{ gitea.ref_type }}" = "tag" ]; then + REF="${{ gitea.ref_name }}" + echo "tags=${IMAGE}:${REF},${IMAGE}:latest" >> "$GITHUB_OUTPUT" + else + echo "tags=${IMAGE}:latest" >> "$GITHUB_OUTPUT" + fi + + - name: Log in to Gitea registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ gitea.actor }} + password: ${{ secrets.GITEA_TOKEN }} + + - name: Set up Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push (linux/arm64) + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + platforms: linux/arm64 + push: true + tags: ${{ steps.tags.outputs.tags }} + # Bump these here if you want to override the Dockerfile defaults + # build-args: | + # BUILD_TOOLS_VERSION=35.0.0 + # PLATFORM_VERSION=android-35 \ No newline at end of file