diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index b6c1a72..105a3ad 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -34,14 +34,17 @@ jobs: 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). + # 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", "") - url = f"{server}/api/v1/repos/{repo}/actions/runs?workflow_id=deploy.yml&status=success&limit=5" + 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: @@ -50,15 +53,40 @@ jobs: r for r in data.get("workflow_runs", []) if r.get("status") == "success" ] - print(runs[0].get("commit_sha") or "") + # 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"API check failed: {e}", file=sys.stderr) + print(f"::error::LAST_DEPLOYED_SHA lookup failed ({type(e).__name__}: {e})") print("") PYEOF ) - if [ -n "$LAST_DEPLOYED_SHA" ] && [ "$HEAD_SHA" = "$LAST_DEPLOYED_SHA" ]; then - echo "HEAD $HEAD_SHA already successfully deployed — skipping all deploy jobs" + 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" @@ -66,15 +94,17 @@ jobs: 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 + # 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 - CHANGED=$(git diff --name-only HEAD~1 HEAD 2>/dev/null \ - || git show --name-only --format= HEAD) + 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:" @@ -86,17 +116,21 @@ jobs: 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: @@ -126,6 +160,11 @@ jobs: 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 diff --git a/scripts/test_verify_playstore_deploy.py b/scripts/test_verify_playstore_deploy.py new file mode 100644 index 0000000..da354c6 --- /dev/null +++ b/scripts/test_verify_playstore_deploy.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Tests for verify_playstore_deploy.py.""" +import os +import sys +import time +import unittest +from pathlib import Path +from unittest.mock import MagicMock, patch + +sys.path.insert(0, str(Path(__file__).parent)) + +import verify_playstore_deploy + + +def _make_session(version_code, track="internal"): + """Return a mock AuthorizedSession with the given version code on the track.""" + session = MagicMock() + + edit_resp = MagicMock() + edit_resp.json.return_value = {"id": "edit-99"} + session.post.return_value = edit_resp + + track_resp = MagicMock() + track_resp.json.return_value = { + "releases": [{"versionCodes": [str(version_code)], "status": "completed"}] + } + session.get.return_value = track_resp + session.delete.return_value = MagicMock() + + return session + + +class TestMissingEnv(unittest.TestCase): + def test_missing_env_exits(self): + with patch.dict(os.environ, {}, clear=True): + with self.assertRaises(SystemExit) as ctx: + verify_playstore_deploy.main() + self.assertEqual(ctx.exception.code, 1) + + +class TestRecentDeploy(unittest.TestCase): + def _run(self, version_code): + session = _make_session(version_code) + with patch.dict(os.environ, {"PLAY_STORE_CONFIG_JSON": '{"type":"service_account"}'}): + with patch("verify_playstore_deploy.service_account.Credentials.from_service_account_info"): + with patch("verify_playstore_deploy.AuthorizedSession", return_value=session): + verify_playstore_deploy.main() + + def test_recent_version_code_passes(self): + # Version code is Unix timestamp — a very recent one should pass. + recent_vc = int(time.time()) - 60 # 1 minute ago + self._run(recent_vc) + + def test_old_version_code_fails(self): + old_vc = int(time.time()) - 7200 # 2 hours ago + with self.assertRaises(SystemExit) as ctx: + self._run(old_vc) + self.assertEqual(ctx.exception.code, 1) + + +class TestEmptyTrack(unittest.TestCase): + def _run_empty(self, releases): + session = MagicMock() + session.post.return_value = MagicMock(**{"json.return_value": {"id": "edit-1"}}) + session.get.return_value = MagicMock(**{"json.return_value": {"releases": releases}}) + session.delete.return_value = MagicMock() + + with patch.dict(os.environ, {"PLAY_STORE_CONFIG_JSON": '{"type":"service_account"}'}): + with patch("verify_playstore_deploy.service_account.Credentials.from_service_account_info"): + with patch("verify_playstore_deploy.AuthorizedSession", return_value=session): + verify_playstore_deploy.main() + + def test_no_releases_exits(self): + with self.assertRaises(SystemExit) as ctx: + self._run_empty([]) + self.assertEqual(ctx.exception.code, 1) + + def test_release_with_no_version_codes_exits(self): + with self.assertRaises(SystemExit) as ctx: + self._run_empty([{"status": "completed", "versionCodes": []}]) + self.assertEqual(ctx.exception.code, 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/verify_playstore_deploy.py b/scripts/verify_playstore_deploy.py new file mode 100644 index 0000000..4864e37 --- /dev/null +++ b/scripts/verify_playstore_deploy.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +"""Verify that the Android app was recently published to the Play Store internal track. + +The publish-android pipeline sets versionCode = int(time.Now().Unix()), so a +freshly deployed release always has a version code close to the current Unix +timestamp. This script queries the internal track and fails if the latest +version code is older than _MAX_DEPLOY_AGE_SECONDS, which would mean the +deployment silently did not land. +""" + +import json +import os +import sys +import time + +from google.auth.transport.requests import AuthorizedSession +from google.oauth2 import service_account + +PACKAGE_NAME = "de.sharedinbox.mua" +TRACK = "internal" +_BASE = "https://androidpublisher.googleapis.com/androidpublisher/v3/applications" +# Allow up to one hour for the build + upload to complete. +_MAX_DEPLOY_AGE_SECONDS = 3600 + + +def main(): + config_json = os.environ.get("PLAY_STORE_CONFIG_JSON") + if not config_json: + print("Error: PLAY_STORE_CONFIG_JSON environment variable not set", file=sys.stderr) + sys.exit(1) + + creds = service_account.Credentials.from_service_account_info( + json.loads(config_json), + scopes=["https://www.googleapis.com/auth/androidpublisher"], + ) + session = AuthorizedSession(creds) + + # Open a read-only edit to query the current track state. + edit_resp = session.post(f"{_BASE}/{PACKAGE_NAME}/edits", json={}, timeout=30) + edit_resp.raise_for_status() + edit_id = edit_resp.json()["id"] + + try: + track_resp = session.get( + f"{_BASE}/{PACKAGE_NAME}/edits/{edit_id}/tracks/{TRACK}", + timeout=30, + ) + track_resp.raise_for_status() + track_data = track_resp.json() + finally: + # Discard the edit — we made no changes. + try: + session.delete(f"{_BASE}/{PACKAGE_NAME}/edits/{edit_id}", timeout=30) + except Exception: + pass + + releases = track_data.get("releases", []) + if not releases: + print( + f"ERROR: No releases found on {TRACK} track — deploy may have failed silently", + file=sys.stderr, + ) + sys.exit(1) + + all_version_codes = [ + int(vc) + for release in releases + for vc in release.get("versionCodes", []) + ] + if not all_version_codes: + print("ERROR: Latest release has no version codes", file=sys.stderr) + sys.exit(1) + + latest_vc = max(all_version_codes) + now = int(time.time()) + # versionCode is set to Unix timestamp by PublishAndroid in ci/main.go. + age_seconds = now - latest_vc + + print(f"Latest version code on {TRACK} track: {latest_vc}") + print(f"Current time: {now} — version code age: {age_seconds}s") + + if age_seconds > _MAX_DEPLOY_AGE_SECONDS: + print( + f"::error::Latest version code {latest_vc} is {age_seconds}s old " + f"(limit: {_MAX_DEPLOY_AGE_SECONDS}s). The deploy may have failed silently.", + file=sys.stderr, + ) + sys.exit(1) + + print(f"OK: version {latest_vc} verified on {TRACK} track ({age_seconds}s old)") + + +if __name__ == "__main__": + main()