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) # Find the most recent workflow run where deploy-playstore actually succeeded # (not merely skipped). Bug fix: previous code used commit_sha (always None in # Forgejo's API) instead of head_sha, causing LAST_DEPLOYED_SHA to be empty on # every run and the fallback diff to only cover HEAD~1..HEAD. 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", "") base_api = f"{server}/api/v1/repos/{repo}/actions" url = f"{base_api}/runs?workflow_id=deploy.yml&status=success&limit=10" 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" ] # Walk runs newest-first; pick the first one where deploy-playstore # actually ran (conclusion=success), not just skipped. for run in runs: run_id = run.get("id") jobs_url = f"{base_api}/runs/{run_id}/jobs" jobs_req = urllib.request.Request(jobs_url, headers={"Authorization": f"token {token}"}) try: with urllib.request.urlopen(jobs_req) as jr: jobs_data = json.loads(jr.read()) for job in jobs_data.get("workflow_jobs", []): if "Deploy to Play Store" in job.get("name", "") and ( job.get("conclusion") == "success" or job.get("status") == "success" ): print(run.get("head_sha") or "") sys.exit(0) except Exception: pass # skip this run if jobs API fails print("") except Exception as e: print(f"::error::LAST_DEPLOYED_SHA lookup failed ({type(e).__name__}: {e})") print("") PYEOF ) if [ -z "$LAST_DEPLOYED_SHA" ]; then echo "::warning::Could not determine last successfully deployed SHA — deploying all targets as a precaution" echo "android=true" >> "$GITHUB_OUTPUT" echo "linux=true" >> "$GITHUB_OUTPUT" exit 0 fi if [ "$HEAD_SHA" = "$LAST_DEPLOYED_SHA" ]; then echo "::notice::All deploys SKIPPED — HEAD $HEAD_SHA was already successfully deployed" echo "android=false" >> "$GITHUB_OUTPUT" echo "linux=false" >> "$GITHUB_OUTPUT" echo "skip_reason=commit $HEAD_SHA was already successfully deployed" >> "$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. Deploy all targets when the # SHA is not in local history (shallow clone or very old deploy). if 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 echo "::warning::Last deployed SHA $LAST_DEPLOYED_SHA not in local history — deploying all targets as a precaution" echo "android=true" >> "$GITHUB_OUTPUT" echo "linux=true" >> "$GITHUB_OUTPUT" exit 0 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)' if echo "$CHANGED" | grep -qE "$android_re"; then echo "android=true" >> "$GITHUB_OUTPUT" echo "Android deploy: TRIGGERED (android-relevant files changed)" echo "::notice::Android deploy TRIGGERED — android-relevant files changed since $LAST_DEPLOYED_SHA" else echo "android=false" >> "$GITHUB_OUTPUT" echo "Android deploy: SKIPPED (no android-relevant files changed)" echo "::notice::Android deploy SKIPPED — diff $LAST_DEPLOYED_SHA..HEAD has no android-relevant changes" fi if echo "$CHANGED" | grep -qE "$linux_re"; then echo "linux=true" >> "$GITHUB_OUTPUT" echo "Linux deploy: TRIGGERED (linux-relevant files changed)" echo "::notice::Linux deploy TRIGGERED — linux-relevant files changed since $LAST_DEPLOYED_SHA" else echo "linux=false" >> "$GITHUB_OUTPUT" echo "Linux deploy: SKIPPED (no linux-relevant files changed)" echo "::notice::Linux deploy SKIPPED — diff $LAST_DEPLOYED_SHA..HEAD has no linux-relevant changes" fi 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 - name: Verify Play Store deployment run: | pip install google-auth requests --quiet 2>&1 | grep -v "already satisfied" || true python3 scripts/verify_playstore_deploy.py 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