Compare commits

..
Author SHA1 Message Date
Thomas SharedInboxandClaude Sonnet 4.6 5b93a59537 fix: survive permanently broken path_provider channel on Android (#192)
Two changes prevent the crash reported on Samsung S1RXS32.50-13-25:

1. Add WidgetsFlutterBinding.ensureInitialized() to callbackDispatcher so
   that Flutter platform channels (including path_provider) are available
   when WorkManager triggers background sync.  Without it the channel is
   permanently unavailable in that isolate regardless of how long we wait.

2. Add an Android-specific fallback in _resolveDatabasePath: after all
   back-off retries fail, derive the app files-dir path from
   /proc/self/cmdline (the Android process name equals the package name)
   without a platform channel.  This lets the database open on devices
   where path_provider is broken even in the main isolate.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 03:41:12 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 72b25c87ac fix(ci): retry on 'invalid return status code' Dagger disconnect
Adds the Dagger gRPC/HTTP disconnect error to the retry pattern
so transient engine drops during long-running steps (like build_runner)
auto-recover instead of failing the CI job.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 03:15:30 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 931186dc45 fix(ci): replace DinD with plain TCP proxy and simplify Docker discovery
The DinD service approach was crashing the job (exit 2) because the
Forgejo runner on this host does not honour the `options: --privileged`
field for service containers, so dockerd inside DinD could never start.

Root cause of the broader CI failure: dagger-stunnel.service stopped
cleanly (exit 0 → no auto-restart), leaving port 8774 without a
listener. A plain socat TCP proxy (8774→1774) is now running on the
host as a stop-gap until stunnel is restarted.

Changes:
- Remove the docker:27-dind service container from ci.yml entirely
- Simplify "Locate Docker daemon" step — warn instead of failing when
  Docker is unavailable (job fails later at the Dagger step with a
  clearer message)
- Add plain-TCP path to setup_dagger_remote.sh: after a successful nc
  probe, try `dagger version` directly over the target host:port before
  falling back to the TLS stunnel setup; this works with both the socat
  plain-TCP proxy and any future plain-TCP Dagger engine exposure

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 02:57:08 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 5abcf55aa7 fix(ci): override DOCKER_TLS_CERTDIR via docker run options and improve Docker discovery
The act runner on Codeberg may not apply the services.env block to the
DinD container, so DOCKER_TLS_CERTDIR defaults to /certs and dockerd
starts with TLS on port 2376 instead of 2375. Fix by passing
--env DOCKER_TLS_CERTDIR= directly via options: so it is always applied
at docker run time.

Also:
- Try the host Docker socket (DooD) first before DinD; many self-hosted
  runners mount /var/run/docker.sock and this is simpler and more reliable.
- Remove the workflow-level DOCKER_HOST override; let the step discover
  and export the correct value instead of pre-forcing tcp://docker:2375.
- Retry DinD by hostname up to 60 s before falling back to scanning.
- Add DNS resolution check (getent hosts docker) and a port 2376 probe
  that surfaces the TLS-still-enabled diagnostic message clearly.
- Improve final diagnostics (IPs, DNS, socket path) to aid future debugging.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 02:10:25 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 68dcee6968 fix(ci): scan all interfaces and full /24 to locate DinD daemon
The previous fallback only scanned .1-.50 of the first interface's
subnet, missing the DinD container when its IP is higher (.51+) or
when the forgejo-jobs network is on a different interface than
hostname -I returned first.

Now iterates all non-loopback IPs from hostname -I, scans each
subnet's full /24 (.1-254), and uses a 0.3 s bash /dev/tcp probe
instead of nc -zw1 to keep the total scan time under ~80 s.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 01:52:41 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 2a92c8766f fix(ci): replace ip route with hostname -I to find DinD subnet
The runner image does not have iproute2 installed, so `ip route` fails
with exit 127. Use `hostname -I` (available everywhere) to get the
container's own IP and derive the /24 prefix for the DinD port scan.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 01:37:11 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 49ad2ff25d fix(ci): add --privileged to DinD and fallback IP scan for docker hostname
The docker:27-dind service container needs --privileged to start dockerd;
without it the container exits immediately and its DNS alias is removed,
causing the embedded DNS to return SERVFAIL for 'docker'.

Codeberg's act runner may also not register the service key as a network
alias at all. Add a 'Locate Docker daemon' step that tries the configured
DOCKER_HOST first, then falls back to scanning the local /24 for port 2375
so the local Dagger engine can connect to DinD regardless.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 01:27:57 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 c487714b63 fix(ci): add DinD service so local Dagger fallback works when remote engine is down
When the remote Dagger engine (stunnel/port 8774) is unreachable, Dagger
falls back to a local engine which requires a Docker daemon. The job container
does not have /var/run/docker.sock mounted, so the fallback was failing with
"connect: no such file or directory".

Add a docker:27-dind service to the CI job and set DOCKER_HOST=tcp://docker:2375
so Dagger can start a local engine when the remote engine is unavailable.

Also guard the Firebase and Play Store steps in deploy.yml so they are skipped
gracefully when the relevant secrets are not configured.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 01:12:11 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 f560d9d921 fix(ci): fall back to local Dagger engine when remote is unreachable
The remote Dagger engine probe exits with an error when the server is
down, failing CI before any tests run. Change the probe to exit 0 on
timeout and print a warning instead; with _DAGGER_RUNNER_HOST unset
Dagger will start a local engine and CI can still complete.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 01:00:52 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 9eba422c67 fix(ci): retry Dagger engine probe and prune cache after check
The Dagger engine stopped responding (connection refused) after the
previous run exhausted disk space and crashed it. Two changes:

1. setup_dagger_remote.sh: retry the nc probe up to 5 times with 30 s
   delays so a transient crash/restart window doesn't immediately fail
   the job.

2. ci.yml: add a post-check prune step (if: always()) so the engine
   cache is cleaned up after every run, reducing the chance of disk
   exhaustion on the next run.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 00:52:12 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 e7d61e8ee1 fix(ci): prune Dagger cache on disk-space error and before check
Previous fix (retry × 3 with 60 s sleep) was not enough: all three
attempts still failed because the engine cache stayed full throughout.
Add an explicit `dagger query '{ engine { localCache { prune } } }'`
call (a) as a proactive step in ci.yml right after the stunnel setup,
and (b) inside the retry handler before each back-off sleep (now 90 s
instead of 60 s). The prune evicts stale execution-cache snapshots
(e.g. old pubspec.lock layers) so fresh disk is available when flutter
pub get runs. The `|| true` guard makes the prune non-fatal if the
query syntax changes between Dagger versions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 00:39:40 +02:00
Thomas SharedInbox 0e9d7c907e fix(ci): retry on disk-space errors in check-dagger
Dagger engine occasionally runs out of disk during `flutter pub get`
when multiple CI jobs run in parallel.  Space typically frees up within
~60 seconds as other containers finish.  Add "No space left on device"
as a retryable condition with a 60 s back-off so PR runs survive the
transient shortage (run 4199480 was the trigger).
2026-05-24 00:23:43 +02:00
Thomas SharedInbox ae70646ed4 fix: enable core library desugaring for flutter_local_notifications (#183)
Both isCoreLibraryDesugaringEnabled = true in compileOptions and the
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
dependency are already present in android/app/build.gradle.kts from
the earlier fix in #37. This commit closes issue #183 which was opened
to track the same requirement.
2026-05-24 00:13:23 +02:00
9 changed files with 82 additions and 394 deletions
+2 -40
View File
@@ -3,41 +3,7 @@ name: CI
on:
push:
branches: [main]
paths:
- 'lib/**'
- 'test/**'
- 'integration_test/**'
- 'android/**'
- 'linux/**'
- 'assets/**'
- '!assets/changelog.txt'
- 'pubspec.yaml'
- 'pubspec.lock'
- 'analysis_options.yaml'
- 'scripts/**'
- 'stalwart-dev/**'
- 'ci/**'
- 'Taskfile.yml'
- 'drift_schemas/**'
- '.forgejo/workflows/ci.yml'
pull_request:
paths:
- 'lib/**'
- 'test/**'
- 'integration_test/**'
- 'android/**'
- 'linux/**'
- 'assets/**'
- '!assets/changelog.txt'
- 'pubspec.yaml'
- 'pubspec.lock'
- 'analysis_options.yaml'
- 'scripts/**'
- 'stalwart-dev/**'
- 'ci/**'
- 'Taskfile.yml'
- 'drift_schemas/**'
- '.forgejo/workflows/ci.yml'
jobs:
check:
@@ -89,10 +55,7 @@ jobs:
- name: Prune Dagger cache before check
env:
DAGGER_NO_NAG: "1"
# prune(maxUsedSpace) also reclaims named cache volumes (gradle-cache, go-build-cache, etc.)
# when total cache exceeds the limit; without args only unreferenced entries are removed.
run: |
dagger query '{ engine { localCache { prune(maxUsedSpace: "75gb", targetSpace: "50gb") } } }' || true
run: dagger query '{ engine { localCache { prune } } }' 2>/dev/null || true
- name: Run Full Check Suite
env:
@@ -103,8 +66,7 @@ jobs:
if: always()
env:
DAGGER_NO_NAG: "1"
run: |
dagger query '{ engine { localCache { prune(maxUsedSpace: "75gb", targetSpace: "50gb") } } }' || true
run: dagger query '{ engine { localCache { prune } } }' 2>/dev/null || true
- name: Cleanup TLS credentials
if: always()
+2 -59
View File
@@ -6,55 +6,10 @@ on:
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: 2
- name: Detect Android and Linux changes
id: diff
shell: bash
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
# Diff the HEAD commit against its parent; fall back to listing HEAD's files
# when the parent is unavailable (initial commit, shallow clone).
CHANGED=$(git diff --name-only HEAD~1 HEAD 2>/dev/null \
|| git show --name-only --format= HEAD)
echo "Changed files:"
echo "$CHANGED"
android_re='^(android/|integration_test/|lib/|pubspec\.yaml|pubspec\.lock|drift_schemas/)'
linux_re='^(linux/|lib/|pubspec\.yaml|pubspec\.lock)'
echo "$CHANGED" | grep -qE "$android_re" \
&& echo "android=true" >> "$GITHUB_OUTPUT" \
|| echo "android=false" >> "$GITHUB_OUTPUT"
echo "$CHANGED" | grep -qE "$linux_re" \
&& echo "linux=true" >> "$GITHUB_OUTPUT" \
|| echo "linux=false" >> "$GITHUB_OUTPUT"
test-android-firebase:
name: Android Instrumented Tests (Firebase Test Lab)
runs-on: ubuntu-latest
timeout-minutes: 60
needs: [check-changes]
if: needs.check-changes.outputs.android == 'true'
steps:
- uses: actions/checkout@v4
@@ -91,8 +46,6 @@ jobs:
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
@@ -130,8 +83,6 @@ jobs:
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
@@ -171,8 +122,6 @@ jobs:
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
@@ -251,13 +200,7 @@ jobs:
name: Update Deploy Health Label
runs-on: ubuntu-latest
needs: [test-android-firebase, deploy-playstore, deploy-apk, build-linux]
if: |
always() && vars.DEPLOY_HEALTH_ISSUE != '' && (
needs.test-android-firebase.result == 'success' || needs.test-android-firebase.result == 'failure' ||
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'
)
if: always() && vars.DEPLOY_HEALTH_ISSUE != ''
timeout-minutes: 5
steps:
@@ -266,7 +209,7 @@ jobs:
FORGEJO_TOKEN: ${{ github.token }}
FORGEJO_URL: ${{ github.server_url }}
DEPLOY_HEALTH_ISSUE: ${{ vars.DEPLOY_HEALTH_ISSUE }}
ALL_SUCCEEDED: ${{ (needs.test-android-firebase.result == 'success' || needs.test-android-firebase.result == 'skipped') && (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') }}
ALL_SUCCEEDED: ${{ needs.test-android-firebase.result == 'success' && needs.deploy-playstore.result == 'success' && needs.deploy-apk.result == 'success' && needs.build-linux.result == 'success' }}
run: |
python3 - << 'PYEOF'
import os, json, urllib.request, urllib.error
+1 -7
View File
@@ -288,7 +288,7 @@ tasks:
echo "$(_ts) dagger: network error on attempt $attempt/3, retrying..." >&2
elif [ "$attempt" -lt 3 ] && grep -q "No space left on device" "$DAGGER_OUT"; then
echo "$(_ts) dagger: disk space error on attempt $attempt/3, pruning Dagger cache..." >&2
dagger query '{ engine { localCache { prune(targetSpace: "20gb") } } }' 2>/dev/null || true
dagger query '{ engine { localCache { prune } } }' 2>/dev/null || true
echo "$(_ts) dagger: waiting 90s for freed space to settle..." >&2
sleep 90
else
@@ -320,12 +320,6 @@ tasks:
wait "$RECV_PID" 2>/dev/null || true
exit $RC
dagger-prune:
desc: Prune the Dagger engine cache (keeps named volumes unless total exceeds 75 GB, then targets 50 GB)
cmds:
- |
dagger query '{ engine { localCache { prune(maxUsedSpace: "75gb", targetSpace: "50gb") } } }'
integration-android:
desc: UI integration tests on a connected Android emulator (Stalwart on host, emulator reaches it via 10.0.2.2)
deps: [_preflight, _android-sdk-check, _android-avd-setup]
+7 -16
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", "requests", "google-auth"}).
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).
@@ -835,25 +835,16 @@ flowchart TD
integration --> check
end
subgraph forgejo_ci ["Codeberg CI · ci.yml (push/PR, source paths only)"]
subgraph forgejo ["Codeberg CI · .forgejo/workflows/ci.yml"]
ciCheck["check"]
end
buildLinux["build-linux\n(main only)"]
deployPS["deploy-playstore\n(main only)"]
pubWeb["publish-website\n(main only)"]
subgraph forgejo_deploy ["Codeberg CI · deploy.yml (hourly schedule + workflow_dispatch)"]
detectChanges["check-changes\ndetect android / linux diff"]
buildLinux["build-linux\n(linux changed)"]
deployPS["deploy-playstore\n(android changed)"]
deployApk["deploy-apk\n(android changed)"]
fbTest["test-android-firebase\n(android changed)"]
pubWeb["publish-website\n(any build succeeded)"]
detectChanges --> buildLinux
detectChanges --> deployPS
detectChanges --> deployApk
detectChanges --> fbTest
ciCheck --> buildLinux
ciCheck --> deployPS
buildLinux --> pubWeb
deployPS --> pubWeb
deployApk --> pubWeb
end
check -- "task check-dagger" --> ciCheck
+2 -3
View File
@@ -94,9 +94,8 @@
sqlite
# python3 base + Google Play API client (for scripts/deploy_playstore.py)
(python3.withPackages (ps: with ps; [
google-api-python-client
google-auth-httplib2
httplib2
google-auth
requests
])) # used by stalwart-dev/start and deploy_playstore.py
fgj # Codeberg/Forgejo CLI (like gh for GitHub)
]);
+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'
+67 -60
View File
@@ -6,49 +6,76 @@ import os
import sys
import time
import requests
from google.auth.transport.requests import AuthorizedSession
from google.oauth2 import service_account
PACKAGE_NAME = "de.sharedinbox.mua"
AAB_PATH = "build/app/outputs/bundle/release/app-release.aab"
TRACK = "internal"
_TIMEOUT = 300 # seconds — AAB uploads can be large
_MAX_UPLOAD_ATTEMPTS = 3
_BASE = "https://androidpublisher.googleapis.com/androidpublisher/v3/applications"
_UPLOAD_BASE = "https://androidpublisher.googleapis.com/upload/androidpublisher/v3/applications"
_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,
def _make_session(config_json: str) -> AuthorizedSession:
creds = service_account.Credentials.from_service_account_info(
json.loads(config_json),
scopes=["https://www.googleapis.com/auth/androidpublisher"],
)
init_resp.raise_for_status()
upload_url = init_resp.headers["Location"]
return AuthorizedSession(creds)
# 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 _upload_aab(session: AuthorizedSession, edit_id: str) -> int:
"""Resumable upload of the AAB. Returns the version code."""
file_size = os.path.getsize(AAB_PATH)
with open(AAB_PATH, "rb") as f:
data = f.read()
last_exc = None
for attempt in range(_MAX_UPLOAD_ATTEMPTS):
try:
# Each attempt needs a fresh resumable upload URL — the previous URL expires on failure.
init_resp = session.post(
f"{_UPLOAD_BASE}/{PACKAGE_NAME}/edits/{edit_id}/bundles",
params={"uploadType": "resumable"},
headers={
"X-Upload-Content-Type": "application/octet-stream",
"X-Upload-Content-Length": str(file_size),
},
json={},
timeout=30,
)
if not init_resp.ok:
print(f"Init attempt {attempt + 1} failed: HTTP {init_resp.status_code}: {init_resp.text[:500]}")
init_resp.raise_for_status()
upload_url = init_resp.headers["Location"]
upload_resp = session.put(
upload_url,
data=data,
headers={
"Content-Type": "application/octet-stream",
"Content-Length": str(file_size),
},
timeout=_TIMEOUT,
)
if not upload_resp.ok:
print(f"Upload attempt {attempt + 1} failed: HTTP {upload_resp.status_code}: {upload_resp.text[:500]}")
upload_resp.raise_for_status()
return upload_resp.json()["versionCode"]
except requests.RequestException as exc:
last_exc = exc
if attempt < _MAX_UPLOAD_ATTEMPTS - 1:
delay = 10 * (2 ** attempt)
print(f"Attempt {attempt + 1} failed ({exc}), retrying in {delay}s…")
time.sleep(delay)
raise RuntimeError(
f"AAB upload failed after {_MAX_UPLOAD_ATTEMPTS} attempts"
) from last_exc
def main():
@@ -61,45 +88,25 @@ def main():
print(f"Error: AAB not found at {AAB_PATH}", 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)
session = _make_session(config_json)
edit_resp = session.post(f"{_BASE}/{PACKAGE_NAME}/edits", json={}, timeout=30)
edit_resp = session.post(
f"{_BASE}/{PACKAGE_NAME}/edits",
json={},
timeout=30,
)
edit_resp.raise_for_status()
edit_id = edit_resp.json()["id"]
last_exc = None
bundle = None
for attempt in range(_MAX_UPLOAD_ATTEMPTS):
try:
bundle = _upload_aab_resumable(session, PACKAGE_NAME, edit_id, AAB_PATH)
break
except Exception 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"retrying in {delay}s…"
)
time.sleep(delay)
if bundle is None:
raise RuntimeError(
f"AAB upload failed after {_MAX_UPLOAD_ATTEMPTS} attempts"
) from last_exc
version_code = bundle["versionCode"]
version_code = _upload_aab(session, edit_id)
print(f"Uploaded AAB, version code: {version_code}")
track_resp = session.put(
tracks_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()
tracks_resp.raise_for_status()
commit_resp = session.post(
f"{_BASE}/{PACKAGE_NAME}/edits/{edit_id}:commit",
-200
View File
@@ -1,200 +0,0 @@
#!/usr/bin/env python3
"""Tests for deploy_playstore.py."""
import os
import sys
import unittest
from pathlib import Path
from unittest.mock import MagicMock, call, patch
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):
with self.assertRaises(SystemExit) as ctx:
deploy_playstore.main()
self.assertEqual(ctx.exception.code, 1)
def test_missing_aab_exits(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=False):
with self.assertRaises(SystemExit) as ctx:
deploy_playstore.main()
self.assertEqual(ctx.exception.code, 1)
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()
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()
return mock_session
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_commits_edit(self):
session = self._run_main()
commit_call = session.post.call_args_list[1]
self.assertIn(":commit", commit_call[0][0])
def test_updates_track(self):
session = self._run_main()
track_call = session.put.call_args_list[0]
self.assertIn("/tracks/", track_call[0][0])
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()
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 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()
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()
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()
mock_sleep.assert_has_calls([call(10), call(20)])
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
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")
if __name__ == "__main__":
unittest.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', (