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
44 changes: 44 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
name: CI

on:
pull_request:
push:
branches: [main]

permissions:
contents: read

jobs:
validate-examples:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Validate shell syntax
run: |
bash -n \
scripts/publish-android.sh \
scripts/validate-android-native-symbols.sh \
examples/generic/*.sh \
tests/*.sh

- name: Run Android native-symbol fixtures
run: tests/test-validate-android-native-symbols.sh

- name: Parse YAML examples
run: |
python3 -m pip install --disable-pip-version-check 'pyyaml==6.0.2'
python3 - <<'PY'
import glob
import yaml

paths = [
"publish-android/action.yml",
*glob.glob("examples/github-actions/*.yml"),
*glob.glob("examples/gitlab-ci/*.yml"),
*glob.glob(".github/workflows/*.yml"),
]
for path in paths:
with open(path, encoding="utf-8") as handle:
yaml.safe_load(handle)
PY
81 changes: 75 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,67 @@ Copy-ready CI examples and reusable actions for publishing app builds to
Hands is the release and distribution layer. Your own build system remains
responsible for compiling and signing the APK, IPA, or other artifact.

## Android native symbols are part of the build

If an APK contains the Hands Android SDK's `libhandscrash.so`, its exact
`native-symbols` classifier is a required release artifact. The AAR and APK
contain a stripped library; repacking that file after the build does **not**
restore function or source information.

Add a resolvable classifier configuration beside the dependency in your Android
app module. This GitHub Packages example deliberately uses one version variable
for both artifacts:

```kotlin
val handsAndroidSdkVersion = "0.11.2" // keep this as your single source of truth
val handsNativeSymbols by configurations.creating {
isCanBeConsumed = false
isCanBeResolved = true
isTransitive = false
}

val copyHandsNativeSymbols by tasks.registering(Sync::class) {
from(handsNativeSymbols)
into(layout.buildDirectory.dir("outputs/hands-native-symbols"))
}

dependencies {
implementation("build.hands:hands-android-sdk:$handsAndroidSdkVersion")
add(
handsNativeSymbols.name,
"build.hands:hands-android-sdk:$handsAndroidSdkVersion:native-symbols@zip",
)
}
```

For JitPack, keep the AAR and classifier on JitPack rather than mixing channels:

```kotlin
implementation("com.github.botiverse:hands:android-sdk-v$handsAndroidSdkVersion")
add(
handsNativeSymbols.name,
"com.github.botiverse:hands:android-sdk-v$handsAndroidSdkVersion:native-symbols@zip",
)
```

Run `assembleRelease` and `copyHandsNativeSymbols` in the same build. The
validator then enforces all of the following before Hands receives anything:

- every APK ABI containing `libhandscrash.so` exists in the archive;
- APK, archive, and `manifest.json` ELF build IDs match per ABI;
- manifest SHA-256 values match the unstripped files;
- each symbol file contains `.debug_info`.

The reusable action defaults to `native-symbols-policy: auto`: an APK containing
`libhandscrash.so` fails when `symbols` is missing. APKs without the Hands native
crash library do not need this archive. `disabled` is an explicit opt-out that
leaves future native crashes unsymbolicatable; do not use it to make a release
green.

For split or external build jobs, transport the APK and classifier archive as
one immutable artifact set. Never download an APK and generate “symbols” from
its stripped libraries later.

## GitHub Actions

### Reusable Android publish action
Expand All @@ -39,6 +100,8 @@ uploads an already-built, signed APK and creates a draft release:
version-name: ${{ github.ref_name }}
version-code: ${{ github.run_number }}
changelog-file: changelog.txt
symbols: path/to/the-resolved/hands-native-symbols.zip
native-symbols-policy: required
hands-token: ${{ secrets.HANDS_BEARER_TOKEN }}
```

Expand All @@ -51,8 +114,8 @@ deliberate review step.
Gradle Android project, signs it through the project's existing release
configuration, and publishes the resulting APK as a Hands draft.
- [`publish-existing-apk.yml`](examples/github-actions/publish-existing-apk.yml)
publishes an APK produced by an earlier job or downloaded from another build
system.
publishes an APK and its exact native-symbol archive produced by an earlier
job or downloaded from another build system.
- [`tauri-publish.yml`](examples/github-actions/tauri-publish.yml) builds signed
Tauri v2 updater bundles on macOS, Linux, and Windows, then publishes one
multi-platform Hands draft on a single channel.
Expand All @@ -74,14 +137,18 @@ export HANDS_BEARER_TOKEN='<publisher deploy token>'
--channel preview \
--version-name 1.0.0 \
--version-code 1000000 \
--changelog ./changelog.txt
--changelog ./changelog.txt \
--symbols ./hands-android-sdk-native-symbols.zip \
--native-symbols-policy required
```

## Security boundary

- Use an app-scoped `publisher` deploy token for CI.
- Store tokens only in the CI platform's encrypted secret store.
- Examples create draft releases by default.
- Android examples validate exact native symbols before upload; validation runs
locally and does not expose symbol bytes or credentials to another service.
- Never commit signing material, deploy tokens, or generated credentials.

## Reference
Expand All @@ -93,7 +160,8 @@ export HANDS_BEARER_TOKEN='<publisher deploy token>'
## GitLab CI

- [`examples/gitlab-ci/android-publish.gitlab-ci.yml`](examples/gitlab-ci/android-publish.gitlab-ci.yml)
— build stage placeholder + draft publish with `hands builds publish-android`.
— same-build APK + exact classifier transport, build-ID validation, then draft
publish with `hands builds publish-android`.
- [`examples/gitlab-ci/ios-publish.gitlab-ci.yml`](examples/gitlab-ci/ios-publish.gitlab-ci.yml)
— IPA + dSYM draft publish; TestFlight upload happens server-side in Hands
afterwards.
Expand Down Expand Up @@ -136,5 +204,6 @@ export HANDS_BEARER_TOKEN='<publisher deploy token>'
- [`examples/generic/publish-electron.sh`](examples/generic/publish-electron.sh)
- [`examples/generic/publish-tauri.sh`](examples/generic/publish-tauri.sh)

Plain shell, driven by environment variables — drop into Jenkins, Buildkite,
or anything that can run bash and npm.
Plain shell, driven by environment variables. Keep the Android wrapper beside
this repository's `scripts/` directory so it can run the shared validator; the
other platform scripts can be copied independently.
26 changes: 17 additions & 9 deletions examples/generic/publish-android.sh
Original file line number Diff line number Diff line change
@@ -1,15 +1,23 @@
#!/usr/bin/env bash
# Publish a signed Android APK to Hands from any CI.
# Env: HANDS_BEARER_TOKEN (deploy token), HANDS_APP_SLUG, APK_PATH,
# VERSION_NAME, VERSION_CODE, optional HANDS_CHANNEL (default preview).
# VERSION_NAME, VERSION_CODE, optional HANDS_CHANNEL (default preview),
# NATIVE_SYMBOLS_PATH, MAPPING_PATH, HANDS_NATIVE_SYMBOLS_POLICY.
# Keep this file beside the repository's scripts/ directory; the canonical
# publisher validates APK/native-symbol build IDs before uploading.
set -euo pipefail
: "${HANDS_BEARER_TOKEN:?}" "${HANDS_APP_SLUG:?}" "${APK_PATH:?}" "${VERSION_NAME:?}" "${VERSION_CODE:?}"
npm install -g @botiverse/hands-cli@0.5.1 >/dev/null
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
git log --no-merges --pretty='- %s' -15 > changelog.txt 2>/dev/null || echo "- release ${VERSION_NAME}" > changelog.txt
hands builds publish-android "${HANDS_APP_SLUG}" \
--apk "${APK_PATH}" \
--channel "${HANDS_CHANNEL:-preview}" \
--version-name "${VERSION_NAME}" \
--version-code "${VERSION_CODE}" \
--changelog-file ./changelog.txt \
--draft
args=(
--app "$HANDS_APP_SLUG"
--apk "$APK_PATH"
--channel "${HANDS_CHANNEL:-preview}"
--version-name "$VERSION_NAME"
--version-code "$VERSION_CODE"
--changelog ./changelog.txt
--native-symbols-policy "${HANDS_NATIVE_SYMBOLS_POLICY:-auto}"
)
test -z "${NATIVE_SYMBOLS_PATH:-}" || args+=(--symbols "$NATIVE_SYMBOLS_PATH")
test -z "${MAPPING_PATH:-}" || args+=(--mapping "$MAPPING_PATH")
"${repo_root}/scripts/publish-android.sh" "${args[@]}"
26 changes: 21 additions & 5 deletions examples/github-actions/android-gradle.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,24 @@ jobs:
cache: gradle

# Configure your normal release signing before this task. Do not place
# signing keys directly in the workflow file.
- name: Build signed release APK
run: ./gradlew :app:assembleRelease
# signing keys directly in the workflow file. copyHandsNativeSymbols is
# the fail-closed classifier task documented in the repository README;
# it must resolve the classifier from the same Maven channel as the AAR.
- name: Build signed release APK and exact Hands native symbols
id: android-artifacts
shell: bash
run: |
set -euo pipefail
./gradlew :app:assembleRelease :app:copyHandsNativeSymbols
symbols="$(find app/build/outputs/hands-native-symbols -maxdepth 1 -type f -name '*native-symbols*.zip' -print -quit)"
test -n "$symbols" || { echo "Hands native-symbols classifier was not resolved" >&2; exit 1; }
echo "symbols=$symbols" >> "$GITHUB_OUTPUT"
mapping="app/build/outputs/mapping/release/mapping.txt"
if test -s "$mapping"; then
echo "mapping=$mapping" >> "$GITHUB_OUTPUT"
else
echo "mapping=" >> "$GITHUB_OUTPUT"
fi

- name: Write changelog
run: git log --no-merges --pretty='- %s' -20 > changelog.txt
Expand All @@ -44,6 +59,7 @@ jobs:
version-name: ${{ inputs.version_name }}
version-code: ${{ inputs.version_code }}
changelog-file: changelog.txt
mapping: app/build/outputs/mapping/release/mapping.txt
mapping: ${{ steps.android-artifacts.outputs.mapping }}
symbols: ${{ steps.android-artifacts.outputs.symbols }}
native-symbols-policy: required
hands-token: ${{ secrets.HANDS_BEARER_TOKEN }}

11 changes: 7 additions & 4 deletions examples/github-actions/publish-existing-apk.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,14 @@ jobs:
steps:
- uses: actions/checkout@v4

# Replace this step with actions/download-artifact, a release download,
# or your build system's artifact fetch command.
- name: Fetch signed APK
# Replace this step with actions/download-artifact or your build system's
# artifact fetch command. Fetch the APK and native-symbols archive from
# the same immutable build; symbols cannot be reconstructed from an APK.
- name: Fetch signed APK and exact native symbols
run: |
mkdir -p dist
cp path/to/your/signed-app.apk dist/app-release.apk
cp path/to/the-same-build/native-symbols.zip dist/native-symbols.zip

- name: Publish draft to Hands
uses: botiverse/hands-examples/publish-android@v1
Expand All @@ -36,5 +38,6 @@ jobs:
channel: preview
version-name: ${{ inputs.version_name }}
version-code: ${{ inputs.version_code }}
symbols: dist/native-symbols.zip
native-symbols-policy: required
hands-token: ${{ secrets.HANDS_BEARER_TOKEN }}

20 changes: 15 additions & 5 deletions examples/gitlab-ci/android-publish.gitlab-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,22 +14,32 @@ build_apk:
stage: build
image: eclipse-temurin:17
script:
# Replace with your own build + signing. Only requirement: a signed APK.
- ./gradlew :app:assembleRelease
# copyHandsNativeSymbols is documented in the repository README. It must
# resolve the classifier from the same Maven channel as the SDK AAR.
- ./gradlew :app:assembleRelease :app:copyHandsNativeSymbols
- mkdir -p dist
- cp app/build/outputs/apk/release/app-release.apk dist/app-release.apk
- cp "$(find app/build/outputs/hands-native-symbols -maxdepth 1 -type f -name '*native-symbols*.zip' -print -quit)" dist/native-symbols.zip
artifacts:
paths:
- app/build/outputs/apk/release/app-release.apk
- dist/app-release.apk
- dist/native-symbols.zip
expire_in: 1 day

publish_hands:
stage: publish
image: node:22
script:
- npm install -g @botiverse/hands-cli@0.5.1
- apt-get update && apt-get install -y --no-install-recommends binutils curl python3 unzip && rm -rf /var/lib/apt/lists/*
- curl -fsSLo /tmp/validate-android-native-symbols.sh https://raw.githubusercontent.com/botiverse/hands-examples/v1/scripts/validate-android-native-symbols.sh
- chmod +x /tmp/validate-android-native-symbols.sh
- /tmp/validate-android-native-symbols.sh --apk dist/app-release.apk --symbols dist/native-symbols.zip --policy required
- npm install -g @botiverse/hands-cli@0.5.4
- git log --no-merges --pretty='- %s' -15 > changelog.txt
- >
hands builds publish-android "$HANDS_APP_SLUG"
--apk app/build/outputs/apk/release/app-release.apk
--apk dist/app-release.apk
--symbols dist/native-symbols.zip
--channel "${HANDS_CHANNEL:-preview}"
--version-name "$VERSION_NAME"
--version-code "$VERSION_CODE"
Expand Down
18 changes: 15 additions & 3 deletions publish-android/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,13 @@ inputs:
required: false
default: ""
symbols:
description: Optional native symbols archive.
description: Exact Hands native-symbols classifier archive for this APK.
required: false
default: ""
native-symbols-policy:
description: auto requires symbols when the APK contains libhandscrash.so; required always requires them; disabled opts out explicitly.
required: false
default: auto
metadata:
description: Optional JSON metadata file.
required: false
Expand All @@ -48,7 +52,7 @@ inputs:
cli-version:
description: Version of @botiverse/hands-cli to install.
required: false
default: 0.5.1
default: 0.5.4

runs:
using: composite
Expand All @@ -58,13 +62,22 @@ runs:
env:
INPUT_APK: ${{ inputs.apk }}
INPUT_DRAFT: ${{ inputs.draft }}
INPUT_MAPPING: ${{ inputs.mapping }}
INPUT_SYMBOLS: ${{ inputs.symbols }}
INPUT_METADATA: ${{ inputs.metadata }}
INPUT_NATIVE_SYMBOLS_POLICY: ${{ inputs.native-symbols-policy }}
run: |
set -euo pipefail
test -s "$INPUT_APK" || { echo "APK not found or empty: $INPUT_APK" >&2; exit 1; }
test -z "$INPUT_MAPPING" || test -s "$INPUT_MAPPING" || { echo "Mapping not found or empty: $INPUT_MAPPING" >&2; exit 1; }
test -z "$INPUT_METADATA" || test -s "$INPUT_METADATA" || { echo "Metadata not found or empty: $INPUT_METADATA" >&2; exit 1; }
case "$INPUT_DRAFT" in
true|false) ;;
*) echo "draft must be true or false" >&2; exit 1 ;;
esac
validate_args=(--apk "$INPUT_APK" --policy "$INPUT_NATIVE_SYMBOLS_POLICY")
test -z "$INPUT_SYMBOLS" || validate_args+=(--symbols "$INPUT_SYMBOLS")
"$GITHUB_ACTION_PATH/../scripts/validate-android-native-symbols.sh" "${validate_args[@]}"

- name: Install Hands CLI
shell: bash
Expand Down Expand Up @@ -102,4 +115,3 @@ runs:
test -z "$INPUT_METADATA" || args+=(--metadata "$INPUT_METADATA")
test "$INPUT_DRAFT" != true || args+=(--draft)
hands "${args[@]}"

Loading
Loading