Skip to content

Commit 260eaa7

Browse files
sharpninjaCopilot
andcommitted
Add CI/CD pipeline for Android APK builds and F-Droid repo
- GitHub Actions workflow: build APK with GitVersion SemVer, create GitHub Release, compute SHA-256, generate F-Droid metadata, deploy to GitHub Pages - F-Droid metadata generator script (Python) producing index-v1.json and entry.json with correct APK hash and release download URL - GitHub Pages landing page with F-Droid repo add instructions - Android csproj updated with conditional signing properties and GitVersion.MsBuild for CI builds - Placeholder F-Droid repo files for initial Pages deployment Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent d0b0a6b commit 260eaa7

7 files changed

Lines changed: 426 additions & 0 deletions

File tree

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
#!/usr/bin/env python3
2+
"""Generate F-Droid-compatible repository metadata for RequestTracker Android.
3+
4+
Usage:
5+
python generate_fdroid_repo.py \
6+
--version <semver> \
7+
--version-code <int> \
8+
--apk-url <release-asset-url> \
9+
--apk-hash <sha256-hex> \
10+
--apk-size <bytes> \
11+
--repo-url <github-pages-fdroid-url> \
12+
--output-dir <path-to-docs/fdroid/repo>
13+
14+
Generates:
15+
- index-v1.json (F-Droid repo index)
16+
- entry.json (F-Droid entry point)
17+
"""
18+
19+
import argparse
20+
import json
21+
import os
22+
import time
23+
24+
25+
def generate_index_v1(args):
26+
"""Generate the F-Droid index-v1.json with app metadata and package info."""
27+
timestamp = int(time.time()) * 1000 # F-Droid uses millis
28+
29+
index = {
30+
"repo": {
31+
"name": "RequestTracker F-Droid Repo",
32+
"description": "F-Droid repository for the RequestTracker Android application.",
33+
"icon": "",
34+
"address": args.repo_url,
35+
"timestamp": timestamp,
36+
"version": 21,
37+
},
38+
"requests": {},
39+
"apps": [
40+
{
41+
"packageName": "com.requesttracker.android",
42+
"name": "RequestTracker",
43+
"summary": "View and analyze Copilot session logs on Android",
44+
"description": (
45+
"RequestTracker is an Avalonia-based Android app for browsing, "
46+
"searching, and analyzing Copilot request/session logs. Supports "
47+
"phone (portrait NavigationView) and tablet (desktop-like) layouts."
48+
),
49+
"license": "MIT",
50+
"webSite": "https://github.com/sharpninja/RequestTracker",
51+
"sourceCode": "https://github.com/sharpninja/RequestTracker",
52+
"issueTracker": "https://github.com/sharpninja/RequestTracker/issues",
53+
"categories": ["Development", "System"],
54+
"suggestedVersionName": args.version,
55+
"suggestedVersionCode": args.version_code,
56+
}
57+
],
58+
"packages": {
59+
"com.requesttracker.android": [
60+
{
61+
"versionName": args.version,
62+
"versionCode": args.version_code,
63+
"apkName": f"RequestTracker-{args.version}.apk",
64+
"hash": args.apk_hash,
65+
"hashType": "sha256",
66+
"size": args.apk_size,
67+
"minSdkVersion": 21,
68+
"targetSdkVersion": 35,
69+
"nativecode": ["arm64-v8a", "armeabi-v7a", "x86_64"],
70+
"srcname": f"RequestTracker-{args.version}-src.tar.gz",
71+
"sig": "",
72+
"packageName": "com.requesttracker.android",
73+
"apkUrl": args.apk_url,
74+
}
75+
]
76+
},
77+
}
78+
79+
return index
80+
81+
82+
def generate_entry(args):
83+
"""Generate the F-Droid entry.json pointing to the index."""
84+
return {
85+
"timestamp": int(time.time()) * 1000,
86+
"version": args.version,
87+
"index": {
88+
"name": "/repo/index-v1.json",
89+
"sha256": "", # Will be filled after writing index
90+
"size": 0,
91+
"numPackages": 1,
92+
},
93+
}
94+
95+
96+
def main():
97+
parser = argparse.ArgumentParser(description="Generate F-Droid repo metadata")
98+
parser.add_argument("--version", required=True, help="Full SemVer version string")
99+
parser.add_argument(
100+
"--version-code", required=True, type=int, help="Android version code (int)"
101+
)
102+
parser.add_argument(
103+
"--apk-url", required=True, help="Download URL for APK (GitHub release asset)"
104+
)
105+
parser.add_argument(
106+
"--apk-hash", required=True, help="SHA-256 hash of the APK file"
107+
)
108+
parser.add_argument(
109+
"--apk-size", required=True, type=int, help="Size of APK in bytes"
110+
)
111+
parser.add_argument(
112+
"--repo-url", required=True, help="Base URL of the F-Droid repo on GitHub Pages"
113+
)
114+
parser.add_argument(
115+
"--output-dir", required=True, help="Output directory for repo files"
116+
)
117+
args = parser.parse_args()
118+
119+
os.makedirs(args.output_dir, exist_ok=True)
120+
121+
# Generate index-v1.json
122+
index = generate_index_v1(args)
123+
index_path = os.path.join(args.output_dir, "index-v1.json")
124+
index_json = json.dumps(index, indent=2)
125+
with open(index_path, "w") as f:
126+
f.write(index_json)
127+
128+
# Compute hash and size of the index file for entry.json
129+
import hashlib
130+
131+
index_bytes = index_json.encode("utf-8")
132+
index_sha256 = hashlib.sha256(index_bytes).hexdigest()
133+
134+
# Generate entry.json
135+
entry = generate_entry(args)
136+
entry["index"]["sha256"] = index_sha256
137+
entry["index"]["size"] = len(index_bytes)
138+
entry_path = os.path.join(args.output_dir, "entry.json")
139+
with open(entry_path, "w") as f:
140+
json.dump(entry, f, indent=2)
141+
142+
print(f"Generated F-Droid repo metadata in {args.output_dir}")
143+
print(f" index-v1.json: {len(index_bytes)} bytes, sha256={index_sha256}")
144+
print(f" Version: {args.version} (code {args.version_code})")
145+
print(f" APK URL: {args.apk_url}")
146+
print(f" APK hash: {args.apk_hash}")
147+
148+
149+
if __name__ == "__main__":
150+
main()
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
name: Build Android APK & Publish F-Droid Repo
2+
3+
on:
4+
push:
5+
branches: [main]
6+
workflow_dispatch:
7+
8+
permissions:
9+
contents: write
10+
pages: write
11+
id-token: write
12+
13+
concurrency:
14+
group: "build-and-deploy"
15+
cancel-in-progress: false
16+
17+
jobs:
18+
build-and-release:
19+
runs-on: ubuntu-latest
20+
outputs:
21+
semver: ${{ steps.gitversion.outputs.semVer }}
22+
version-code: ${{ steps.version-code.outputs.code }}
23+
apk-url: ${{ steps.upload-release.outputs.apk-url }}
24+
apk-hash: ${{ steps.apk-hash.outputs.sha256 }}
25+
apk-size: ${{ steps.apk-hash.outputs.size }}
26+
steps:
27+
- name: Checkout
28+
uses: actions/checkout@v4
29+
with:
30+
fetch-depth: 0 # Full history for GitVersion
31+
32+
- name: Setup .NET
33+
uses: actions/setup-dotnet@v4
34+
with:
35+
dotnet-version: '9.0.x'
36+
37+
- name: Install Android workload
38+
run: dotnet workload install android
39+
40+
- name: Install GitVersion
41+
uses: gittools/actions/gitversion/setup@v3.2.1
42+
with:
43+
versionSpec: '6.x'
44+
45+
- name: Determine version
46+
id: gitversion
47+
uses: gittools/actions/gitversion/execute@v3.2.1
48+
with:
49+
useConfigFile: false
50+
51+
- name: Display version
52+
run: |
53+
echo "SemVer: ${{ steps.gitversion.outputs.semVer }}"
54+
echo "FullSemVer: ${{ steps.gitversion.outputs.fullSemVer }}"
55+
echo "Major: ${{ steps.gitversion.outputs.major }}"
56+
echo "Minor: ${{ steps.gitversion.outputs.minor }}"
57+
echo "Patch: ${{ steps.gitversion.outputs.patch }}"
58+
echo "CommitsSinceVersionSource: ${{ steps.gitversion.outputs.commitsSinceVersionSource }}"
59+
60+
- name: Calculate version code
61+
id: version-code
62+
run: |
63+
MAJOR=${{ steps.gitversion.outputs.major }}
64+
MINOR=${{ steps.gitversion.outputs.minor }}
65+
PATCH=${{ steps.gitversion.outputs.patch }}
66+
COMMITS=${{ steps.gitversion.outputs.commitsSinceVersionSource }}
67+
# version code = major*10000 + minor*1000 + patch*100 + commits
68+
CODE=$(( MAJOR * 10000 + MINOR * 1000 + PATCH * 100 + COMMITS ))
69+
if [ "$CODE" -lt 1 ]; then CODE=1; fi
70+
echo "code=$CODE" >> "$GITHUB_OUTPUT"
71+
echo "Version code: $CODE"
72+
73+
- name: Decode keystore
74+
if: env.KEYSTORE_BASE64 != ''
75+
env:
76+
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
77+
run: |
78+
echo "$KEYSTORE_BASE64" | base64 -d > ${{ github.workspace }}/release.keystore
79+
80+
- name: Build Android APK
81+
run: |
82+
SIGNING_ARGS=""
83+
if [ -f "${{ github.workspace }}/release.keystore" ]; then
84+
SIGNING_ARGS="/p:AndroidSigningKeyStore=${{ github.workspace }}/release.keystore /p:AndroidSigningKeyAlias=${{ secrets.ANDROID_KEY_ALIAS }} /p:AndroidSigningStorePass=${{ secrets.ANDROID_KEYSTORE_PASSWORD }} /p:AndroidSigningKeyPass=${{ secrets.ANDROID_KEY_PASSWORD }}"
85+
fi
86+
dotnet publish src/RequestTracker.Android/RequestTracker.Android.csproj \
87+
-c Release \
88+
-f net9.0-android \
89+
/p:ApplicationVersion=${{ steps.version-code.outputs.code }} \
90+
/p:ApplicationDisplayVersion=${{ steps.gitversion.outputs.semVer }} \
91+
$SIGNING_ARGS
92+
93+
- name: Find and rename APK
94+
id: find-apk
95+
run: |
96+
APK=$(find src/RequestTracker.Android/bin/Release -name "*.apk" | head -1)
97+
if [ -z "$APK" ]; then
98+
echo "ERROR: No APK found!"
99+
find src/RequestTracker.Android/bin/Release -type f
100+
exit 1
101+
fi
102+
DEST="src/RequestTracker.Android/bin/Release/RequestTracker-${{ steps.gitversion.outputs.semVer }}.apk"
103+
cp "$APK" "$DEST"
104+
echo "apk-path=$DEST" >> "$GITHUB_OUTPUT"
105+
echo "Found APK: $APK -> $DEST"
106+
107+
- name: Compute APK hash and size
108+
id: apk-hash
109+
run: |
110+
APK="${{ steps.find-apk.outputs.apk-path }}"
111+
SHA256=$(sha256sum "$APK" | awk '{print $1}')
112+
SIZE=$(stat -c%s "$APK")
113+
echo "sha256=$SHA256" >> "$GITHUB_OUTPUT"
114+
echo "size=$SIZE" >> "$GITHUB_OUTPUT"
115+
echo "APK SHA-256: $SHA256"
116+
echo "APK size: $SIZE bytes"
117+
118+
- name: Create GitHub Release
119+
id: create-release
120+
uses: softprops/action-gh-release@v2
121+
with:
122+
tag_name: v${{ steps.gitversion.outputs.semVer }}
123+
name: RequestTracker v${{ steps.gitversion.outputs.semVer }}
124+
body: |
125+
## RequestTracker v${{ steps.gitversion.outputs.semVer }}
126+
127+
### Android APK
128+
- **Version**: ${{ steps.gitversion.outputs.semVer }}
129+
- **Version Code**: ${{ steps.version-code.outputs.code }}
130+
- **SHA-256**: `${{ steps.apk-hash.outputs.sha256 }}`
131+
132+
### Install via F-Droid
133+
Add this repo URL to your F-Droid client:
134+
```
135+
https://sharpninja.github.io/RequestTracker/fdroid/repo
136+
```
137+
draft: false
138+
prerelease: ${{ contains(steps.gitversion.outputs.semVer, '-') }}
139+
files: ${{ steps.find-apk.outputs.apk-path }}
140+
141+
- name: Get APK release asset URL
142+
id: upload-release
143+
run: |
144+
# Get the direct download URL for the APK asset from the release
145+
TAG="v${{ steps.gitversion.outputs.semVer }}"
146+
APK_NAME="RequestTracker-${{ steps.gitversion.outputs.semVer }}.apk"
147+
APK_URL="https://github.com/${{ github.repository }}/releases/download/${TAG}/${APK_NAME}"
148+
echo "apk-url=$APK_URL" >> "$GITHUB_OUTPUT"
149+
echo "APK release URL: $APK_URL"
150+
151+
deploy-fdroid:
152+
needs: build-and-release
153+
runs-on: ubuntu-latest
154+
environment:
155+
name: github-pages
156+
url: ${{ steps.deployment.outputs.page_url }}
157+
steps:
158+
- name: Checkout
159+
uses: actions/checkout@v4
160+
161+
- name: Setup Python
162+
uses: actions/setup-python@v5
163+
with:
164+
python-version: '3.12'
165+
166+
- name: Generate F-Droid repo metadata
167+
run: |
168+
python .github/scripts/generate_fdroid_repo.py \
169+
--version "${{ needs.build-and-release.outputs.semver }}" \
170+
--version-code "${{ needs.build-and-release.outputs.version-code }}" \
171+
--apk-url "${{ needs.build-and-release.outputs.apk-url }}" \
172+
--apk-hash "${{ needs.build-and-release.outputs.apk-hash }}" \
173+
--apk-size "${{ needs.build-and-release.outputs.apk-size }}" \
174+
--repo-url "https://sharpninja.github.io/RequestTracker/fdroid/repo" \
175+
--output-dir docs/fdroid/repo
176+
177+
- name: Show generated metadata
178+
run: |
179+
echo "=== index-v1.json ==="
180+
cat docs/fdroid/repo/index-v1.json
181+
echo ""
182+
echo "=== entry.json ==="
183+
cat docs/fdroid/repo/entry.json
184+
185+
- name: Setup Pages
186+
uses: actions/configure-pages@v5
187+
188+
- name: Upload Pages artifact
189+
uses: actions/upload-pages-artifact@v3
190+
with:
191+
path: docs/fdroid
192+
193+
- name: Deploy to GitHub Pages
194+
id: deployment
195+
uses: actions/deploy-pages@v4

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@ stderr*.log
88
src/RequestTracker/crash.log
99
docs/EXCEPTION-EVALUATION.md
1010
.nuget/
11+
.github/scripts/__pycache__/

0 commit comments

Comments
 (0)