Compare commits

..
Author SHA1 Message Date
Thomas SharedInboxandClaude Sonnet 4.6 bd94d0babe fix: limit dagger-data volume growth by pruning named caches (#193)
The plain `prune` operation only removes unreferenced execution-cache
entries; named cache volumes (gradle-cache, go-build-cache, go-mod-cache,
pip-cache) are never reclaimed and grow without bound, causing the
dagger-data Docker volume to reach 200 GB over time.

Pass maxUsedSpace/targetSpace arguments to `prune` so Dagger also
reclaims named volumes when the total cache exceeds 75 GB, targeting
50 GB. Use an aggressive targetSpace of 20 GB for disk-space emergency
recovery in check-dagger. Add a dagger-prune task for manual use.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 05:51:40 +02:00
5 changed files with 179 additions and 210 deletions
+1 -1
View File
@@ -739,7 +739,7 @@ func (m *Ci) UploadToPlayStore(
From("python:3.12-alpine").
WithExec([]string{"apk", "add", "--no-cache", "curl"}).
WithMountedCache("/root/.cache/pip", dag.CacheVolume("pip-cache")).
WithExec([]string{"pip", "install", "google-auth", "requests"}).
WithExec([]string{"pip", "install", "google-api-python-client", "google-auth-httplib2", "httplib2"}).
WithFile("/src/build/app/outputs/bundle/release/app-release.aab", aab).
WithFile("/src/scripts/deploy_playstore.py", scriptSource.File("scripts/deploy_playstore.py")).
WithSecretVariable("PLAY_STORE_CONFIG_JSON", playStoreConfig).
+1 -5
View File
@@ -47,14 +47,10 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
final osName = _capitalize(Platform.operatingSystem);
final isDark = MediaQuery.of(context).platformBrightness == Brightness.dark;
final gitCommitLine = _gitHash.isNotEmpty
? '| Git Commit | [$_gitHash](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash) |\n'
: '';
return '## [sharedinbox.de](https://sharedinbox.de)\n\n'
return '## sharedinbox.de\n\n'
'| Property | Value |\n'
'|----------|-------|\n'
'| App Version | $versionDisplay |\n'
'$gitCommitLine'
'| Platform | ${Platform.operatingSystem} |\n'
'| $osName Version | ${Platform.operatingSystemVersion} |\n'
'| Resolution | ${physW}x$physH px'
+36 -58
View File
@@ -6,51 +6,19 @@ import os
import sys
import time
from google.auth.transport.requests import AuthorizedSession
import google_auth_httplib2
import httplib2
from google.oauth2 import service_account
from googleapiclient.discovery import build
from googleapiclient.http import MediaFileUpload
PACKAGE_NAME = "de.sharedinbox.mua"
AAB_PATH = "build/app/outputs/bundle/release/app-release.aab"
TRACK = "internal"
_BASE = "https://androidpublisher.googleapis.com/androidpublisher/v3/applications"
_UPLOAD_BASE = "https://androidpublisher.googleapis.com/upload/androidpublisher/v3/applications"
_TIMEOUT = 300 # seconds — AAB uploads can be large
_MAX_UPLOAD_ATTEMPTS = 3
def _upload_aab_resumable(session, package, edit_id, aab_path):
"""Upload AAB using the Google resumable upload protocol."""
file_size = os.path.getsize(aab_path)
init_url = f"{_UPLOAD_BASE}/{package}/edits/{edit_id}/bundles"
# Step 1: initiate the resumable upload session
init_resp = session.post(
init_url,
params={"uploadType": "resumable"},
headers={
"X-Upload-Content-Type": "application/octet-stream",
"X-Upload-Content-Length": str(file_size),
"Content-Length": "0",
},
timeout=60,
)
init_resp.raise_for_status()
upload_url = init_resp.headers["Location"]
# Step 2: upload the file in a single PUT to the session URI
with open(aab_path, "rb") as f:
upload_resp = session.put(
upload_url,
data=f,
headers={
"Content-Type": "application/octet-stream",
"Content-Length": str(file_size),
},
timeout=600,
)
upload_resp.raise_for_status()
return upload_resp.json()
def main():
config_json = os.environ.get("PLAY_STORE_CONFIG_JSON")
if not config_json:
@@ -65,47 +33,57 @@ def main():
json.loads(config_json),
scopes=["https://www.googleapis.com/auth/androidpublisher"],
)
session = AuthorizedSession(creds)
edit_resp = session.post(f"{_BASE}/{PACKAGE_NAME}/edits", json={}, timeout=30)
edit_resp.raise_for_status()
edit_id = edit_resp.json()["id"]
authorized_http = google_auth_httplib2.AuthorizedHttp(
creds, http=httplib2.Http(timeout=_TIMEOUT)
)
service = build("androidpublisher", "v3", http=authorized_http)
edit = service.edits().insert(body={}, packageName=PACKAGE_NAME).execute(num_retries=3)
edit_id = edit["id"]
# The resumable upload can fail with RedirectMissingLocation on transient
# network hiccups. Retry with a fresh MediaFileUpload each time (resumable
# uploads can't reuse the same object) using exponential backoff.
version_code = None
last_exc = None
bundle = None
for attempt in range(_MAX_UPLOAD_ATTEMPTS):
try:
bundle = _upload_aab_resumable(session, PACKAGE_NAME, edit_id, AAB_PATH)
media = MediaFileUpload(
AAB_PATH, mimetype="application/octet-stream", resumable=True
)
bundle = (
service.edits()
.bundles()
.upload(packageName=PACKAGE_NAME, editId=edit_id, media_body=media)
.execute(num_retries=3)
)
version_code = bundle["versionCode"]
break
except Exception as exc:
except httplib2.error.RedirectMissingLocation as exc:
last_exc = exc
if attempt < _MAX_UPLOAD_ATTEMPTS - 1:
delay = 10 * (2 ** attempt)
print(
f"Upload attempt {attempt + 1} failed ({type(exc).__name__}: {exc}), "
f"Upload attempt {attempt + 1} failed (redirect error), "
f"retrying in {delay}s…"
)
time.sleep(delay)
if bundle is None:
else:
raise RuntimeError(
f"AAB upload failed after {_MAX_UPLOAD_ATTEMPTS} attempts"
) from last_exc
version_code = bundle["versionCode"]
print(f"Uploaded AAB, version code: {version_code}")
track_resp = session.put(
f"{_BASE}/{PACKAGE_NAME}/edits/{edit_id}/tracks/{TRACK}",
json={"releases": [{"versionCodes": [version_code], "status": "completed"}]},
timeout=30,
)
track_resp.raise_for_status()
service.edits().tracks().update(
packageName=PACKAGE_NAME,
editId=edit_id,
track=TRACK,
body={"releases": [{"versionCodes": [version_code], "status": "completed"}]},
).execute(num_retries=3)
commit_resp = session.post(
f"{_BASE}/{PACKAGE_NAME}/edits/{edit_id}:commit",
timeout=30,
)
commit_resp.raise_for_status()
service.edits().commit(packageName=PACKAGE_NAME, editId=edit_id).execute(num_retries=3)
print(f"Deployed version {version_code} to {TRACK} track")
+141 -142
View File
@@ -1,7 +1,9 @@
#!/usr/bin/env python3
"""Tests for deploy_playstore.py."""
import io
import os
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import MagicMock, call, patch
@@ -11,35 +13,6 @@ sys.path.insert(0, str(Path(__file__).parent))
import deploy_playstore
def _make_session(
edit_id="edit-42",
version_code=7,
upload_side_effects=None,
):
"""Return a mock AuthorizedSession with sensible defaults."""
session = MagicMock()
# POST /edits → create edit
edit_resp = MagicMock()
edit_resp.json.return_value = {"id": edit_id}
session.post.return_value = edit_resp
# POST resumable-init → Location header
init_resp = MagicMock()
init_resp.headers = {"Location": "https://upload.example.com/session"}
# PUT upload → bundle JSON
upload_resp = MagicMock()
upload_resp.json.return_value = {"versionCode": version_code}
if upload_side_effects is not None:
# Use side_effect list: first call is edit create, rest are upload inits
# We override the PUT side effects via _upload_aab_resumable mock instead
pass
return session, init_resp, upload_resp
class TestMainEnvChecks(unittest.TestCase):
def test_missing_env_exits(self):
with patch.dict(os.environ, {}, clear=True):
@@ -57,143 +30,169 @@ class TestMainEnvChecks(unittest.TestCase):
class TestMainHappyPath(unittest.TestCase):
def _run_main(self, fake_config='{"type":"service_account"}'):
mock_session = MagicMock()
# POST for edit create and commit
post_responses = [
MagicMock(**{"json.return_value": {"id": "edit-42"}}), # create edit
MagicMock(), # commit
]
mock_session.post.side_effect = post_responses
# PUT for track update
mock_session.put.return_value = MagicMock()
def _run_main(self, fake_config):
mock_service = MagicMock()
mock_edits = mock_service.edits.return_value
mock_edits.insert.return_value.execute.return_value = {"id": "edit-42"}
mock_edits.bundles.return_value.upload.return_value.execute.return_value = {
"versionCode": 7
}
mock_edits.tracks.return_value.update.return_value.execute.return_value = {}
mock_edits.commit.return_value.execute.return_value = {}
with patch.dict(os.environ, {"PLAY_STORE_CONFIG_JSON": fake_config}):
with patch("deploy_playstore.os.path.exists", return_value=True):
with patch("deploy_playstore.service_account.Credentials.from_service_account_info"):
with patch("deploy_playstore.AuthorizedSession", return_value=mock_session):
with patch(
"deploy_playstore._upload_aab_resumable",
return_value={"versionCode": 7},
):
deploy_playstore.main()
with patch("deploy_playstore.google_auth_httplib2.AuthorizedHttp"):
with patch("deploy_playstore.build", return_value=mock_service):
with patch("deploy_playstore.MediaFileUpload"):
deploy_playstore.main()
return mock_session
return mock_edits
def test_creates_edit(self):
session = self._run_main()
create_call = session.post.call_args_list[0]
self.assertIn("/edits", create_call[0][0])
def test_insert_called_with_num_retries(self):
edits = self._run_main('{"type":"service_account"}')
edits.insert.return_value.execute.assert_called_once_with(num_retries=3)
def test_commits_edit(self):
session = self._run_main()
commit_call = session.post.call_args_list[1]
self.assertIn(":commit", commit_call[0][0])
def test_bundle_upload_called_with_num_retries(self):
edits = self._run_main('{"type":"service_account"}')
edits.bundles.return_value.upload.return_value.execute.assert_called_once_with(num_retries=3)
def test_updates_track(self):
session = self._run_main()
track_call = session.put.call_args_list[0]
self.assertIn("/tracks/", track_call[0][0])
def test_tracks_update_called_with_num_retries(self):
edits = self._run_main('{"type":"service_account"}')
edits.tracks.return_value.update.return_value.execute.assert_called_once_with(num_retries=3)
def test_commit_called_with_num_retries(self):
edits = self._run_main('{"type":"service_account"}')
edits.commit.return_value.execute.assert_called_once_with(num_retries=3)
def test_authorized_http_uses_timeout(self):
fake_config = '{"type":"service_account"}'
with patch.dict(os.environ, {"PLAY_STORE_CONFIG_JSON": fake_config}):
with patch("deploy_playstore.os.path.exists", return_value=True):
with patch("deploy_playstore.service_account.Credentials.from_service_account_info"):
with patch("deploy_playstore.httplib2.Http") as mock_http_cls:
with patch("deploy_playstore.google_auth_httplib2.AuthorizedHttp") as mock_auth:
mock_service = MagicMock()
mock_edits = mock_service.edits.return_value
mock_edits.insert.return_value.execute.return_value = {"id": "e1"}
mock_edits.bundles.return_value.upload.return_value.execute.return_value = {
"versionCode": 1
}
mock_edits.tracks.return_value.update.return_value.execute.return_value = {}
mock_edits.commit.return_value.execute.return_value = {}
with patch("deploy_playstore.build", return_value=mock_service):
with patch("deploy_playstore.MediaFileUpload"):
deploy_playstore.main()
mock_http_cls.assert_called_once_with(timeout=deploy_playstore._TIMEOUT)
def _redirect_error():
import httplib2
return httplib2.error.RedirectMissingLocation("redirect missing", {}, b"")
class TestUploadRetry(unittest.TestCase):
def _run_main(self, upload_side_effects, sleep_mock=None):
mock_session = MagicMock()
post_responses = [
MagicMock(**{"json.return_value": {"id": "edit-1"}}),
MagicMock(),
]
mock_session.post.side_effect = post_responses
mock_session.put.return_value = MagicMock()
def _make_mock_service(self, upload_side_effects):
mock_service = MagicMock()
mock_edits = mock_service.edits.return_value
mock_edits.insert.return_value.execute.return_value = {"id": "edit-1"}
mock_edits.bundles.return_value.upload.return_value.execute.side_effect = (
upload_side_effects
)
mock_edits.tracks.return_value.update.return_value.execute.return_value = {}
mock_edits.commit.return_value.execute.return_value = {}
return mock_service, mock_edits
patches = [
patch.dict(os.environ, {"PLAY_STORE_CONFIG_JSON": '{"type":"service_account"}'}),
patch("deploy_playstore.os.path.exists", return_value=True),
patch("deploy_playstore.service_account.Credentials.from_service_account_info"),
patch("deploy_playstore.AuthorizedSession", return_value=mock_session),
patch("deploy_playstore._upload_aab_resumable", side_effect=upload_side_effects),
patch("deploy_playstore.time.sleep"),
]
for p in patches:
p.start()
try:
deploy_playstore.main()
finally:
for p in patches:
p.stop()
def _run_with_service(self, mock_service):
fake_config = '{"type":"service_account"}'
with patch.dict(os.environ, {"PLAY_STORE_CONFIG_JSON": fake_config}):
with patch("deploy_playstore.os.path.exists", return_value=True):
with patch("deploy_playstore.service_account.Credentials.from_service_account_info"):
with patch("deploy_playstore.google_auth_httplib2.AuthorizedHttp"):
with patch("deploy_playstore.build", return_value=mock_service):
with patch("deploy_playstore.MediaFileUpload"):
with patch("deploy_playstore.time.sleep"):
deploy_playstore.main()
def test_succeeds_on_first_attempt(self):
with patch("deploy_playstore._upload_aab_resumable", return_value={"versionCode": 5}) as mock_upload:
with patch.dict(os.environ, {"PLAY_STORE_CONFIG_JSON": '{"type":"service_account"}'}):
with patch("deploy_playstore.os.path.exists", return_value=True):
with patch("deploy_playstore.service_account.Credentials.from_service_account_info"):
mock_session = MagicMock()
mock_session.post.side_effect = [
MagicMock(**{"json.return_value": {"id": "e1"}}),
MagicMock(),
]
mock_session.put.return_value = MagicMock()
with patch("deploy_playstore.AuthorizedSession", return_value=mock_session):
deploy_playstore.main()
mock_upload.assert_called_once()
mock_service, mock_edits = self._make_mock_service([{"versionCode": 5}])
self._run_with_service(mock_service)
mock_edits.bundles.return_value.upload.return_value.execute.assert_called_once_with(
num_retries=3
)
def test_retries_once_on_error_then_succeeds(self):
self._run_main([ValueError("transient"), {"versionCode": 9}])
def test_raises_after_all_attempts_exhausted(self):
with self.assertRaises(RuntimeError) as ctx:
self._run_main([ValueError("err"), ValueError("err"), ValueError("err")])
self.assertIn(str(deploy_playstore._MAX_UPLOAD_ATTEMPTS), str(ctx.exception))
def test_backoff_delays_are_10s_then_20s(self):
mock_session = MagicMock()
mock_session.post.side_effect = [
MagicMock(**{"json.return_value": {"id": "e1"}}),
MagicMock(),
]
mock_session.put.return_value = MagicMock()
def test_retries_once_on_redirect_error_then_succeeds(self):
mock_service, mock_edits = self._make_mock_service(
[_redirect_error(), {"versionCode": 9}]
)
with patch.dict(os.environ, {"PLAY_STORE_CONFIG_JSON": '{"type":"service_account"}'}):
with patch("deploy_playstore.os.path.exists", return_value=True):
with patch("deploy_playstore.service_account.Credentials.from_service_account_info"):
with patch("deploy_playstore.AuthorizedSession", return_value=mock_session):
with patch(
"deploy_playstore._upload_aab_resumable",
side_effect=[ValueError("e"), ValueError("e"), {"versionCode": 3}],
):
with patch("deploy_playstore.time.sleep") as mock_sleep:
deploy_playstore.main()
with patch("deploy_playstore.google_auth_httplib2.AuthorizedHttp"):
with patch("deploy_playstore.build", return_value=mock_service):
with patch("deploy_playstore.MediaFileUpload") as mock_media_cls:
with patch("deploy_playstore.time.sleep") as mock_sleep:
deploy_playstore.main()
self.assertEqual(
mock_edits.bundles.return_value.upload.return_value.execute.call_count, 2
)
mock_sleep.assert_called_once_with(10)
self.assertEqual(mock_media_cls.call_count, 2)
def test_raises_after_all_attempts_exhausted(self):
mock_service, _ = self._make_mock_service(
[_redirect_error(), _redirect_error(), _redirect_error()]
)
with patch.dict(os.environ, {"PLAY_STORE_CONFIG_JSON": '{"type":"service_account"}'}):
with patch("deploy_playstore.os.path.exists", return_value=True):
with patch("deploy_playstore.service_account.Credentials.from_service_account_info"):
with patch("deploy_playstore.google_auth_httplib2.AuthorizedHttp"):
with patch("deploy_playstore.build", return_value=mock_service):
with patch("deploy_playstore.MediaFileUpload"):
with patch("deploy_playstore.time.sleep"):
with self.assertRaises(RuntimeError) as ctx:
deploy_playstore.main()
self.assertIn(
str(deploy_playstore._MAX_UPLOAD_ATTEMPTS), str(ctx.exception)
)
def test_backoff_delays_are_10s_then_20s(self):
mock_service, _ = self._make_mock_service(
[_redirect_error(), _redirect_error(), {"versionCode": 3}]
)
with patch.dict(os.environ, {"PLAY_STORE_CONFIG_JSON": '{"type":"service_account"}'}):
with patch("deploy_playstore.os.path.exists", return_value=True):
with patch("deploy_playstore.service_account.Credentials.from_service_account_info"):
with patch("deploy_playstore.google_auth_httplib2.AuthorizedHttp"):
with patch("deploy_playstore.build", return_value=mock_service):
with patch("deploy_playstore.MediaFileUpload"):
with patch("deploy_playstore.time.sleep") as mock_sleep:
deploy_playstore.main()
mock_sleep.assert_has_calls([call(10), call(20)])
def test_fresh_media_upload_created_on_each_attempt(self):
mock_service, _ = self._make_mock_service(
[_redirect_error(), {"versionCode": 2}]
)
class TestUploadAabResumable(unittest.TestCase):
def test_initiates_and_uploads(self):
mock_session = MagicMock()
init_resp = MagicMock()
init_resp.headers = {"Location": "https://upload.example.com/sess"}
upload_resp = MagicMock()
upload_resp.json.return_value = {"versionCode": 42}
mock_session.post.return_value = init_resp
mock_session.put.return_value = upload_resp
with patch.dict(os.environ, {"PLAY_STORE_CONFIG_JSON": '{"type":"service_account"}'}):
with patch("deploy_playstore.os.path.exists", return_value=True):
with patch("deploy_playstore.service_account.Credentials.from_service_account_info"):
with patch("deploy_playstore.google_auth_httplib2.AuthorizedHttp"):
with patch("deploy_playstore.build", return_value=mock_service):
with patch("deploy_playstore.MediaFileUpload") as mock_media_cls:
with patch("deploy_playstore.time.sleep"):
deploy_playstore.main()
import tempfile
with tempfile.NamedTemporaryFile(delete=False) as f:
f.write(b"fake-aab-content")
aab_path = f.name
try:
result = deploy_playstore._upload_aab_resumable(
mock_session, "com.example.app", "edit-1", aab_path
)
finally:
os.unlink(aab_path)
self.assertEqual(result["versionCode"], 42)
mock_session.post.assert_called_once()
mock_session.put.assert_called_once()
put_call = mock_session.put.call_args
self.assertEqual(put_call[0][0], "https://upload.example.com/sess")
self.assertEqual(mock_media_cls.call_count, 2)
if __name__ == "__main__":
-4
View File
@@ -151,10 +151,6 @@ void main() {
expect(clipboardText, contains('Dark Mode'));
expect(clipboardText, contains('IMAP Accounts'));
expect(clipboardText, contains('JMAP Accounts'));
expect(
clipboardText,
contains('[sharedinbox.de](https://sharedinbox.de)'),
);
});
testWidgets('AboutScreen create-issue button opens Codeberg URL', (