2026-05-22 22:05:09 +02:00
name : Deploy
on :
schedule :
- cron : '0 * * * *' # every hour on the hour
workflow_dispatch :
jobs :
2026-05-24 08:30:10 +02:00
check-changes :
name : Detect Changed Files
runs-on : ubuntu-latest
timeout-minutes : 5
outputs :
android : ${{ steps.diff.outputs.android }}
linux : ${{ steps.diff.outputs.linux }}
steps :
- uses : actions/checkout@v4
with :
fetch-depth : 2
- name : Detect Android and Linux changes
id : diff
shell : bash
2026-05-26 07:35:18 +02:00
env :
FORGEJO_TOKEN : ${{ github.token }}
2026-05-24 08:30:10 +02:00
run : |
# On workflow_dispatch always build everything
if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then
echo "android=true" >> "$GITHUB_OUTPUT"
echo "linux=true" >> "$GITHUB_OUTPUT"
exit 0
fi
2026-05-26 07:35:18 +02:00
HEAD_SHA=$(git rev-parse HEAD)
# Skip if this exact commit was already successfully deployed (prevents
# hourly schedule from redeploying the same commit on every tick).
LAST_DEPLOYED_SHA=$(python3 - << 'PYEOF'
import json, os, sys, urllib.request
token = os.environ.get("FORGEJO_TOKEN", "")
server = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
repo = os.environ.get("GITHUB_REPOSITORY", "")
url = f"{server}/api/v1/repos/{repo}/actions/runs?workflow_id=deploy.yml&status=success&limit=5"
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
try:
with urllib.request.urlopen(req) as r:
data = json.loads(r.read())
runs = [
r for r in data.get("workflow_runs", [])
if r.get("workflow_id") == "deploy.yml" and r.get("status") == "success"
]
2026-05-26 15:21:50 +02:00
print(runs[0].get("commit_sha") or "")
2026-05-26 07:35:18 +02:00
except Exception as e:
print(f"API check failed: {e}", file=sys.stderr)
print("")
PYEOF
)
if [ -n "$LAST_DEPLOYED_SHA" ] && [ "$HEAD_SHA" = "$LAST_DEPLOYED_SHA" ]; then
echo "HEAD $HEAD_SHA already successfully deployed — skipping"
echo "android=false" >> "$GITHUB_OUTPUT"
echo "linux=false" >> "$GITHUB_OUTPUT"
exit 0
fi
2026-05-24 08:30:10 +02:00
# Diff the HEAD commit against its parent; fall back to listing HEAD's files
# when the parent is unavailable (initial commit, shallow clone).
CHANGED=$(git diff --name-only HEAD~1 HEAD 2>/dev/null \
|| git show --name-only --format= HEAD)
echo "Changed files:"
echo "$CHANGED"
2026-05-24 21:05:10 +02:00
android_re='^(android/|integration_test/|lib/|pubspec\.yaml|pubspec\.lock|drift_schemas/|scripts/deploy_playstore\.py)'
2026-05-24 08:30:10 +02:00
linux_re='^(linux/|lib/|pubspec\.yaml|pubspec\.lock)'
echo "$CHANGED" | grep -qE "$android_re" \
&& echo "android=true" >> "$GITHUB_OUTPUT" \
|| echo "android=false" >> "$GITHUB_OUTPUT"
echo "$CHANGED" | grep -qE "$linux_re" \
&& echo "linux=true" >> "$GITHUB_OUTPUT" \
|| echo "linux=false" >> "$GITHUB_OUTPUT"
2026-05-22 22:05:09 +02:00
deploy-playstore :
name : Build & Deploy to Play Store
runs-on : ubuntu-latest
timeout-minutes : 60
2026-05-24 08:30:10 +02:00
needs : [ check-changes]
if : needs.check-changes.outputs.android == 'true'
2026-05-22 22:05:09 +02:00
steps :
- uses : actions/checkout@v4
with :
2026-05-24 21:05:10 +02:00
fetch-depth : 100
2026-05-22 22:05:09 +02:00
- name : Check runner tools
run : |
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
- name : Setup Dagger Remote Engine (via stunnel)
env :
DAGGER_STUNNEL_URL : ${{ secrets.DAGGER_STUNNEL_URL }}
DAGGER_CA_CERT : ${{ secrets.DAGGER_CA_CERT }}
DAGGER_CLIENT_CERT : ${{ secrets.DAGGER_CLIENT_CERT }}
DAGGER_CLIENT_KEY : ${{ secrets.DAGGER_CLIENT_KEY }}
run : scripts/setup_dagger_remote.sh
- name : Publish Android to Play Store
2026-05-24 18:39:23 +02:00
if : ${{ secrets.PLAY_STORE_CONFIG_JSON != '' }}
2026-05-22 22:05:09 +02:00
env :
2026-05-24 18:39:23 +02:00
ANDROID_KEYSTORE_BASE64 : ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
ANDROID_KEYSTORE_PASSWORD : ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
PLAY_STORE_CONFIG_JSON : ${{ secrets.PLAY_STORE_CONFIG_JSON }}
2026-05-22 22:05:09 +02:00
DAGGER_NO_NAG : "1"
run : task publish-android
2026-05-23 18:55:08 +02:00
- name : Cleanup TLS credentials
if : always()
run : rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
deploy-apk :
name : Build & Deploy APK to Server
runs-on : ubuntu-latest
timeout-minutes : 60
2026-05-24 08:30:10 +02:00
needs : [ check-changes]
if : needs.check-changes.outputs.android == 'true'
2026-05-23 18:55:08 +02:00
steps :
- uses : actions/checkout@v4
with :
2026-05-25 14:47:25 +02:00
fetch-depth : 100
2026-05-23 18:55:08 +02:00
- name : Check runner tools
run : |
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
- name : Setup Dagger Remote Engine (via stunnel)
env :
DAGGER_STUNNEL_URL : ${{ secrets.DAGGER_STUNNEL_URL }}
DAGGER_CA_CERT : ${{ secrets.DAGGER_CA_CERT }}
DAGGER_CLIENT_CERT : ${{ secrets.DAGGER_CLIENT_CERT }}
DAGGER_CLIENT_KEY : ${{ secrets.DAGGER_CLIENT_KEY }}
run : scripts/setup_dagger_remote.sh
2026-05-22 22:05:09 +02:00
- name : Build & Deploy APK to server
2026-05-24 18:39:23 +02:00
if : ${{ secrets.SSH_PRIVATE_KEY != '' }}
2026-05-22 22:05:09 +02:00
env :
2026-05-24 18:39:23 +02:00
SSH_PRIVATE_KEY : ${{ secrets.SSH_PRIVATE_KEY }}
SSH_KNOWN_HOSTS : ${{ secrets.SSH_KNOWN_HOSTS }}
SSH_USER : ${{ secrets.SSH_USER }}
SSH_HOST : ${{ secrets.SSH_HOST }}
ANDROID_KEYSTORE_BASE64 : ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
ANDROID_KEYSTORE_PASSWORD : ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
2026-05-22 22:05:09 +02:00
DAGGER_NO_NAG : "1"
run : task deploy-apk
2026-05-23 10:54:25 +02:00
- name : Cleanup TLS credentials
if : always()
run : rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
2026-05-22 22:05:09 +02:00
build-linux :
name : Build Linux Release
runs-on : ubuntu-latest
timeout-minutes : 60
2026-05-24 08:30:10 +02:00
needs : [ check-changes]
if : needs.check-changes.outputs.linux == 'true'
2026-05-22 22:05:09 +02:00
steps :
- uses : actions/checkout@v4
with :
2026-05-25 14:47:25 +02:00
fetch-depth : 100
2026-05-22 22:05:09 +02:00
- name : Check runner tools
run : |
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
- name : Setup Dagger Remote Engine (via stunnel)
env :
DAGGER_STUNNEL_URL : ${{ secrets.DAGGER_STUNNEL_URL }}
DAGGER_CA_CERT : ${{ secrets.DAGGER_CA_CERT }}
DAGGER_CLIENT_CERT : ${{ secrets.DAGGER_CLIENT_CERT }}
DAGGER_CLIENT_KEY : ${{ secrets.DAGGER_CLIENT_KEY }}
run : scripts/setup_dagger_remote.sh
- name : Build & Deploy Linux to server
2026-05-24 18:39:23 +02:00
if : ${{ secrets.SSH_PRIVATE_KEY != '' }}
2026-05-22 22:05:09 +02:00
env :
2026-05-24 18:39:23 +02:00
SSH_PRIVATE_KEY : ${{ secrets.SSH_PRIVATE_KEY }}
SSH_KNOWN_HOSTS : ${{ secrets.SSH_KNOWN_HOSTS }}
SSH_USER : ${{ secrets.SSH_USER }}
SSH_HOST : ${{ secrets.SSH_HOST }}
2026-05-22 22:05:09 +02:00
DAGGER_NO_NAG : "1"
run : task deploy-linux
2026-05-23 10:54:25 +02:00
- name : Cleanup TLS credentials
if : always()
run : rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
2026-05-22 22:05:09 +02:00
label-deploy-health :
name : Update Deploy Health Label
runs-on : ubuntu-latest
2026-05-26 08:48:10 +02:00
needs : [ deploy-playstore, deploy-apk, build-linux]
2026-05-24 08:30:10 +02:00
if : |
always() && vars.DEPLOY_HEALTH_ISSUE != '' && (
needs.deploy-playstore.result == 'success' || needs.deploy-playstore.result == 'failure' ||
needs.deploy-apk.result == 'success' || needs.deploy-apk.result == 'failure' ||
needs.build-linux.result == 'success' || needs.build-linux.result == 'failure'
)
2026-05-22 22:05:09 +02:00
timeout-minutes : 5
steps :
- name : Set CI/Full-Pass or CI/Full-Fail label on tracking issue
env :
FORGEJO_TOKEN : ${{ github.token }}
FORGEJO_URL : ${{ github.server_url }}
DEPLOY_HEALTH_ISSUE : ${{ vars.DEPLOY_HEALTH_ISSUE }}
2026-05-26 08:48:10 +02:00
ALL_SUCCEEDED : ${{ (needs.deploy-playstore.result == 'success' || needs.deploy-playstore.result == 'skipped') && (needs.deploy-apk.result == 'success' || needs.deploy-apk.result == 'skipped') && (needs.build-linux.result == 'success' || needs.build-linux.result == 'skipped') }}
2026-05-22 22:05:09 +02:00
run : |
python3 - << 'PYEOF'
import os, json, urllib.request, urllib.error
issue = os.environ.get("DEPLOY_HEALTH_ISSUE", "").strip()
if not issue:
print("DEPLOY_HEALTH_ISSUE not set; skipping")
raise SystemExit(0)
token = os.environ["FORGEJO_TOKEN"]
url_base = os.environ["FORGEJO_URL"].rstrip("/")
succeeded = os.environ.get("ALL_SUCCEEDED", "false").lower() == "true"
add_label = "CI/Full-Pass" if succeeded else "CI/Full-Fail"
remove_label = "CI/Full-Fail" if succeeded else "CI/Full-Pass"
headers = {"Authorization": f"token {token}", "Content-Type": "application/json"}
api = f"{url_base}/api/v1/repos/guettli/sharedinbox"
def api_get(path):
req = urllib.request.Request(f"{api}{path}", headers=headers)
with urllib.request.urlopen(req) as r:
return json.loads(r.read())
def api_put(path, body):
data = json.dumps(body).encode()
req = urllib.request.Request(f"{api}{path}", data=data, headers=headers, method="PUT")
try:
with urllib.request.urlopen(req) as r:
return json.loads(r.read())
except urllib.error.HTTPError as e:
print(f"PUT {path} failed: {e.read().decode()}")
raise
repo_labels = api_get("/labels")
label_map = {l["name"]: l["id"] for l in repo_labels}
if add_label not in label_map:
print(f"Label '{add_label}' not found in repo — create it first")
raise SystemExit(1)
current = api_get(f"/issues/{issue}/labels")
keep_ids = [l["id"] for l in current if l["name"] not in ("CI/Full-Pass", "CI/Full-Fail")]
keep_ids.append(label_map[add_label])
api_put(f"/issues/{issue}/labels", {"labels": keep_ids})
print(f"Set '{add_label}' on issue #{issue}")
PYEOF