name: Deploy on: schedule: - cron: '0 * * * *' # every hour on the hour workflow_dispatch: jobs: 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: 0 - name: Detect Android and Linux changes id: diff shell: bash env: FORGEJO_TOKEN: ${{ github.token }} 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 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("status") == "success" ] print(runs[0].get("commit_sha") or "") 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 # Diff from the last successfully deployed commit to catch all changes since # that deploy, not just the most recent commit. Falls back to HEAD~1 when # LAST_DEPLOYED_SHA is unknown or not in local history. if [ -n "$LAST_DEPLOYED_SHA" ] && git cat-file -e "$LAST_DEPLOYED_SHA" 2>/dev/null; then echo "Diffing from last deployed SHA $LAST_DEPLOYED_SHA" CHANGED=$(git diff --name-only "$LAST_DEPLOYED_SHA" HEAD 2>/dev/null \ || git show --name-only --format= HEAD) else CHANGED=$(git diff --name-only HEAD~1 HEAD 2>/dev/null \ || git show --name-only --format= HEAD) fi echo "Changed files:" echo "$CHANGED" android_re='^(android/|integration_test/|lib/|pubspec\.yaml|pubspec\.lock|drift_schemas/|scripts/deploy_playstore\.py)' 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" deploy-playstore: name: Build & Deploy to Play Store runs-on: ubuntu-latest timeout-minutes: 60 needs: [check-changes] if: needs.check-changes.outputs.android == 'true' steps: - uses: actions/checkout@v4 with: fetch-depth: 100 - 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; } - name: Setup Dagger Remote Engine env: SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }} run: scripts/setup_dagger_remote.sh - name: Publish Android to Play Store env: DAGGER_NO_NAG: "1" run: task publish-android deploy-apk: name: Build & Deploy APK to Server runs-on: ubuntu-latest timeout-minutes: 60 needs: [check-changes] if: needs.check-changes.outputs.android == 'true' steps: - uses: actions/checkout@v4 with: fetch-depth: 100 - 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; } - name: Setup Dagger Remote Engine env: SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }} run: scripts/setup_dagger_remote.sh - name: Build & Deploy APK to server env: DAGGER_NO_NAG: "1" run: task deploy-apk build-linux: name: Build Linux Release runs-on: ubuntu-latest timeout-minutes: 60 needs: [check-changes] if: needs.check-changes.outputs.linux == 'true' steps: - uses: actions/checkout@v4 with: fetch-depth: 100 - 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; } - name: Setup Dagger Remote Engine env: SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }} run: scripts/setup_dagger_remote.sh - name: Build & Deploy Linux to server env: DAGGER_NO_NAG: "1" run: task deploy-linux label-deploy-health: name: Update Deploy Health Label runs-on: ubuntu-latest needs: [deploy-playstore, deploy-apk, build-linux] 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' ) 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 }} 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') }} 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