Skip to content

Commit fd24eb6

Browse files
authored
Merge branch 'main' into llm-android-ubuntu-runner
2 parents 590d50e + bdab5b6 commit fd24eb6

5 files changed

Lines changed: 178 additions & 92 deletions

File tree

.github/workflows/ANDROID_BUILD.md

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# Workflows
2+
3+
## Android Build (`android-build.yml`)
4+
5+
This workflow builds Android demo applications and creates nightly releases.
6+
7+
### Trigger Events
8+
9+
1. **Scheduled (Nightly)**: Runs automatically every day at midnight UTC via cron schedule
10+
2. **Manual Dispatch**: Can be triggered manually via GitHub Actions UI with optional parameters
11+
12+
### Jobs
13+
14+
#### Build Job
15+
16+
Builds APKs for the following apps:
17+
- **LlamaDemo**: Located at `llm/android/LlamaDemo`
18+
- **DeepLabV3Demo**: Located at `dl3/android/DeepLabV3Demo`
19+
20+
The build job:
21+
- Sets up JDK 17
22+
- Uses Gradle to build the APKs
23+
- Supports using a custom local AAR file (via `local_aar` input parameter)
24+
- Uploads APKs as workflow artifacts
25+
26+
#### Create Release Job
27+
28+
Runs only for scheduled (nightly) builds. This job:
29+
- Downloads all APK artifacts from the build job
30+
- Generates a date-based release tag (format: `nightly-YYYYMMDD`)
31+
- Creates a GitHub release with:
32+
- Tag: `nightly-YYYYMMDD`
33+
- Name: `Nightly Build YYYY-MM-DD`
34+
- All built APK files attached
35+
- Marked as pre-release
36+
- Build metadata (date and commit SHA)
37+
- Automatically cleans up old nightly releases, keeping only the last 7 nightly builds to prevent the releases page from becoming cluttered
38+
39+
### How to Access Nightly Releases
40+
41+
Nightly builds are automatically published as GitHub Releases:
42+
43+
1. Go to the [Releases page](https://github.com/meta-pytorch/executorch-examples/releases)
44+
2. Look for releases tagged with `nightly-YYYYMMDD`
45+
3. Download the APK files from the release assets
46+
47+
**Note:** Only the last 7 nightly releases are kept. Older releases are automatically deleted to keep the releases page manageable.
48+
49+
### Manual Workflow Dispatch
50+
51+
To manually trigger a build:
52+
53+
1. Go to Actions → Android Build
54+
2. Click "Run workflow"
55+
3. Optionally provide a `local_aar` URL to use a custom ExecuTorch AAR file
56+
4. Note: Manual runs do NOT create releases, only scheduled runs do
57+
58+
### Permissions
59+
60+
The workflow requires `contents: write` permission to create releases and push tags to the repository.

.github/workflows/android-build.yml

Lines changed: 75 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,6 @@
77
name: Android Build
88

99
on:
10-
pull_request:
11-
branches: [main]
1210
schedule:
1311
# Run nightly at midnight UTC
1412
- cron: '0 0 * * *'
@@ -20,7 +18,7 @@ on:
2018
type: string
2119

2220
permissions:
23-
contents: read
21+
contents: write
2422

2523
jobs:
2624
build:
@@ -70,70 +68,85 @@ jobs:
7068
path: ${{ matrix.path }}/app/build/outputs/apk/
7169
if-no-files-found: warn
7270

73-
instrumentation-test:
71+
create-release:
72+
name: Create Nightly Release
73+
needs: build
7474
runs-on: ubuntu-latest
75-
strategy:
76-
fail-fast: false
77-
matrix:
78-
include:
79-
- name: LlamaDemo
80-
path: llm/android/LlamaDemo
81-
env:
82-
API_LEVEL: 34
83-
ARCH: x86_64
84-
EMULATOR_OPTIONS: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
85-
86-
name: Instrumentation Test ${{ matrix.name }}
75+
if: github.event_name == 'schedule'
8776
steps:
88-
- name: Checkout repository
89-
uses: actions/checkout@v4
90-
91-
- name: Enable KVM group perms
92-
run: |
93-
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
94-
sudo udevadm control --reload-rules
95-
sudo udevadm trigger --name-match=kvm
96-
97-
- name: Set up JDK 17
98-
uses: actions/setup-java@v4
77+
- name: Download all artifacts
78+
uses: actions/download-artifact@v4.1.3
9979
with:
100-
java-version: '17'
101-
distribution: 'temurin'
80+
path: artifacts
10281

103-
- name: Setup Gradle
104-
uses: gradle/actions/setup-gradle@v4
82+
- name: Generate release tag
83+
id: tag
84+
run: |
85+
echo "tag_name=nightly-$(date +'%Y%m%d')" >> $GITHUB_OUTPUT
86+
echo "release_name=Nightly Build $(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT
87+
echo "build_date=$(date +'%Y-%m-%d %H:%M:%S UTC')" >> $GITHUB_OUTPUT
10588
106-
- name: AVD cache
107-
uses: actions/cache@v4
108-
id: avd-cache
109-
with:
110-
path: |
111-
~/.android/avd/*
112-
~/.android/adb*
113-
key: avd-${{ env.API_LEVEL }}-${{ env.ARCH }}
114-
115-
- name: Create AVD and generate snapshot for caching
116-
if: steps.avd-cache.outputs.cache-hit != 'true'
117-
uses: reactivecircus/android-emulator-runner@v2
89+
- name: Create Release
90+
uses: softprops/action-gh-release@v1
11891
with:
119-
api-level: ${{ env.API_LEVEL }}
120-
arch: ${{ env.ARCH }}
121-
force-avd-creation: false
122-
ram-size: 16384M
123-
emulator-options: ${{ env.EMULATOR_OPTIONS }}
124-
disable-animations: false
125-
working-directory: ${{ matrix.path }}
126-
script: echo "Generated AVD snapshot for caching."
127-
128-
- name: Run instrumentation tests
129-
uses: reactivecircus/android-emulator-runner@v2
92+
tag_name: ${{ steps.tag.outputs.tag_name }}
93+
name: ${{ steps.tag.outputs.release_name }}
94+
body: |
95+
Automated nightly build of Android demo applications.
96+
97+
## Apps included:
98+
- LlamaDemo APK
99+
- DeepLabV3Demo APK
100+
101+
**Build Date:** ${{ steps.tag.outputs.build_date }}
102+
**Commit:** ${{ github.sha }}
103+
files: |
104+
artifacts/*-apk/**/*.apk
105+
prerelease: true
106+
draft: false
107+
108+
- name: Clean up old nightly releases
109+
uses: actions/github-script@v7
130110
with:
131-
api-level: ${{ env.API_LEVEL }}
132-
arch: ${{ env.ARCH }}
133-
force-avd-creation: false
134-
ram-size: 6144M
135-
emulator-options: -no-snapshot-save ${{ env.EMULATOR_OPTIONS }}
136-
disable-animations: true
137-
working-directory: ${{ matrix.path }}
138111
script: |
139-
./gradlew connectedCheck
112+
const keep = 7; // Keep last 7 nightly releases
113+
const owner = context.repo.owner;
114+
const repo = context.repo.repo;
115+
116+
// Get all releases
117+
const releases = await github.rest.repos.listReleases({
118+
owner,
119+
repo,
120+
per_page: 100
121+
});
122+
123+
// Filter nightly releases and sort by date (newest first)
124+
const nightlyReleases = releases.data
125+
.filter(release => release.tag_name.startsWith('nightly-'))
126+
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
127+
128+
// Delete releases older than the keep threshold
129+
for (let i = keep; i < nightlyReleases.length; i++) {
130+
const release = nightlyReleases[i];
131+
console.log(`Deleting old nightly release: ${release.tag_name}`);
132+
133+
try {
134+
// Delete the release
135+
await github.rest.repos.deleteRelease({
136+
owner,
137+
repo,
138+
release_id: release.id
139+
});
140+
141+
// Delete the tag
142+
await github.rest.git.deleteRef({
143+
owner,
144+
repo,
145+
ref: `tags/${release.tag_name}`
146+
});
147+
148+
console.log(`Deleted release and tag: ${release.tag_name}`);
149+
} catch (error) {
150+
console.log(`Error deleting ${release.tag_name}: ${error.message}`);
151+
}
152+
}

llm/android/LlamaDemo/app/build.gradle.kts

Lines changed: 36 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,11 @@ plugins {
1414
// Model files configuration for instrumentation tests
1515
val modelFilesBaseUrl = "https://ossci-android.s3.amazonaws.com/executorch/stories/snapshot-20260114"
1616
val deviceModelDir = "/data/local/tmp/llama"
17-
val modelFiles = listOf(
18-
"stories110M.pte",
19-
"tokenizer.model"
17+
val modelFiles = mapOf(
18+
"stories110M.pte" to "model.pte",
19+
"tokenizer.model" to "tokenizer.model"
2020
)
21+
val skipModelDownload: Boolean = (project.findProperty("skipModelDownload") as? String)?.toBoolean() ?: false
2122

2223
fun execCmd(vararg args: String): String {
2324
val process = ProcessBuilder(*args)
@@ -42,6 +43,11 @@ tasks.register("pushModelFiles") {
4243
group = "verification"
4344

4445
doLast {
46+
if (skipModelDownload) {
47+
logger.lifecycle("Skipping model download (skipModelDownload=true)")
48+
return@doLast
49+
}
50+
4551
// Check if adb is available
4652
val adbPath = android.adbExecutable.absolutePath
4753
val (adbCheckCode, _) = execCmdWithExitCode(adbPath, "devices")
@@ -50,8 +56,8 @@ tasks.register("pushModelFiles") {
5056
}
5157

5258
// Check which files need to be pushed
53-
val filesToPush = modelFiles.filter { fileName ->
54-
val devicePath = "$deviceModelDir/$fileName"
59+
val filesToPush = modelFiles.filter { (_, targetName) ->
60+
val devicePath = "$deviceModelDir/$targetName"
5561
val (exitCode, _) = execCmdWithExitCode(adbPath, "shell", "test -f $devicePath && echo exists")
5662
exitCode != 0
5763
}
@@ -61,7 +67,7 @@ tasks.register("pushModelFiles") {
6167
return@doLast
6268
}
6369

64-
logger.lifecycle("Need to push ${filesToPush.size} model file(s): ${filesToPush.joinToString(", ")}")
70+
logger.lifecycle("Need to push ${filesToPush.size} model file(s): ${filesToPush.values.joinToString(", ")}")
6571

6672
// Create temp directory using mktemp
6773
val tempDir = execCmd("mktemp", "-d")
@@ -71,45 +77,52 @@ tasks.register("pushModelFiles") {
7177
// Create device directory
7278
execCmd(adbPath, "shell", "mkdir -p $deviceModelDir")
7379

74-
for (fileName in filesToPush) {
75-
val localPath = "$tempDir/$fileName"
76-
val checksumPath = "$tempDir/$fileName.sha256sums"
77-
val devicePath = "$deviceModelDir/$fileName"
80+
for ((sourceName, targetName) in filesToPush) {
81+
val localPath = "$tempDir/$targetName"
82+
val checksumPath = "$tempDir/$sourceName.sha256sums"
83+
val devicePath = "$deviceModelDir/$targetName"
7884

79-
// Download file
80-
logger.lifecycle("Downloading $fileName...")
85+
// Download file (with original name for checksum verification, then rename)
86+
val downloadPath = "$tempDir/$sourceName"
87+
logger.lifecycle("Downloading $sourceName...")
8188
val (dlCode, dlOutput) = execCmdWithExitCode(
82-
"curl", "-fL", "-o", localPath, "$modelFilesBaseUrl/$fileName"
89+
"curl", "-fL", "-o", downloadPath, "$modelFilesBaseUrl/$sourceName"
8390
)
8491
if (dlCode != 0) {
85-
throw GradleException("Failed to download $fileName: $dlOutput")
92+
throw GradleException("Failed to download $sourceName: $dlOutput")
8693
}
8794

8895
// Download and verify checksum
89-
logger.lifecycle("Verifying checksum for $fileName...")
96+
logger.lifecycle("Verifying checksum for $sourceName...")
9097
val (csDownloadCode, csDownloadOutput) = execCmdWithExitCode(
91-
"curl", "-fL", "-o", checksumPath, "$modelFilesBaseUrl/$fileName.sha256sums"
98+
"curl", "-fL", "-o", checksumPath, "$modelFilesBaseUrl/$sourceName.sha256sums"
9299
)
93100
if (csDownloadCode != 0) {
94-
throw GradleException("Failed to download checksum for $fileName: $csDownloadOutput")
101+
throw GradleException("Failed to download checksum for $sourceName: $csDownloadOutput")
95102
}
96103

97104
// Verify checksum (run sha256sum in the temp directory)
98105
val (verifyCode, verifyOutput) = execCmdWithExitCode(
99-
"bash", "-c", "cd $tempDir && sha256sum -c $fileName.sha256sums"
106+
"bash", "-c", "cd $tempDir && sha256sum -c $sourceName.sha256sums"
100107
)
101108
if (verifyCode != 0) {
102-
throw GradleException("Checksum verification failed for $fileName: $verifyOutput")
109+
throw GradleException("Checksum verification failed for $sourceName: $verifyOutput")
110+
}
111+
logger.lifecycle("Checksum verified for $sourceName")
112+
113+
// Rename file if needed
114+
if (sourceName != targetName) {
115+
execCmd("mv", downloadPath, localPath)
116+
logger.lifecycle("Renamed $sourceName to $targetName")
103117
}
104-
logger.lifecycle("Checksum verified for $fileName")
105118

106119
// Push to device
107-
logger.lifecycle("Pushing $fileName to device...")
120+
logger.lifecycle("Pushing $targetName to device...")
108121
val (pushCode, pushOutput) = execCmdWithExitCode(adbPath, "push", localPath, devicePath)
109122
if (pushCode != 0) {
110-
throw GradleException("Failed to push $fileName to device: $pushOutput")
123+
throw GradleException("Failed to push $targetName to device: $pushOutput")
111124
}
112-
logger.lifecycle("Successfully pushed $fileName")
125+
logger.lifecycle("Successfully pushed $targetName")
113126
}
114127
} finally {
115128
// Clean up temp directory

llm/android/LlamaDemo/app/src/androidTest/java/com/example/executorchllamademo/SanityCheck.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ public class SanityCheck implements LlmCallback {
2525

2626
private static final String RESOURCE_PATH = "/data/local/tmp/llama/";
2727
private static final String TOKENIZER_PATH = "tokenizer.model";
28-
private static final String MODEL_PATH = "stories110M.pte";
28+
private static final String MODEL_PATH = "model.pte";
2929

3030
private final List<String> results = new ArrayList<>();
3131

llm/android/LlamaDemo/app/src/androidTest/java/com/example/executorchllamademo/UIWorkflowTest.java

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ public void clearSharedPreferences() {
7070
* 1. Dismiss the "Please Select a Model" dialog
7171
* 2. Click settings button
7272
* 3. Verify model path and tokenizer path show default "no selection" text
73-
* 4. Click model selection, select stories110M.pte
73+
* 4. Click model selection, select model.pte
7474
* 5. Click tokenizer selection, select tokenizer.model
7575
* 6. Click load model button
7676
*/
@@ -92,10 +92,10 @@ public void testModelLoadingWorkflow() throws Exception {
9292
onView(withId(R.id.modelTextView)).check(matches(withText("no model selected")));
9393
onView(withId(R.id.tokenizerTextView)).check(matches(withText("no tokenizer selected")));
9494

95-
// Step 3: Click model selection button and select stories110M.pte
95+
// Step 3: Click model selection button and select model.pte
9696
onView(withId(R.id.modelImageButton)).perform(click());
97-
// Select the model file containing "stories110M.pte"
98-
onData(hasToString(containsString("stories110M.pte"))).inRoot(isDialog()).perform(click());
97+
// Select the model file containing "model.pte"
98+
onData(hasToString(containsString("model.pte"))).inRoot(isDialog()).perform(click());
9999

100100
// Step 4: Click tokenizer selection button and select tokenizer.model
101101
onView(withId(R.id.tokenizerImageButton)).perform(click());
@@ -139,10 +139,10 @@ public void testSendMessageAndReceiveResponse() throws Exception {
139139
// Verify load button is initially disabled (no model/tokenizer selected)
140140
onView(withId(R.id.loadModelButton)).check(matches(not(isEnabled())));
141141

142-
// Select model - choose stories110M.pte
142+
// Select model - choose model.pte
143143
onView(withId(R.id.modelImageButton)).perform(click());
144144
Thread.sleep(300); // Wait for dialog to appear
145-
onData(hasToString(containsString("stories110M.pte"))).inRoot(isDialog()).perform(click());
145+
onData(hasToString(containsString("model.pte"))).inRoot(isDialog()).perform(click());
146146
Thread.sleep(300); // Wait for dialog to dismiss and UI to update
147147

148148
// Select tokenizer - choose tokenizer.model

0 commit comments

Comments
 (0)