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)
|
HEAD_SHA=$(git rev-parse HEAD)
|
||||||
|
|
||||||
# Skip if this exact commit was already successfully deployed (prevents
|
# Find the most recent workflow run where deploy-playstore actually succeeded
|
||||||
# hourly schedule from redeploying the same commit on every tick).
|
# (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'
|
LAST_DEPLOYED_SHA=$(python3 - << 'PYEOF'
|
||||||
import json, os, sys, urllib.request
|
import json, os, sys, urllib.request
|
||||||
token = os.environ.get("FORGEJO_TOKEN", "")
|
token = os.environ.get("FORGEJO_TOKEN", "")
|
||||||
server = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
|
server = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
|
||||||
repo = os.environ.get("GITHUB_REPOSITORY", "")
|
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}"})
|
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(req) as r:
|
with urllib.request.urlopen(req) as r:
|
||||||
@@ -50,15 +53,40 @@ jobs:
|
|||||||
r for r in data.get("workflow_runs", [])
|
r for r in data.get("workflow_runs", [])
|
||||||
if r.get("status") == "success"
|
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:
|
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("")
|
print("")
|
||||||
PYEOF
|
PYEOF
|
||||||
)
|
)
|
||||||
|
|
||||||
if [ -n "$LAST_DEPLOYED_SHA" ] && [ "$HEAD_SHA" = "$LAST_DEPLOYED_SHA" ]; then
|
if [ -z "$LAST_DEPLOYED_SHA" ]; then
|
||||||
echo "HEAD $HEAD_SHA already successfully deployed — skipping all deploy jobs"
|
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 "android=false" >> "$GITHUB_OUTPUT"
|
||||||
echo "linux=false" >> "$GITHUB_OUTPUT"
|
echo "linux=false" >> "$GITHUB_OUTPUT"
|
||||||
echo "skip_reason=commit $HEAD_SHA was already successfully deployed" >> "$GITHUB_OUTPUT"
|
echo "skip_reason=commit $HEAD_SHA was already successfully deployed" >> "$GITHUB_OUTPUT"
|
||||||
@@ -66,15 +94,17 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Diff from the last successfully deployed commit to catch all changes since
|
# 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
|
# that deploy, not just the most recent commit. Deploy all targets when the
|
||||||
# LAST_DEPLOYED_SHA is unknown or not in local history.
|
# SHA is not in local history (shallow clone or very old deploy).
|
||||||
if [ -n "$LAST_DEPLOYED_SHA" ] && git cat-file -e "$LAST_DEPLOYED_SHA" 2>/dev/null; then
|
if git cat-file -e "$LAST_DEPLOYED_SHA" 2>/dev/null; then
|
||||||
echo "Diffing from last deployed SHA $LAST_DEPLOYED_SHA"
|
echo "Diffing from last deployed SHA $LAST_DEPLOYED_SHA"
|
||||||
CHANGED=$(git diff --name-only "$LAST_DEPLOYED_SHA" HEAD 2>/dev/null \
|
CHANGED=$(git diff --name-only "$LAST_DEPLOYED_SHA" HEAD 2>/dev/null \
|
||||||
|| git show --name-only --format= HEAD)
|
|| git show --name-only --format= HEAD)
|
||||||
else
|
else
|
||||||
CHANGED=$(git diff --name-only HEAD~1 HEAD 2>/dev/null \
|
echo "::warning::Last deployed SHA $LAST_DEPLOYED_SHA not in local history — deploying all targets as a precaution"
|
||||||
|| git show --name-only --format= HEAD)
|
echo "android=true" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "linux=true" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Changed files:"
|
echo "Changed files:"
|
||||||
@@ -86,17 +116,21 @@ jobs:
|
|||||||
if echo "$CHANGED" | grep -qE "$android_re"; then
|
if echo "$CHANGED" | grep -qE "$android_re"; then
|
||||||
echo "android=true" >> "$GITHUB_OUTPUT"
|
echo "android=true" >> "$GITHUB_OUTPUT"
|
||||||
echo "Android deploy: TRIGGERED (android-relevant files changed)"
|
echo "Android deploy: TRIGGERED (android-relevant files changed)"
|
||||||
|
echo "::notice::Android deploy TRIGGERED — android-relevant files changed since $LAST_DEPLOYED_SHA"
|
||||||
else
|
else
|
||||||
echo "android=false" >> "$GITHUB_OUTPUT"
|
echo "android=false" >> "$GITHUB_OUTPUT"
|
||||||
echo "Android deploy: SKIPPED (no android-relevant files changed)"
|
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
|
fi
|
||||||
|
|
||||||
if echo "$CHANGED" | grep -qE "$linux_re"; then
|
if echo "$CHANGED" | grep -qE "$linux_re"; then
|
||||||
echo "linux=true" >> "$GITHUB_OUTPUT"
|
echo "linux=true" >> "$GITHUB_OUTPUT"
|
||||||
echo "Linux deploy: TRIGGERED (linux-relevant files changed)"
|
echo "Linux deploy: TRIGGERED (linux-relevant files changed)"
|
||||||
|
echo "::notice::Linux deploy TRIGGERED — linux-relevant files changed since $LAST_DEPLOYED_SHA"
|
||||||
else
|
else
|
||||||
echo "linux=false" >> "$GITHUB_OUTPUT"
|
echo "linux=false" >> "$GITHUB_OUTPUT"
|
||||||
echo "Linux deploy: SKIPPED (no linux-relevant files changed)"
|
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
|
fi
|
||||||
|
|
||||||
deploy-playstore:
|
deploy-playstore:
|
||||||
@@ -126,6 +160,11 @@ jobs:
|
|||||||
DAGGER_NO_NAG: "1"
|
DAGGER_NO_NAG: "1"
|
||||||
run: task publish-android
|
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:
|
deploy-apk:
|
||||||
name: Build & Deploy APK to Server
|
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