Skip to content

Commit bce48b9

Browse files
authored
Add Android nightly build workflow (#652)
1 parent bae60c1 commit bce48b9

7 files changed

Lines changed: 541 additions & 2 deletions

File tree

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
name: Android Full Nightly
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
target_ref:
7+
description: Branch, tag, or SHA to build.
8+
required: true
9+
default: develop
10+
force:
11+
description: Build even when no functional files changed since the previous nightly.
12+
required: true
13+
type: boolean
14+
default: false
15+
publish:
16+
description: Publish or update the public nightly GitHub prerelease.
17+
required: true
18+
type: boolean
19+
default: true
20+
schedule:
21+
- cron: "37 2 * * *"
22+
23+
permissions:
24+
contents: write
25+
26+
concurrency:
27+
group: android-full-nightly
28+
cancel-in-progress: false
29+
30+
env:
31+
APK_NAME: sdai-full-nightly.apk
32+
NIGHTLY_TAG: nightly
33+
NIGHTLY_RELEASE_NAME: Android Full Nightly
34+
35+
jobs:
36+
nightly:
37+
name: Build Android full nightly
38+
runs-on: ubuntu-24.04
39+
timeout-minutes: 90
40+
env:
41+
TARGET_REF: ${{ github.event_name == 'workflow_dispatch' && inputs.target_ref || 'develop' }}
42+
FORCE_NIGHTLY: ${{ github.event_name == 'workflow_dispatch' && inputs.force || false }}
43+
PUBLISH_NIGHTLY: ${{ github.event_name != 'workflow_dispatch' || inputs.publish }}
44+
45+
steps:
46+
- name: Checkout target ref
47+
uses: actions/checkout@v4
48+
with:
49+
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.target_ref || 'develop' }}
50+
fetch-depth: 0
51+
52+
- name: Check whether a nightly is needed
53+
id: plan
54+
shell: bash
55+
run: |
56+
set -euo pipefail
57+
58+
git fetch --force origin "refs/tags/${NIGHTLY_TAG}:refs/tags/${NIGHTLY_TAG}" || true
59+
60+
target_sha="$(git rev-parse HEAD)"
61+
echo "target_sha=${target_sha}" >> "${GITHUB_OUTPUT}"
62+
63+
if ! git rev-parse --verify "${NIGHTLY_TAG}^{commit}" >/dev/null 2>&1; then
64+
echo "should_build=true" >> "${GITHUB_OUTPUT}"
65+
echo "reason=No previous nightly tag exists." >> "${GITHUB_OUTPUT}"
66+
{
67+
echo "### Android full nightly"
68+
echo
69+
echo "No previous nightly tag exists. Building ${target_sha}."
70+
} >> "${GITHUB_STEP_SUMMARY}"
71+
exit 0
72+
fi
73+
74+
if [[ "${FORCE_NIGHTLY}" == "true" ]]; then
75+
echo "should_build=true" >> "${GITHUB_OUTPUT}"
76+
echo "reason=Manual force build was requested." >> "${GITHUB_OUTPUT}"
77+
{
78+
echo "### Android full nightly"
79+
echo
80+
echo "Manual force build requested for ${target_sha}."
81+
} >> "${GITHUB_STEP_SUMMARY}"
82+
exit 0
83+
fi
84+
85+
git diff --name-only "${NIGHTLY_TAG}..HEAD" > "${RUNNER_TEMP}/nightly-changed-files.txt"
86+
87+
functional_pattern='^(app/|core/|data/|demo/|domain/|feature/|network/|presentation/|storage/|build-logic/|gradle/|build\.gradle\.kts$|settings\.gradle\.kts$|gradle\.properties$|gradlew$|gradlew\.bat$)'
88+
if grep -Eq "${functional_pattern}" "${RUNNER_TEMP}/nightly-changed-files.txt"; then
89+
echo "should_build=true" >> "${GITHUB_OUTPUT}"
90+
echo "reason=Functional files changed since the previous nightly." >> "${GITHUB_OUTPUT}"
91+
else
92+
echo "should_build=false" >> "${GITHUB_OUTPUT}"
93+
echo "reason=Only documentation, website, or repository metadata changed since the previous nightly." >> "${GITHUB_OUTPUT}"
94+
fi
95+
96+
{
97+
echo "### Android full nightly"
98+
echo
99+
echo "Target ref: \`${TARGET_REF}\`"
100+
echo "Target commit: \`${target_sha}\`"
101+
echo "Decision: \`$(grep '^should_build=' "${GITHUB_OUTPUT}" | tail -n 1 | cut -d= -f2)\`"
102+
echo
103+
echo "Changed files since \`${NIGHTLY_TAG}\`:"
104+
echo
105+
sed 's/^/- `/' "${RUNNER_TEMP}/nightly-changed-files.txt" | sed 's/$/`/' || true
106+
} >> "${GITHUB_STEP_SUMMARY}"
107+
108+
- name: Set up JDK 17
109+
if: steps.plan.outputs.should_build == 'true'
110+
uses: actions/setup-java@v4
111+
with:
112+
distribution: temurin
113+
java-version: "17"
114+
115+
- name: Set up Gradle
116+
if: steps.plan.outputs.should_build == 'true'
117+
uses: gradle/actions/setup-gradle@v4
118+
119+
- name: Build unsigned full release APK
120+
if: steps.plan.outputs.should_build == 'true'
121+
shell: bash
122+
run: |
123+
set -euo pipefail
124+
125+
./gradlew :app:assembleFullRelease \
126+
--no-daemon \
127+
-Dkotlin.native.ignoreDisabledTargets=true \
128+
-Pkotlin.native.ignoreDisabledTargets=true
129+
130+
- name: Sign APK with apksigner key and certificate
131+
if: steps.plan.outputs.should_build == 'true'
132+
id: sign
133+
shell: bash
134+
env:
135+
ANDROID_NIGHTLY_CERT_PEM_BASE64: ${{ secrets.ANDROID_NIGHTLY_CERT_PEM_BASE64 }}
136+
ANDROID_NIGHTLY_KEY_PASSWORD: ${{ secrets.ANDROID_NIGHTLY_KEY_PASSWORD }}
137+
ANDROID_NIGHTLY_KEY_PK8_BASE64: ${{ secrets.ANDROID_NIGHTLY_KEY_PK8_BASE64 }}
138+
run: |
139+
set -euo pipefail
140+
141+
: "${ANDROID_NIGHTLY_CERT_PEM_BASE64:?Missing ANDROID_NIGHTLY_CERT_PEM_BASE64 secret.}"
142+
: "${ANDROID_NIGHTLY_KEY_PK8_BASE64:?Missing ANDROID_NIGHTLY_KEY_PK8_BASE64 secret.}"
143+
144+
unsigned_apk="$(find app/android/build/outputs/apk/full/release -type f -name '*-unsigned.apk' -print -quit)"
145+
if [[ -z "${unsigned_apk}" ]]; then
146+
echo "Could not find unsigned full release APK." >&2
147+
find app/android/build/outputs/apk/full/release -type f -print >&2 || true
148+
exit 1
149+
fi
150+
151+
build_tools="$(find "${ANDROID_HOME}/build-tools" -mindepth 1 -maxdepth 1 -type d -print | sort -V | tail -n 1)"
152+
apksigner="${build_tools}/apksigner"
153+
zipalign="${build_tools}/zipalign"
154+
155+
signing_dir="${RUNNER_TEMP}/nightly-signing"
156+
output_dir="${RUNNER_TEMP}/nightly-output"
157+
install -m 700 -d "${signing_dir}" "${output_dir}"
158+
159+
key_file="${signing_dir}/nightly.pk8"
160+
cert_file="${signing_dir}/nightly.x509.pem"
161+
aligned_apk="${output_dir}/sdai-full-nightly-aligned.apk"
162+
signed_apk="${output_dir}/${APK_NAME}"
163+
164+
printf "%s" "${ANDROID_NIGHTLY_KEY_PK8_BASE64}" | base64 --decode > "${key_file}"
165+
printf "%s" "${ANDROID_NIGHTLY_CERT_PEM_BASE64}" | base64 --decode > "${cert_file}"
166+
chmod 600 "${key_file}" "${cert_file}"
167+
168+
"${zipalign}" -p -f 4 "${unsigned_apk}" "${aligned_apk}"
169+
170+
sign_args=(sign --key "${key_file}" --cert "${cert_file}" --out "${signed_apk}")
171+
if [[ -n "${ANDROID_NIGHTLY_KEY_PASSWORD:-}" ]]; then
172+
sign_args+=(--key-pass "pass:${ANDROID_NIGHTLY_KEY_PASSWORD}")
173+
fi
174+
"${apksigner}" "${sign_args[@]}" "${aligned_apk}"
175+
"${apksigner}" verify --print-certs "${signed_apk}" > "${output_dir}/apksigner.txt"
176+
177+
sha256sum "${signed_apk}" > "${output_dir}/${APK_NAME}.sha256"
178+
echo "signed_apk=${signed_apk}" >> "${GITHUB_OUTPUT}"
179+
echo "sha256_file=${output_dir}/${APK_NAME}.sha256" >> "${GITHUB_OUTPUT}"
180+
echo "apksigner_report=${output_dir}/apksigner.txt" >> "${GITHUB_OUTPUT}"
181+
182+
- name: Prepare nightly release notes
183+
if: steps.plan.outputs.should_build == 'true'
184+
id: metadata
185+
shell: bash
186+
run: |
187+
set -euo pipefail
188+
189+
output_dir="${RUNNER_TEMP}/nightly-output"
190+
release_notes="${output_dir}/release-notes.md"
191+
target_sha="${{ steps.plan.outputs.target_sha }}"
192+
version_name="$(awk -F '"' '/^versionName = / { print $2; exit }' gradle/libs.versions.toml)"
193+
version_code="$(awk -F '"' '/^versionCode = / { print $2; exit }' gradle/libs.versions.toml)"
194+
apk_sha256="$(cut -d ' ' -f 1 "${{ steps.sign.outputs.sha256_file }}")"
195+
cert_sha256="$(awk -F': ' '/Signer #1 certificate SHA-256 digest:/ { print $2; exit }' "${{ steps.sign.outputs.apksigner_report }}")"
196+
download_url="https://github.com/${GITHUB_REPOSITORY}/releases/download/${NIGHTLY_TAG}/${APK_NAME}"
197+
198+
cat > "${release_notes}" <<EOF
199+
Automated Android full nightly build.
200+
201+
- Target ref: \`${TARGET_REF}\`
202+
- Commit: \`${target_sha}\`
203+
- Version: \`${version_name} (${version_code})\`
204+
- APK SHA-256: \`${apk_sha256}\`
205+
- Signing certificate SHA-256: \`${cert_sha256}\`
206+
207+
This is a prerelease build from the moving \`${NIGHTLY_TAG}\` tag. It is not a Play Store, F-Droid, or stable project release.
208+
EOF
209+
210+
echo "release_notes=${release_notes}" >> "${GITHUB_OUTPUT}"
211+
{
212+
echo "### Nightly artifact"
213+
echo
214+
echo "Static APK URL: ${download_url}"
215+
echo "APK SHA-256: \`${apk_sha256}\`"
216+
echo "Signing certificate SHA-256: \`${cert_sha256}\`"
217+
} >> "${GITHUB_STEP_SUMMARY}"
218+
219+
- name: Publish GitHub prerelease
220+
if: steps.plan.outputs.should_build == 'true' && env.PUBLISH_NIGHTLY == 'true'
221+
shell: bash
222+
env:
223+
GH_TOKEN: ${{ github.token }}
224+
run: |
225+
set -euo pipefail
226+
227+
target_sha="${{ steps.plan.outputs.target_sha }}"
228+
git config user.name "github-actions[bot]"
229+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
230+
git tag -f "${NIGHTLY_TAG}" "${target_sha}"
231+
git push --force origin "refs/tags/${NIGHTLY_TAG}"
232+
233+
if gh release view "${NIGHTLY_TAG}" >/dev/null 2>&1; then
234+
while IFS= read -r asset_name; do
235+
case "${asset_name}" in
236+
"${APK_NAME}"|"${APK_NAME}.sha256") ;;
237+
*) gh release delete-asset "${NIGHTLY_TAG}" "${asset_name}" --yes ;;
238+
esac
239+
done < <(gh release view "${NIGHTLY_TAG}" --json assets --jq '.assets[].name')
240+
241+
gh release upload "${NIGHTLY_TAG}" \
242+
"${{ steps.sign.outputs.signed_apk }}" \
243+
"${{ steps.sign.outputs.sha256_file }}" \
244+
--clobber
245+
gh release edit "${NIGHTLY_TAG}" \
246+
--title "${NIGHTLY_RELEASE_NAME}" \
247+
--notes-file "${{ steps.metadata.outputs.release_notes }}" \
248+
--prerelease
249+
else
250+
gh release create "${NIGHTLY_TAG}" \
251+
"${{ steps.sign.outputs.signed_apk }}" \
252+
"${{ steps.sign.outputs.sha256_file }}" \
253+
--title "${NIGHTLY_RELEASE_NAME}" \
254+
--notes-file "${{ steps.metadata.outputs.release_notes }}" \
255+
--prerelease \
256+
--verify-tag
257+
fi

NIGHTLY_BUILDS.md

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
# Nightly Android Builds
2+
3+
SDAI publishes an Android `full` flavor nightly APK from `develop` through GitHub Actions.
4+
5+
The public APK URL is stable:
6+
7+
```text
8+
https://github.com/ShiftHackZ/Stable-Diffusion-Android/releases/download/nightly/sdai-full-nightly.apk
9+
```
10+
11+
The release page is:
12+
13+
```text
14+
https://github.com/ShiftHackZ/Stable-Diffusion-Android/releases/tag/nightly
15+
```
16+
17+
## What Gets Built
18+
19+
- Android only.
20+
- `full` flavor only.
21+
- Gradle produces an unsigned release APK; CI signs that APK with Android SDK `apksigner`.
22+
- The scheduled workflow runs daily and skips the build when only docs, website files, or repository metadata changed since the previous nightly.
23+
- Manual `force` builds can publish a new artifact even when the functional-file check would skip.
24+
25+
The workflow does not commit generated files or changing build metadata back to the repository.
26+
27+
## Publication Model
28+
29+
Nightlies are published to one GitHub prerelease named `Android Full Nightly`, backed by the moving tag `nightly`.
30+
31+
The workflow force-moves the `nightly` tag to the built commit, removes obsolete uploaded assets from the nightly release, and uploads the current assets with fixed filenames using overwrite mode. The Releases page should therefore show one nightly release, and that release should contain only the latest APK and checksum assets.
32+
33+
The workflow does not upload separate GitHub Actions artifacts, so APK downloads are kept in the single current nightly release.
34+
35+
## Signing Model
36+
37+
Nightly signing does not use JKS or Gradle `signing.properties`.
38+
39+
The workflow signs the unsigned APK with Android SDK `apksigner` using:
40+
41+
- `ANDROID_NIGHTLY_KEY_PK8_BASE64`: Base64-encoded PKCS#8 private key file.
42+
- `ANDROID_NIGHTLY_CERT_PEM_BASE64`: Base64-encoded X.509 certificate file.
43+
- `ANDROID_NIGHTLY_KEY_PASSWORD`: optional password for an encrypted private key.
44+
45+
These values should be stored as repository or environment secrets. Do not commit them to the repository.
46+
47+
If the nightly key is different from the normal `full` release signing certificate, Android will not install a nightly APK as an update over an existing `com.shifthackz.aisdv1.app.full` build. Testers must uninstall the old build first, or the nightly signing certificate must match the installed build.
48+
49+
## Manual Run
50+
51+
GitHub only exposes `workflow_dispatch` for workflow files that exist on the repository default branch. Keep `.github/workflows/nightly_android.yml` on `master`; the workflow still builds `develop` through the `target_ref` input.
52+
53+
Run from the GitHub UI:
54+
55+
1. Open `Actions`.
56+
2. Select `Android Full Nightly`.
57+
3. Click `Run workflow`.
58+
4. Keep `target_ref` as `develop`.
59+
5. Set `force` to `true` when you want a build even if no functional files changed.
60+
6. Keep `publish` as `true` to overwrite the public nightly prerelease assets.
61+
62+
Run from GitHub CLI:
63+
64+
```bash
65+
gh workflow run nightly_android.yml --ref master -f target_ref=develop -f force=true -f publish=true
66+
```
67+
68+
After the job finishes, send the stable APK URL above to testers.
69+
70+
## Daily Cron
71+
72+
The workflow runs once per day:
73+
74+
```yaml
75+
schedule:
76+
- cron: "37 2 * * *"
77+
```
78+
79+
Scheduled workflows run from the default branch, while the checkout step uses `develop` as the default build target.
80+
81+
## F-Droid Safety
82+
83+
Nightlies use the non-version, moving `nightly` tag and the GitHub release is marked as a prerelease. It is not a stable release tag, and scheduled builds point the moving tag at the selected build target, normally `develop`.
84+
85+
F-Droid release automation should continue to use its normal versioned tags from `master`; do not configure F-Droid metadata to match the `nightly` tag.

README.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
![Google Play](https://img.shields.io/endpoint?color=blue&logo=google-play&logoColor=white&url=https%3A%2F%2Fplay.cuzi.workers.dev%2Fplay%3Fi%3Dcom.shifthackz.aisdv1.app%26l%3DGoogle%2520Play%26m%3D%24version)
44
![F-Droid](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Ff-droid.org%2Fapi%2Fv1%2Fpackages%2Fcom.shifthackz.aisdv1.app.foss&query=%24.packages%5B0%5D.versionName&label=F-Droid&link=https%3A%2F%2Ff-droid.org%2Fpackages%2Fcom.shifthackz.aisdv1.app.foss%2F)
55

6-
[Website](https://sdai.moroz.cc) | [Telegram](https://t.me/sdai_app) | [Discord](https://discord.gg/jzdR9m8Ves)
6+
[Website](https://sdai.moroz.cc) | [Nightly Android Full](https://github.com/ShiftHackZ/Stable-Diffusion-Android/releases/download/nightly/sdai-full-nightly.apk) | [Telegram](https://t.me/sdai_app) | [Discord](https://discord.gg/jzdR9m8Ves)
77

88
<p>
99
<a href="https://play.google.com/store/apps/details?id=com.shifthackz.aisdv1.app"><img src="docs/assets/badge-google-play.svg" alt="Get it on Google Play" height="54"></a>
@@ -16,6 +16,16 @@ SDAI is an open-source, cross-platform AI image generation client for Android an
1616

1717
No ads. No telemetry. No lock-in to a single provider.
1818

19+
## Project Documentation
20+
21+
Root-level Markdown documents:
22+
23+
- [Documentation](DOCUMENTATION.md)
24+
- [Screenshot generation](SCREENSHOT_GENERATION.md)
25+
- [Nightly Android builds](NIGHTLY_BUILDS.md)
26+
- [Git workflow](GIT_WORKFLOW.md)
27+
- [Code of conduct](CODE_OF_CONDUCT.md)
28+
1929
## Why SDAI
2030

2131
- Choose the backend that fits the moment: your own AUTOMATIC1111 or SwarmUI server, AI Horde, Hugging Face, OpenAI, Stability AI, or local diffusion where the platform supports it.

0 commit comments

Comments
 (0)