mirror of
https://github.com/VibedByKaKi/t3-code-android-nightly.git
synced 2026-10-10 12:21:16 +02:00
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>
378 lines
16 KiB
YAML
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
|