t3-code-android-nightly/.github/workflows/android-nightly.yml
KaKi87 aac42f1dd5 fix(ci): unshallow main before nightly changelog
Publish was failing after a successful APK build because the shallow
checkout could not resolve previous_sha..upstream_sha for release notes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-06 12:15:50 +02:00

378 lines
16 KiB
YAML

name: Android Nightly
on:
schedule:
# Four checks per hour (off :00 to reduce GitHub schedule drops).
# Builds only when upstream apps/mobile moved.
- cron: "7,22,37,52 * * * *"
workflow_dispatch:
concurrency:
group: android-nightly
cancel-in-progress: false
permissions:
contents: write
jobs:
sync:
name: Sync main from upstream
runs-on: ubuntu-latest
outputs:
should_build: ${{ steps.sync.outputs.should_build }}
synced: ${{ steps.sync.outputs.synced }}
upstream_sha: ${{ steps.sync.outputs.upstream_sha }}
short_sha: ${{ steps.sync.outputs.short_sha }}
previous_sha: ${{ steps.sync.outputs.previous_sha }}
steps:
- name: Checkout
uses: actions/checkout@v5
with:
fetch-depth: 0
# Fine-grained PAT with Contents + Workflows + Actions write so we can
# push upstream history (including workflow files) onto main.
token: ${{ secrets.NIGHTLY_SYNC_TOKEN }}
- name: Sync upstream main into origin/main
id: sync
env:
GH_TOKEN: ${{ secrets.NIGHTLY_SYNC_TOKEN }}
run: |
set -euo pipefail
if [ -z "${GH_TOKEN:-}" ]; then
echo "NIGHTLY_SYNC_TOKEN secret is required" >&2
exit 1
fi
git remote add upstream https://github.com/pingdotgg/t3code.git 2>/dev/null || git remote set-url upstream https://github.com/pingdotgg/t3code.git
git fetch --no-tags upstream main
git fetch --no-tags origin main || true
upstream_sha="$(git rev-parse upstream/main)"
short_sha="$(git rev-parse --short=12 upstream/main)"
echo "upstream_sha=$upstream_sha" >> "$GITHUB_OUTPUT"
echo "short_sha=$short_sha" >> "$GITHUB_OUTPUT"
if git rev-parse --verify origin/main >/dev/null 2>&1; then
main_sha="$(git rev-parse origin/main)"
else
main_sha=""
fi
synced=false
if [ "$main_sha" = "$upstream_sha" ]; then
echo "origin/main already at upstream tip $short_sha"
else
# Push official upstream history as-is. Force is needed once to leave
# the old mirror-commit history; later syncs fast-forward when possible.
if [ -n "$main_sha" ] && git merge-base --is-ancestor "$main_sha" "$upstream_sha"; then
git push origin "$upstream_sha:refs/heads/main"
elif [ -n "$main_sha" ]; then
git push --force-with-lease=refs/heads/main:"$main_sha" origin "$upstream_sha:refs/heads/main"
else
git push --force origin "$upstream_sha:refs/heads/main"
fi
synced=true
echo "Updated origin/main to upstream $short_sha"
fi
echo "synced=$synced" >> "$GITHUB_OUTPUT"
previous_sha=""
last_tag="$(
gh api "repos/${{ github.repository }}/releases?per_page=100" \
--jq '[.[] | select(.prerelease == true and .draft == false)] | sort_by(.published_at) | reverse | .[0].tag_name // empty'
)"
if [[ "$last_tag" =~ ^nightly-[0-9]{8}-[0-9]{6}-([0-9a-f]+)$ ]]; then
last_short="${BASH_REMATCH[1]}"
elif [[ "$last_tag" =~ ^nightly-([0-9a-f]+)$ ]]; then
last_short="${BASH_REMATCH[1]}"
else
last_short=""
fi
if [ -n "$last_short" ]; then
if previous_sha="$(git rev-parse --verify "${last_short}^{commit}" 2>/dev/null)"; then
echo "Previous nightly: $last_tag ($previous_sha)"
else
echo "Could not resolve previous nightly commit '$last_short'"
previous_sha=""
fi
fi
echo "previous_sha=$previous_sha" >> "$GITHUB_OUTPUT"
should_build=false
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "Manual dispatch; forcing a build"
should_build=true
elif [ -z "$last_tag" ]; then
echo "No prerelease exists yet; forcing a build"
should_build=true
elif [[ "$last_tag" != nightly-* ]]; then
echo "Latest prerelease tag '$last_tag' is not a nightly-*; forcing a build"
should_build=true
elif [ -z "$previous_sha" ]; then
echo "Could not resolve last nightly commit; forcing a build"
should_build=true
elif [ "$previous_sha" = "$upstream_sha" ]; then
echo "Already built upstream tip $short_sha; skipping"
else
mobile_changes="$(git diff --name-only "$previous_sha" "$upstream_sha" -- apps/mobile || true)"
if [ -n "$mobile_changes" ]; then
echo "Upstream apps/mobile changed since $last_tag; building"
head -50 <<< "$mobile_changes"
should_build=true
else
echo "No apps/mobile changes since $last_tag; skipping build"
fi
fi
echo "should_build=$should_build" >> "$GITHUB_OUTPUT"
- name: Cancel inherited upstream workflow runs on main
if: steps.sync.outputs.synced == 'true'
env:
GH_TOKEN: ${{ secrets.NIGHTLY_SYNC_TOKEN }}
run: |
set -euo pipefail
# Pushing upstream commits onto main can enqueue Blacksmith/EAS workflows
# that do not exist on this fork's default branch. Cancel them and disable
# any non-nightly workflows that become visible.
sleep 20
gh run list --repo "${{ github.repository }}" --branch main --limit 30 \
--json databaseId,name,status,workflowName \
--jq '.[] | select(.workflowName != "Android Nightly") | select(.status == "queued" or .status == "in_progress" or .status == "pending") | .databaseId' \
| while read -r id; do
echo "Canceling run $id"
gh run cancel "$id" --repo "${{ github.repository }}" || true
done
gh api "repos/${{ github.repository }}/actions/workflows" --paginate \
--jq '.workflows[] | select(.path != ".github/workflows/android-nightly.yml") | select(.state == "active") | [.id, .path] | @tsv' \
| while IFS=$'\t' read -r id wpath; do
echo "Disabling workflow $wpath ($id)"
gh api -X PUT "repos/${{ github.repository }}/actions/workflows/$id/disable" || true
done
build:
name: Build and publish APK
needs: sync
if: needs.sync.outputs.should_build == 'true'
runs-on: ubuntu-latest
timeout-minutes: 180
env:
APP_VARIANT: preview
T3CODE_MOBILE_UPDATES_ENABLED: "0"
NODE_OPTIONS: --max-old-space-size=8192
EXPO_NO_GIT_STATUS: "1"
GRADLE_OPTS: -Dorg.gradle.daemon=false -Dorg.gradle.jvmargs=-Xmx6g
steps:
- name: Checkout main
uses: actions/checkout@v5
with:
ref: main
fetch-depth: 1
- name: Checkout nightly CI helpers
uses: actions/checkout@v5
with:
ref: dev
sparse-checkout: |
.github/scripts
sparse-checkout-cone-mode: false
path: .nightly-ci
- name: Setup Java
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: "17"
- name: Setup Android SDK
uses: android-actions/setup-android@v3
with:
# Do not install obsolete `tools` — sdkmanager no longer provides it.
packages: platform-tools platforms;android-36 build-tools;36.0.0 ndk;27.1.12297006 cmake;3.22.1
- name: Setup Vite+
uses: voidzero-dev/setup-vp@v1
with:
node-version-file: package.json
cache: true
run-install: |
args:
- --filter=@t3tools/mobile...
- name: Expose pnpm
run: |
pnpm_version="$(node --print "require('./package.json').packageManager.split('@').pop()")"
vp_pnpm_bin="$HOME/.vite-plus/package_manager/pnpm/$pnpm_version/pnpm/bin"
echo "$vp_pnpm_bin" >> "$GITHUB_PATH"
"$vp_pnpm_bin/pnpm" --version
- name: Retarget nightly Android identity
run: |
python3 <<'PY'
from pathlib import Path
import re
path = Path("apps/mobile/app.config.ts")
text = path.read_text()
replacements = [
('appName: "T3 Code Preview"', 'appName: "T3 Code Nightly"'),
('androidPackage: "com.t3tools.t3code.preview"', 'androidPackage: "com.vibedbykaki.t3code.nightly"'),
("scheme: \"t3code-preview\"", "scheme: \"t3code-nightly\""),
]
for old, new in replacements:
if old not in text:
raise SystemExit(f"missing expected snippet: {old}")
text = text.replace(old, new, 1)
path.write_text(text)
print("Retargeted preview variant for nightly APK builds")
PY
- name: Install stable APK signing keystore
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
run: |
set -euo pipefail
if [ -z "${ANDROID_KEYSTORE_BASE64:-}" ]; then
echo "ANDROID_KEYSTORE_BASE64 secret is required" >&2
exit 1
fi
mkdir -p "$HOME/.android" "$RUNNER_TEMP/nightly-signing"
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 --decode > "$HOME/.android/debug.keystore"
cp "$HOME/.android/debug.keystore" "$RUNNER_TEMP/nightly-signing/nightly.keystore"
# Standard Android debug alias/passwords; keystore itself is our stable nightly key.
keytool -list -keystore "$HOME/.android/debug.keystore" -storepass android >/dev/null
echo "NIGHTLY_KEYSTORE=$RUNNER_TEMP/nightly-signing/nightly.keystore" >> "$GITHUB_ENV"
- name: Expo prebuild (Android)
working-directory: apps/mobile
run: pnpm exec expo prebuild --platform android --clean --non-interactive
# fbjni 0.8.0+ (Maven Central, 2026-09-25) references
# __cxa_init_primary_exception, which the libc++_shared.so shipped with
# React Native does not export. Gradle was resolving past RN's declared
# 0.7.0 and the APK crashed in JNI_OnLoad on load. Pin the RN-matched
# artifact so nightlies stay ABI-compatible.
- name: Pin fbjni 0.7.0
run: |
python3 <<'PY'
from pathlib import Path
root = Path("apps/mobile/android/build.gradle")
text = root.read_text()
marker = "force \"com.facebook.fbjni:fbjni:0.7.0\""
if marker not in text:
block = """
// Nightly CI: keep fbjni on the RN-declared ABI (see workflow).
subprojects { subproject ->
subproject.configurations.configureEach {
resolutionStrategy.force "com.facebook.fbjni:fbjni:0.7.0"
}
}
"""
text = text.rstrip() + "\n" + block + "\n"
root.write_text(text)
print("Pinned com.facebook.fbjni:fbjni:0.7.0")
PY
- name: Configure release signing
run: python3 .nightly-ci/.github/scripts/configure-nightly-signing.py
- name: Build release APK
working-directory: apps/mobile/android
run: |
chmod +x gradlew
# Release embeds the JS bundle. Debug only opens the Expo development-client launcher.
./gradlew :app:assembleRelease --no-daemon --stacktrace
- name: Stage APK
id: apk
run: |
set -euo pipefail
src="apps/mobile/android/app/build/outputs/apk/release/app-release.apk"
test -f "$src"
short_sha="${{ needs.sync.outputs.short_sha }}"
mkdir -p dist
out="dist/t3-code-nightly-${short_sha}.apk"
cp "$src" "$out"
echo "path=$out" >> "$GITHUB_OUTPUT"
echo "name=$(basename "$out")" >> "$GITHUB_OUTPUT"
ls -lh "$out"
- name: Publish prerelease
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
short_sha="${{ needs.sync.outputs.short_sha }}"
upstream_sha="${{ needs.sync.outputs.upstream_sha }}"
previous_sha="${{ needs.sync.outputs.previous_sha }}"
# Time-prefixed tags sort correctly when clients order by tag name, and
# --target must be the built main commit. Defaulting to branch "dev"
# made every tag point at the same orphan-branch tip, so created_at
# tied and the Releases API fell back to hex tag-name order.
built_sha="$(git rev-parse HEAD)"
stamp="$(date -u +%Y%m%d-%H%M%S)"
tag="nightly-${stamp}-${short_sha}"
title="Nightly ${short_sha}"
git remote add upstream https://github.com/pingdotgg/t3code.git 2>/dev/null \
|| git remote set-url upstream https://github.com/pingdotgg/t3code.git
# Build checks out main shallow (depth 1). Unshallow so the changelog
# range previous_sha..upstream_sha resolves; a tip-only upstream fetch
# is not enough and previously failed publish with exit 128.
if [ "$(git rev-parse --is-shallow-repository)" = "true" ]; then
git fetch --no-tags --unshallow origin || git fetch --no-tags --deepen=2147483647 origin
fi
git fetch --no-tags upstream main
if [ -n "$previous_sha" ] && ! git cat-file -e "${previous_sha}^{commit}" 2>/dev/null; then
git fetch --no-tags upstream "$previous_sha"
fi
commits_file="$(mktemp)"
if [ -n "$previous_sha" ] && [ "$previous_sha" != "$upstream_sha" ]; then
if git cat-file -e "${previous_sha}^{commit}" 2>/dev/null \
&& git cat-file -e "${upstream_sha}^{commit}" 2>/dev/null; then
git log --reverse --pretty=format:'- [%h](https://github.com/pingdotgg/t3code/commit/%H) %s%n' \
"$previous_sha..$upstream_sha" -- apps/mobile > "$commits_file" || true
else
echo "Skipping changelog range; missing commit objects" >&2
fi
fi
if [ ! -s "$commits_file" ]; then
if [ -z "$previous_sha" ]; then
printf '%s\n' "- Initial nightly build." > "$commits_file"
else
printf '%s\n' "- No new commits touching \`apps/mobile\` since the previous nightly." > "$commits_file"
fi
fi
notes_file="$(mktemp)"
{
printf '%s\n' \
"Unofficial Android nightly of T3 Code." \
"" \
"- Upstream commit: [\`${short_sha}\`](https://github.com/pingdotgg/t3code/commit/${upstream_sha})" \
"- Package: \`com.vibedbykaki.t3code.nightly\`" \
"- Standalone release APK (not an Expo development client)" \
"- Built from \`main\` by [Android Nightly](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})" \
"" \
"## Commits affecting the Android app" \
""
if [ -n "$previous_sha" ]; then
prev_short="$(git rev-parse --short=12 "$previous_sha" 2>/dev/null || printf '%.12s' "$previous_sha")"
printf '%s\n\n' "Since [\`${prev_short}\`](https://github.com/pingdotgg/t3code/commit/${previous_sha}) (commits touching \`apps/mobile\`):"
fi
cat "$commits_file"
} > "$notes_file"
if gh release view "$tag" --repo "${{ github.repository }}" >/dev/null 2>&1; then
gh release upload "$tag" "${{ steps.apk.outputs.path }}" --repo "${{ github.repository }}" --clobber
gh release edit "$tag" --repo "${{ github.repository }}" --prerelease --notes-file "$notes_file" --title "$title"
else
gh release create "$tag" "${{ steps.apk.outputs.path }}" \
--repo "${{ github.repository }}" \
--target "$built_sha" \
--prerelease \
--title "$title" \
--notes-file "$notes_file"
fi