fix: correct LAST_DEPLOYED_SHA detection so Play Store always gets updated (#364)
Closes #361 Three bugs in the hourly deploy workflow's change-detection logic caused the Play Store to silently fall behind whenever a deploy failed or all-android jobs were skipped. **Bug 1 (primary): commit_sha → head_sha** Forgejo's API returns head_sha; commit_sha was always None. This meant LAST_DEPLOYED_SHA was always empty, so the diff fell back to HEAD~1..HEAD — only the single most recent commit was inspected. If android changes landed in an earlier commit, they were silently missed. **Bug 2: Skipped runs counted as 'deployed'** A workflow run where deploy-playstore was skipped (android=false) has status=success, so it was treated as a successful deploy. Now the code queries each run's job results and only trusts a run where the 'Build & Deploy to Play Store' job's own conclusion=success. **Bug 3: Narrow fallback when SHA unknown** When LAST_DEPLOYED_SHA could not be determined the workflow diffed HEAD~1..HEAD — potentially missing many commits. Now it defaults to android=true / linux=true (deploy everything) as the safe fallback. Additional changes: - ::error:: / ::warning:: / ::notice:: annotations so skip/failure reasons surface in the Actions UI. - scripts/verify_playstore_deploy.py: new post-deploy check that queries the internal track and fails if the latest version code is more than 1 hour old. (Version codes are Unix timestamps set by ci/main.go's PublishAndroid.) Catches silent deploy failures the upload API did not reject. - scripts/test_verify_playstore_deploy.py: 5 unit tests for the verify script (all pass). Co-authored-by: Thomas SharedInbox <sharedinbox@thomas-guettler.de> Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/364
This commit was merged in pull request #364.
This commit is contained in:
committed by
guettli
co-authored by
guettli
Thomas SharedInbox
parent
d847d40ab0
commit
6a097976d3
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user