Compare commits
24
Commits
@@ -3,7 +3,41 @@ name: CI
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
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:
|
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:
|
jobs:
|
||||||
check:
|
check:
|
||||||
@@ -30,11 +64,48 @@ jobs:
|
|||||||
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
|
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
|
||||||
run: scripts/setup_dagger_remote.sh
|
run: scripts/setup_dagger_remote.sh
|
||||||
|
|
||||||
|
- name: Locate Docker daemon for local Dagger engine
|
||||||
|
run: |
|
||||||
|
# Skip if remote Dagger engine is already configured (preferred path)
|
||||||
|
if [ -n "${_DAGGER_RUNNER_HOST:-}" ]; then
|
||||||
|
echo "Remote Dagger engine configured, no local Docker needed."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Try host Docker socket (DooD) if runner mounts it
|
||||||
|
if [ -S /var/run/docker.sock ]; then
|
||||||
|
if DOCKER_HOST=unix:///var/run/docker.sock docker info >/dev/null 2>&1; then
|
||||||
|
echo "Docker available via host socket."
|
||||||
|
echo "DOCKER_HOST=unix:///var/run/docker.sock" >> "$GITHUB_ENV"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "WARNING: No remote Dagger engine and no local Docker found." >&2
|
||||||
|
echo " - Remote engine: check DAGGER_STUNNEL_URL secret and that the host proxy is running." >&2
|
||||||
|
echo " - Local Docker: runner does not expose /var/run/docker.sock." >&2
|
||||||
|
echo "CI will likely fail at the Dagger step." >&2
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|
||||||
- name: Run Full Check Suite
|
- name: Run Full Check Suite
|
||||||
env:
|
env:
|
||||||
DAGGER_NO_NAG: "1"
|
DAGGER_NO_NAG: "1"
|
||||||
run: task check-dagger
|
run: task check-dagger
|
||||||
|
|
||||||
|
- name: Prune Dagger cache after check
|
||||||
|
if: always()
|
||||||
|
env:
|
||||||
|
DAGGER_NO_NAG: "1"
|
||||||
|
run: |
|
||||||
|
dagger query '{ engine { localCache { prune(maxUsedSpace: "75gb", targetSpace: "50gb") } } }' || true
|
||||||
|
|
||||||
- name: Cleanup TLS credentials
|
- name: Cleanup TLS credentials
|
||||||
if: always()
|
if: always()
|
||||||
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
||||||
|
|||||||
+105
-26
@@ -6,15 +6,60 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test-android-firebase:
|
check-changes:
|
||||||
name: Android Instrumented Tests (Firebase Test Lab)
|
name: Detect Changed Files
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 60
|
timeout-minutes: 5
|
||||||
|
outputs:
|
||||||
|
android: ${{ steps.diff.outputs.android }}
|
||||||
|
linux: ${{ steps.diff.outputs.linux }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 50
|
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
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
- name: Check runner tools
|
- name: Check runner tools
|
||||||
run: |
|
run: |
|
||||||
@@ -31,6 +76,7 @@ jobs:
|
|||||||
run: scripts/setup_dagger_remote.sh
|
run: scripts/setup_dagger_remote.sh
|
||||||
|
|
||||||
- name: Run Android Tests on Firebase Test Lab
|
- name: Run Android Tests on Firebase Test Lab
|
||||||
|
if: ${{ secrets.FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY != '' }}
|
||||||
env:
|
env:
|
||||||
FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY: ${{ secrets.FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY }}
|
FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY: ${{ secrets.FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY }}
|
||||||
FIREBASE_PROJECT_ID: ${{ vars.FIREBASE_PROJECT_ID }}
|
FIREBASE_PROJECT_ID: ${{ vars.FIREBASE_PROJECT_ID }}
|
||||||
@@ -45,11 +91,13 @@ jobs:
|
|||||||
name: Build & Deploy to Play Store
|
name: Build & Deploy to Play Store
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
|
needs: [check-changes]
|
||||||
|
if: needs.check-changes.outputs.android == 'true'
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 50
|
fetch-depth: 1
|
||||||
|
|
||||||
- name: Check runner tools
|
- name: Check runner tools
|
||||||
run: |
|
run: |
|
||||||
@@ -66,6 +114,7 @@ jobs:
|
|||||||
run: scripts/setup_dagger_remote.sh
|
run: scripts/setup_dagger_remote.sh
|
||||||
|
|
||||||
- name: Publish Android to Play Store
|
- name: Publish Android to Play Store
|
||||||
|
if: ${{ secrets.PLAY_STORE_CONFIG_JSON != '' }}
|
||||||
env:
|
env:
|
||||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||||
@@ -73,14 +122,41 @@ jobs:
|
|||||||
DAGGER_NO_NAG: "1"
|
DAGGER_NO_NAG: "1"
|
||||||
run: task publish-android
|
run: task publish-android
|
||||||
|
|
||||||
|
- name: Cleanup TLS credentials
|
||||||
|
if: always()
|
||||||
|
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
||||||
|
|
||||||
|
deploy-apk:
|
||||||
|
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
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Check runner tools
|
||||||
|
run: |
|
||||||
|
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||||
|
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||||
|
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
|
||||||
|
|
||||||
|
- name: Setup Dagger Remote Engine (via stunnel)
|
||||||
|
env:
|
||||||
|
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
|
||||||
|
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
|
||||||
|
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
|
||||||
|
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
|
||||||
|
run: scripts/setup_dagger_remote.sh
|
||||||
|
|
||||||
- name: Build & Deploy APK to server
|
- name: Build & Deploy APK to server
|
||||||
# continue-on-error: step requires SSH_PRIVATE_KEY secret; if unset the task
|
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
|
||||||
# precondition fails, but we don't want that to fail the whole job — the Play
|
|
||||||
# Store publish above already succeeded. The overall job stays green even
|
|
||||||
# though this step shows as failed/orange in the UI.
|
|
||||||
continue-on-error: true
|
|
||||||
env:
|
env:
|
||||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
|
SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
|
||||||
SSH_USER: ${{ secrets.SSH_USER }}
|
SSH_USER: ${{ secrets.SSH_USER }}
|
||||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||||
@@ -96,11 +172,13 @@ jobs:
|
|||||||
name: Build Linux Release
|
name: Build Linux Release
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
|
needs: [check-changes]
|
||||||
|
if: needs.check-changes.outputs.linux == 'true'
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 50
|
fetch-depth: 1
|
||||||
|
|
||||||
- name: Check runner tools
|
- name: Check runner tools
|
||||||
run: |
|
run: |
|
||||||
@@ -117,14 +195,10 @@ jobs:
|
|||||||
run: scripts/setup_dagger_remote.sh
|
run: scripts/setup_dagger_remote.sh
|
||||||
|
|
||||||
- name: Build & Deploy Linux to server
|
- name: Build & Deploy Linux to server
|
||||||
# continue-on-error: step requires SSH_PRIVATE_KEY secret; if unset the task
|
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
|
||||||
# precondition fails, but the build step that precedes this (done via Dagger)
|
|
||||||
# already succeeded. Deployment is best-effort; a missing secret should not
|
|
||||||
# turn the job red. The step will show as failed/orange in the UI even though
|
|
||||||
# the overall job is green — this is intentional.
|
|
||||||
continue-on-error: true
|
|
||||||
env:
|
env:
|
||||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
|
SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
|
||||||
SSH_USER: ${{ secrets.SSH_USER }}
|
SSH_USER: ${{ secrets.SSH_USER }}
|
||||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||||
DAGGER_NO_NAG: "1"
|
DAGGER_NO_NAG: "1"
|
||||||
@@ -137,16 +211,16 @@ jobs:
|
|||||||
publish-website:
|
publish-website:
|
||||||
name: Publish Website Build History
|
name: Publish Website Build History
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [build-linux, deploy-playstore]
|
needs: [build-linux, deploy-playstore, deploy-apk]
|
||||||
if: |
|
if: |
|
||||||
always() &&
|
always() &&
|
||||||
(needs.build-linux.result == 'success' || needs.deploy-playstore.result == 'success')
|
(needs.build-linux.result == 'success' || needs.deploy-playstore.result == 'success' || needs.deploy-apk.result == 'success')
|
||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 50
|
fetch-depth: 1
|
||||||
|
|
||||||
- name: Check runner tools
|
- name: Check runner tools
|
||||||
run: |
|
run: |
|
||||||
@@ -163,11 +237,10 @@ jobs:
|
|||||||
run: scripts/setup_dagger_remote.sh
|
run: scripts/setup_dagger_remote.sh
|
||||||
|
|
||||||
- name: Generate build history and deploy website
|
- name: Generate build history and deploy website
|
||||||
# continue-on-error: website publish is best-effort; a missing SSH_PRIVATE_KEY
|
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
|
||||||
# should not block the overall workflow status.
|
|
||||||
continue-on-error: true
|
|
||||||
env:
|
env:
|
||||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
|
SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
|
||||||
SSH_USER: ${{ secrets.SSH_USER }}
|
SSH_USER: ${{ secrets.SSH_USER }}
|
||||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||||
DAGGER_NO_NAG: "1"
|
DAGGER_NO_NAG: "1"
|
||||||
@@ -180,8 +253,14 @@ jobs:
|
|||||||
label-deploy-health:
|
label-deploy-health:
|
||||||
name: Update Deploy Health Label
|
name: Update Deploy Health Label
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [test-android-firebase, deploy-playstore, build-linux]
|
needs: [test-android-firebase, deploy-playstore, deploy-apk, build-linux]
|
||||||
if: always() && vars.DEPLOY_HEALTH_ISSUE != ''
|
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'
|
||||||
|
)
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -190,7 +269,7 @@ jobs:
|
|||||||
FORGEJO_TOKEN: ${{ github.token }}
|
FORGEJO_TOKEN: ${{ github.token }}
|
||||||
FORGEJO_URL: ${{ github.server_url }}
|
FORGEJO_URL: ${{ github.server_url }}
|
||||||
DEPLOY_HEALTH_ISSUE: ${{ vars.DEPLOY_HEALTH_ISSUE }}
|
DEPLOY_HEALTH_ISSUE: ${{ vars.DEPLOY_HEALTH_ISSUE }}
|
||||||
ALL_SUCCEEDED: ${{ needs.test-android-firebase.result == 'success' && needs.deploy-playstore.result == 'success' && needs.build-linux.result == 'success' }}
|
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') }}
|
||||||
run: |
|
run: |
|
||||||
python3 - << 'PYEOF'
|
python3 - << 'PYEOF'
|
||||||
import os, json, urllib.request, urllib.error
|
import os, json, urllib.request, urllib.error
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ jobs:
|
|||||||
name: Build & Deploy Windows (Nightly)
|
name: Build & Deploy Windows (Nightly)
|
||||||
runs-on: windows-runner
|
runs-on: windows-runner
|
||||||
if: false
|
if: false
|
||||||
continue-on-error: true
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -32,7 +31,6 @@ jobs:
|
|||||||
|
|
||||||
- name: Set up SSH key
|
- name: Set up SSH key
|
||||||
if: env.SKIP_BUILD != 'true'
|
if: env.SKIP_BUILD != 'true'
|
||||||
continue-on-error: true
|
|
||||||
env:
|
env:
|
||||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
run: |
|
run: |
|
||||||
@@ -42,7 +40,6 @@ jobs:
|
|||||||
|
|
||||||
- name: Deploy Windows to server
|
- name: Deploy Windows to server
|
||||||
if: env.SKIP_BUILD != 'true'
|
if: env.SKIP_BUILD != 'true'
|
||||||
continue-on-error: true
|
|
||||||
env:
|
env:
|
||||||
SSH_USER: ${{ secrets.SSH_USER }}
|
SSH_USER: ${{ secrets.SSH_USER }}
|
||||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||||
|
|||||||
@@ -202,6 +202,8 @@ jobs:
|
|||||||
mkdir -p ~/.ssh
|
mkdir -p ~/.ssh
|
||||||
printf '%s\n' "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
|
printf '%s\n' "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
|
||||||
chmod 600 ~/.ssh/id_ed25519
|
chmod 600 ~/.ssh/id_ed25519
|
||||||
|
printf '%s\n' "${{ secrets.SSH_KNOWN_HOSTS }}" >> ~/.ssh/known_hosts
|
||||||
|
chmod 644 ~/.ssh/known_hosts
|
||||||
|
|
||||||
- name: Build Linux release
|
- name: Build Linux release
|
||||||
run: |
|
run: |
|
||||||
@@ -215,20 +217,20 @@ jobs:
|
|||||||
REMOTE_DIR="public_html/builds/$DATE_PATH"
|
REMOTE_DIR="public_html/builds/$DATE_PATH"
|
||||||
TARBALL="sharedinbox-linux-amd64-$HASH.tar.gz"
|
TARBALL="sharedinbox-linux-amd64-$HASH.tar.gz"
|
||||||
tar -czf /tmp/$TARBALL -C build/linux/x64/release bundle
|
tar -czf /tmp/$TARBALL -C build/linux/x64/release bundle
|
||||||
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
|
ssh "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
|
||||||
scp -o StrictHostKeyChecking=no /tmp/$TARBALL "$SSH_USER@$SSH_HOST:$REMOTE_DIR/$TARBALL"
|
scp /tmp/$TARBALL "$SSH_USER@$SSH_HOST:$REMOTE_DIR/$TARBALL"
|
||||||
DOWNLOAD_URL="https://sharedinbox.de/builds/$DATE_PATH/$TARBALL"
|
DOWNLOAD_URL="https://sharedinbox.de/builds/$DATE_PATH/$TARBALL"
|
||||||
EXISTING=$(ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" \
|
EXISTING=$(ssh "$SSH_USER@$SSH_HOST" \
|
||||||
"cat public_html/latest.json 2>/dev/null || echo '{}'")
|
"cat public_html/latest.json 2>/dev/null || echo '{}'")
|
||||||
WINDOWS_URL=$(echo "$EXISTING" | \
|
WINDOWS_URL=$(echo "$EXISTING" | \
|
||||||
python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('windows',''))" \
|
python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('windows',''))" \
|
||||||
2>/dev/null || true)
|
2>/dev/null || true)
|
||||||
if [ -n "$WINDOWS_URL" ]; then
|
if [ -n "$WINDOWS_URL" ]; then
|
||||||
echo "{\"version\":\"$HASH\",\"linux\":\"$DOWNLOAD_URL\",\"windows\":\"$WINDOWS_URL\"}" | \
|
echo "{\"version\":\"$HASH\",\"linux\":\"$DOWNLOAD_URL\",\"windows\":\"$WINDOWS_URL\"}" | \
|
||||||
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
ssh "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
||||||
else
|
else
|
||||||
echo "{\"version\":\"$HASH\",\"linux\":\"$DOWNLOAD_URL\"}" | \
|
echo "{\"version\":\"$HASH\",\"linux\":\"$DOWNLOAD_URL\"}" | \
|
||||||
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
ssh "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Generate build history pages
|
- name: Generate build history pages
|
||||||
@@ -244,6 +246,5 @@ jobs:
|
|||||||
rsync -avz --delete \
|
rsync -avz --delete \
|
||||||
--exclude='*.apk' \
|
--exclude='*.apk' \
|
||||||
--exclude='*.tar.gz' \
|
--exclude='*.tar.gz' \
|
||||||
-e "ssh -o StrictHostKeyChecking=no" \
|
|
||||||
website/public/ \
|
website/public/ \
|
||||||
"$SSH_USER@$SSH_HOST:public_html/"
|
"$SSH_USER@$SSH_HOST:public_html/"
|
||||||
|
|||||||
+53
-17
@@ -215,8 +215,10 @@ tasks:
|
|||||||
preconditions:
|
preconditions:
|
||||||
- sh: test -n "$SSH_PRIVATE_KEY"
|
- sh: test -n "$SSH_PRIVATE_KEY"
|
||||||
msg: "SSH_PRIVATE_KEY is not set"
|
msg: "SSH_PRIVATE_KEY is not set"
|
||||||
|
- sh: test -n "$SSH_KNOWN_HOSTS"
|
||||||
|
msg: "SSH_KNOWN_HOSTS is not set"
|
||||||
cmds:
|
cmds:
|
||||||
- HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. deploy-linux --ssh-key env:SSH_PRIVATE_KEY --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH"
|
- HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. deploy-linux --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH"
|
||||||
|
|
||||||
build-android-bundle:
|
build-android-bundle:
|
||||||
desc: Build AAB via Dagger (cached, versionCode=1 placeholder) and export locally
|
desc: Build AAB via Dagger (cached, versionCode=1 placeholder) and export locally
|
||||||
@@ -251,17 +253,24 @@ tasks:
|
|||||||
preconditions:
|
preconditions:
|
||||||
- sh: test -n "$SSH_PRIVATE_KEY"
|
- sh: test -n "$SSH_PRIVATE_KEY"
|
||||||
msg: "SSH_PRIVATE_KEY is not set"
|
msg: "SSH_PRIVATE_KEY is not set"
|
||||||
|
- sh: test -n "$SSH_KNOWN_HOSTS"
|
||||||
|
msg: "SSH_KNOWN_HOSTS is not set"
|
||||||
- sh: test -n "$ANDROID_KEYSTORE_BASE64"
|
- sh: test -n "$ANDROID_KEYSTORE_BASE64"
|
||||||
msg: "ANDROID_KEYSTORE_BASE64 is not set"
|
msg: "ANDROID_KEYSTORE_BASE64 is not set"
|
||||||
- sh: test -n "$ANDROID_KEYSTORE_PASSWORD"
|
- sh: test -n "$ANDROID_KEYSTORE_PASSWORD"
|
||||||
msg: "ANDROID_KEYSTORE_PASSWORD is not set"
|
msg: "ANDROID_KEYSTORE_PASSWORD is not set"
|
||||||
cmds:
|
cmds:
|
||||||
- HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. deploy-apk --ssh-key env:SSH_PRIVATE_KEY --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH" --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD --build-number "$(git log -1 --format=%ct HEAD)"
|
- HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. deploy-apk --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH" --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD --build-number "$(git log -1 --format=%ct HEAD)"
|
||||||
|
|
||||||
publish-website:
|
publish-website:
|
||||||
desc: Build and publish website via Dagger
|
desc: Build and publish website via Dagger
|
||||||
|
preconditions:
|
||||||
|
- sh: test -n "$SSH_PRIVATE_KEY"
|
||||||
|
msg: "SSH_PRIVATE_KEY is not set"
|
||||||
|
- sh: test -n "$SSH_KNOWN_HOSTS"
|
||||||
|
msg: "SSH_KNOWN_HOSTS is not set"
|
||||||
cmds:
|
cmds:
|
||||||
- dagger call --progress=plain -q -m ci --source=. publish-website --ssh-key file:$HOME/.ssh/id_ed25519 --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST"
|
- dagger call --progress=plain -q -m ci --source=. publish-website --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST"
|
||||||
|
|
||||||
check-dagger:
|
check-dagger:
|
||||||
desc: Run full check suite via Dagger (with OTEL timing report if python3 is available)
|
desc: Run full check suite via Dagger (with OTEL timing report if python3 is available)
|
||||||
@@ -284,8 +293,13 @@ tasks:
|
|||||||
for attempt in 1 2 3; do
|
for attempt in 1 2 3; do
|
||||||
run_dagger "$@" && return 0
|
run_dagger "$@" && return 0
|
||||||
RC=$?
|
RC=$?
|
||||||
if [ "$attempt" -lt 3 ] && grep -qE "connection reset|context canceled|connection refused" "$DAGGER_OUT"; then
|
if [ "$attempt" -lt 3 ] && grep -qE "connection reset|context canceled|connection refused|invalid return status code" "$DAGGER_OUT"; then
|
||||||
echo "$(_ts) dagger: network error on attempt $attempt/3, retrying..." >&2
|
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
|
||||||
|
echo "$(_ts) dagger: waiting 90s for freed space to settle..." >&2
|
||||||
|
sleep 90
|
||||||
else
|
else
|
||||||
return "$RC"
|
return "$RC"
|
||||||
fi
|
fi
|
||||||
@@ -315,6 +329,12 @@ tasks:
|
|||||||
wait "$RECV_PID" 2>/dev/null || true
|
wait "$RECV_PID" 2>/dev/null || true
|
||||||
exit $RC
|
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:
|
integration-android:
|
||||||
desc: UI integration tests on a connected Android emulator (Stalwart on host, emulator reaches it via 10.0.2.2)
|
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]
|
deps: [_preflight, _android-sdk-check, _android-avd-setup]
|
||||||
@@ -362,25 +382,29 @@ tasks:
|
|||||||
msg: "SSH_USER is not set"
|
msg: "SSH_USER is not set"
|
||||||
- sh: test -n "$SSH_HOST"
|
- sh: test -n "$SSH_HOST"
|
||||||
msg: "SSH_HOST is not set"
|
msg: "SSH_HOST is not set"
|
||||||
|
- sh: test -n "$SSH_KNOWN_HOSTS"
|
||||||
|
msg: "SSH_KNOWN_HOSTS is not set"
|
||||||
cmds:
|
cmds:
|
||||||
- |
|
- |
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
printf '%s\n' "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
|
||||||
HASH=$(git rev-parse --short HEAD)
|
HASH=$(git rev-parse --short HEAD)
|
||||||
DATE_PATH=$(date -u +%Y/%m/%d)
|
DATE_PATH=$(date -u +%Y/%m/%d)
|
||||||
REMOTE_DIR="public_html/builds/$DATE_PATH"
|
REMOTE_DIR="public_html/builds/$DATE_PATH"
|
||||||
TARBALL="sharedinbox-linux-amd64-$HASH.tar.gz"
|
TARBALL="sharedinbox-linux-amd64-$HASH.tar.gz"
|
||||||
tar -czf /tmp/$TARBALL -C build/linux/x64/release bundle
|
tar -czf /tmp/$TARBALL -C build/linux/x64/release bundle
|
||||||
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
|
ssh "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
|
||||||
scp -o StrictHostKeyChecking=no /tmp/$TARBALL "$SSH_USER@$SSH_HOST:$REMOTE_DIR/$TARBALL"
|
scp /tmp/$TARBALL "$SSH_USER@$SSH_HOST:$REMOTE_DIR/$TARBALL"
|
||||||
DOWNLOAD_URL="https://sharedinbox.de/builds/$DATE_PATH/$TARBALL"
|
DOWNLOAD_URL="https://sharedinbox.de/builds/$DATE_PATH/$TARBALL"
|
||||||
# Merge with any existing latest.json so we don't overwrite the windows key
|
# Merge with any existing latest.json so we don't overwrite the windows key
|
||||||
EXISTING=$(ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat public_html/latest.json 2>/dev/null || echo '{}'")
|
EXISTING=$(ssh "$SSH_USER@$SSH_HOST" "cat public_html/latest.json 2>/dev/null || echo '{}'")
|
||||||
WINDOWS_URL=$(echo "$EXISTING" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('windows',''))" 2>/dev/null || true)
|
WINDOWS_URL=$(echo "$EXISTING" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('windows',''))" 2>/dev/null || true)
|
||||||
if [ -n "$WINDOWS_URL" ]; then
|
if [ -n "$WINDOWS_URL" ]; then
|
||||||
echo "{\"version\":\"$HASH\",\"linux\":\"$DOWNLOAD_URL\",\"windows\":\"$WINDOWS_URL\"}" | \
|
echo "{\"version\":\"$HASH\",\"linux\":\"$DOWNLOAD_URL\",\"windows\":\"$WINDOWS_URL\"}" | \
|
||||||
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
ssh "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
||||||
else
|
else
|
||||||
echo "{\"version\":\"$HASH\",\"linux\":\"$DOWNLOAD_URL\"}" | \
|
echo "{\"version\":\"$HASH\",\"linux\":\"$DOWNLOAD_URL\"}" | \
|
||||||
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
ssh "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
||||||
fi
|
fi
|
||||||
echo "Uploaded $TARBALL and updated latest.json"
|
echo "Uploaded $TARBALL and updated latest.json"
|
||||||
|
|
||||||
@@ -405,24 +429,28 @@ tasks:
|
|||||||
msg: "SSH_USER is not set"
|
msg: "SSH_USER is not set"
|
||||||
- sh: test -n "$SSH_HOST"
|
- sh: test -n "$SSH_HOST"
|
||||||
msg: "SSH_HOST is not set"
|
msg: "SSH_HOST is not set"
|
||||||
|
- sh: test -n "$SSH_KNOWN_HOSTS"
|
||||||
|
msg: "SSH_KNOWN_HOSTS is not set"
|
||||||
cmds:
|
cmds:
|
||||||
- |
|
- |
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
printf '%s\n' "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
|
||||||
HASH=$(git rev-parse --short HEAD)
|
HASH=$(git rev-parse --short HEAD)
|
||||||
DATE_PATH=$(date -u +%Y/%m/%d)
|
DATE_PATH=$(date -u +%Y/%m/%d)
|
||||||
REMOTE_DIR="public_html/builds/$DATE_PATH"
|
REMOTE_DIR="public_html/builds/$DATE_PATH"
|
||||||
ZIPFILE="sharedinbox-windows-x64-$HASH.zip"
|
ZIPFILE="sharedinbox-windows-x64-$HASH.zip"
|
||||||
cd build/windows/x64/runner && zip -r /tmp/$ZIPFILE Release/ && cd -
|
cd build/windows/x64/runner && zip -r /tmp/$ZIPFILE Release/ && cd -
|
||||||
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
|
ssh "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
|
||||||
scp -o StrictHostKeyChecking=no /tmp/$ZIPFILE "$SSH_USER@$SSH_HOST:$REMOTE_DIR/$ZIPFILE"
|
scp /tmp/$ZIPFILE "$SSH_USER@$SSH_HOST:$REMOTE_DIR/$ZIPFILE"
|
||||||
DOWNLOAD_URL="https://sharedinbox.de/builds/$DATE_PATH/$ZIPFILE"
|
DOWNLOAD_URL="https://sharedinbox.de/builds/$DATE_PATH/$ZIPFILE"
|
||||||
EXISTING=$(ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat public_html/latest.json 2>/dev/null || echo '{}'")
|
EXISTING=$(ssh "$SSH_USER@$SSH_HOST" "cat public_html/latest.json 2>/dev/null || echo '{}'")
|
||||||
LINUX_URL=$(echo "$EXISTING" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('linux',''))" 2>/dev/null || true)
|
LINUX_URL=$(echo "$EXISTING" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('linux',''))" 2>/dev/null || true)
|
||||||
if [ -n "$LINUX_URL" ]; then
|
if [ -n "$LINUX_URL" ]; then
|
||||||
echo "{\"version\":\"$HASH\",\"linux\":\"$LINUX_URL\",\"windows\":\"$DOWNLOAD_URL\"}" | \
|
echo "{\"version\":\"$HASH\",\"linux\":\"$LINUX_URL\",\"windows\":\"$DOWNLOAD_URL\"}" | \
|
||||||
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
ssh "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
||||||
else
|
else
|
||||||
echo "{\"version\":\"$HASH\",\"windows\":\"$DOWNLOAD_URL\"}" | \
|
echo "{\"version\":\"$HASH\",\"windows\":\"$DOWNLOAD_URL\"}" | \
|
||||||
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
ssh "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
||||||
fi
|
fi
|
||||||
echo "Uploaded $ZIPFILE and updated latest.json"
|
echo "Uploaded $ZIPFILE and updated latest.json"
|
||||||
|
|
||||||
@@ -572,14 +600,18 @@ tasks:
|
|||||||
msg: "SSH_USER is not set"
|
msg: "SSH_USER is not set"
|
||||||
- sh: test -n "$SSH_HOST"
|
- sh: test -n "$SSH_HOST"
|
||||||
msg: "SSH_HOST is not set"
|
msg: "SSH_HOST is not set"
|
||||||
|
- sh: test -n "$SSH_KNOWN_HOSTS"
|
||||||
|
msg: "SSH_KNOWN_HOSTS is not set"
|
||||||
cmds:
|
cmds:
|
||||||
- |
|
- |
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
printf '%s\n' "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
|
||||||
HASH=$(git rev-parse --short HEAD)
|
HASH=$(git rev-parse --short HEAD)
|
||||||
DATE_PATH=$(date -u +%Y/%m/%d)
|
DATE_PATH=$(date -u +%Y/%m/%d)
|
||||||
REMOTE_DIR="public_html/builds/$DATE_PATH"
|
REMOTE_DIR="public_html/builds/$DATE_PATH"
|
||||||
APK_NAME="sharedinbox-mua-$HASH.apk"
|
APK_NAME="sharedinbox-mua-$HASH.apk"
|
||||||
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
|
ssh "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
|
||||||
scp -o StrictHostKeyChecking=no \
|
scp \
|
||||||
build/app/outputs/flutter-apk/app-release.apk \
|
build/app/outputs/flutter-apk/app-release.apk \
|
||||||
"$SSH_USER@$SSH_HOST:$REMOTE_DIR/$APK_NAME"
|
"$SSH_USER@$SSH_HOST:$REMOTE_DIR/$APK_NAME"
|
||||||
echo "Uploaded $APK_NAME to $REMOTE_DIR"
|
echo "Uploaded $APK_NAME to $REMOTE_DIR"
|
||||||
@@ -608,12 +640,16 @@ tasks:
|
|||||||
website-deploy:
|
website-deploy:
|
||||||
desc: Deploy the website via rsync to public_html
|
desc: Deploy the website via rsync to public_html
|
||||||
deps: [website-build]
|
deps: [website-build]
|
||||||
|
preconditions:
|
||||||
|
- sh: test -n "$SSH_KNOWN_HOSTS"
|
||||||
|
msg: "SSH_KNOWN_HOSTS is not set"
|
||||||
cmds:
|
cmds:
|
||||||
- |
|
- |
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
printf '%s\n' "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
|
||||||
rsync -avz --delete \
|
rsync -avz --delete \
|
||||||
--exclude='*.apk' \
|
--exclude='*.apk' \
|
||||||
--exclude='*.tar.gz' \
|
--exclude='*.tar.gz' \
|
||||||
-e "ssh -o StrictHostKeyChecking=no" \
|
|
||||||
website/public/ \
|
website/public/ \
|
||||||
${SSH_USER}@${SSH_HOST}:public_html/
|
${SSH_USER}@${SSH_HOST}:public_html/
|
||||||
|
|
||||||
|
|||||||
+38
-22
@@ -221,7 +221,7 @@ func (m *Ci) pubGetLayer() *dagger.Container {
|
|||||||
WithExec([]string{"/bin/bash", "-c",
|
WithExec([]string{"/bin/bash", "-c",
|
||||||
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
|
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
|
||||||
`flutter pub get >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
|
`flutter pub get >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
|
||||||
`grep -vE '^[+~><] ' "$tmp" || true`}).
|
`grep -vE '^(\+|Downloading packages)' "$tmp" || true`}).
|
||||||
WithExec([]string{"python3", "-c",
|
WithExec([]string{"python3", "-c",
|
||||||
"import json, os\n" +
|
"import json, os\n" +
|
||||||
"f='.dart_tool/package_config.json'; d=json.load(open(f)); [d.pop(k,None) for k in ('generated','generatorVersion')]; json.dump(d,open(f,'w'))\n" +
|
"f='.dart_tool/package_config.json'; d=json.load(open(f)); [d.pop(k,None) for k in ('generated','generatorVersion')]; json.dump(d,open(f,'w'))\n" +
|
||||||
@@ -245,7 +245,7 @@ func (m *Ci) codegenBase() *dagger.Container {
|
|||||||
WithExec([]string{"/bin/bash", "-c",
|
WithExec([]string{"/bin/bash", "-c",
|
||||||
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
|
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
|
||||||
`flutter pub run build_runner build --delete-conflicting-outputs >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
|
`flutter pub run build_runner build --delete-conflicting-outputs >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
|
||||||
`grep -vE '^\[' "$tmp" || true`})
|
`grep -vE '^\[.*s\] \|' "$tmp" || true`})
|
||||||
}
|
}
|
||||||
|
|
||||||
// setup overlays platform-specific source files onto the shared codegen base.
|
// setup overlays platform-specific source files onto the shared codegen base.
|
||||||
@@ -312,17 +312,19 @@ func (m *Ci) Hugo() *dagger.Container {
|
|||||||
From("alpine:3.21").
|
From("alpine:3.21").
|
||||||
WithExec([]string{"apk", "--no-cache", "add", "curl", "tar", "libc6-compat", "libstdc++", "gcompat"}).
|
WithExec([]string{"apk", "--no-cache", "add", "curl", "tar", "libc6-compat", "libstdc++", "gcompat"}).
|
||||||
WithExec([]string{"curl", "-sL", "https://github.com/gohugoio/hugo/releases/download/v0.152.2/hugo_extended_0.152.2_linux-amd64.tar.gz", "-o", "/tmp/hugo.tar.gz"}).
|
WithExec([]string{"curl", "-sL", "https://github.com/gohugoio/hugo/releases/download/v0.152.2/hugo_extended_0.152.2_linux-amd64.tar.gz", "-o", "/tmp/hugo.tar.gz"}).
|
||||||
|
WithExec([]string{"sh", "-c", "echo '416bcfbdf5f68469ec9644dbe507da50fc21b94b69a125b059d64ed2cb4d8c27 /tmp/hugo.tar.gz' | sha256sum -c -"}).
|
||||||
WithExec([]string{"tar", "-xzf", "/tmp/hugo.tar.gz", "-C", "/usr/local/bin", "hugo"}).
|
WithExec([]string{"tar", "-xzf", "/tmp/hugo.tar.gz", "-C", "/usr/local/bin", "hugo"}).
|
||||||
WithExec([]string{"rm", "/tmp/hugo.tar.gz"})
|
WithExec([]string{"rm", "/tmp/hugo.tar.gz"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deploy container for rsync/ssh
|
// Deploy container for rsync/ssh
|
||||||
func (m *Ci) Deployer(sshKey *dagger.Secret) *dagger.Container {
|
func (m *Ci) Deployer(sshKey *dagger.Secret, knownHosts *dagger.Secret) *dagger.Container {
|
||||||
return dag.Container().
|
return dag.Container().
|
||||||
From("alpine:3.21").
|
From("alpine:3.21").
|
||||||
WithExec([]string{"apk", "--no-cache", "add", "rsync", "openssh-client", "python3", "tar"}).
|
WithExec([]string{"apk", "--no-cache", "add", "rsync", "openssh-client", "python3", "tar"}).
|
||||||
WithMountedSecret("/root/.ssh/id_ed25519", sshKey, dagger.ContainerWithMountedSecretOpts{Mode: 0600}).
|
WithMountedSecret("/root/.ssh/id_ed25519", sshKey, dagger.ContainerWithMountedSecretOpts{Mode: 0600}).
|
||||||
WithEnvVariable("RSYNC_RSH", "ssh -o StrictHostKeyChecking=no -i /root/.ssh/id_ed25519")
|
WithMountedSecret("/root/.ssh/known_hosts", knownHosts, dagger.ContainerWithMountedSecretOpts{Mode: 0644}).
|
||||||
|
WithEnvVariable("RSYNC_RSH", "ssh -i /root/.ssh/id_ed25519")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stalwart mail server service for backend and integration tests.
|
// Stalwart mail server service for backend and integration tests.
|
||||||
@@ -410,7 +412,7 @@ func (m *Ci) CheckMocks(ctx context.Context) (string, error) {
|
|||||||
WithExec([]string{"/bin/bash", "-c",
|
WithExec([]string{"/bin/bash", "-c",
|
||||||
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
|
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
|
||||||
`flutter pub run build_runner build --delete-conflicting-outputs >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
|
`flutter pub run build_runner build --delete-conflicting-outputs >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
|
||||||
`grep -vE '^\[' "$tmp" || true`}).
|
`grep -vE '^\[.*s\] \|' "$tmp" || true`}).
|
||||||
WithExec([]string{"/bin/bash", "-c", "CHANGED=$(find . -name '*.mocks.dart' | xargs -r git diff --exit-code); if [ $? -ne 0 ]; then echo \"ERROR: Mocks are out of date\"; exit 1; fi; echo \"Mocks are up to date.\""}).
|
WithExec([]string{"/bin/bash", "-c", "CHANGED=$(find . -name '*.mocks.dart' | xargs -r git diff --exit-code); if [ $? -ne 0 ]; then echo \"ERROR: Mocks are out of date\"; exit 1; fi; echo \"Mocks are up to date.\""}).
|
||||||
Stdout(ctx)
|
Stdout(ctx)
|
||||||
}
|
}
|
||||||
@@ -513,6 +515,7 @@ func (m *Ci) Check(ctx context.Context) (string, error) {
|
|||||||
func (m *Ci) GenerateBuildHistory(
|
func (m *Ci) GenerateBuildHistory(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
sshKey *dagger.Secret,
|
sshKey *dagger.Secret,
|
||||||
|
knownHosts *dagger.Secret,
|
||||||
sshUser string,
|
sshUser string,
|
||||||
sshHost string,
|
sshHost string,
|
||||||
) *dagger.Directory {
|
) *dagger.Directory {
|
||||||
@@ -524,7 +527,7 @@ func (m *Ci) GenerateBuildHistory(
|
|||||||
From("python:3.12-alpine").
|
From("python:3.12-alpine").
|
||||||
WithExec([]string{"apk", "add", "--no-cache", "openssh-client"}).
|
WithExec([]string{"apk", "add", "--no-cache", "openssh-client"}).
|
||||||
WithMountedSecret("/root/.ssh/id_ed25519", sshKey, dagger.ContainerWithMountedSecretOpts{Mode: 0600}).
|
WithMountedSecret("/root/.ssh/id_ed25519", sshKey, dagger.ContainerWithMountedSecretOpts{Mode: 0600}).
|
||||||
WithExec([]string{"chmod", "700", "/root/.ssh"}).
|
WithMountedSecret("/root/.ssh/known_hosts", knownHosts, dagger.ContainerWithMountedSecretOpts{Mode: 0644}).
|
||||||
WithEnvVariable("SSH_USER", sshUser).
|
WithEnvVariable("SSH_USER", sshUser).
|
||||||
WithEnvVariable("SSH_HOST", sshHost).
|
WithEnvVariable("SSH_HOST", sshHost).
|
||||||
WithDirectory("/src", scriptSource).
|
WithDirectory("/src", scriptSource).
|
||||||
@@ -537,10 +540,11 @@ func (m *Ci) GenerateBuildHistory(
|
|||||||
func (m *Ci) BuildWebsite(
|
func (m *Ci) BuildWebsite(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
sshKey *dagger.Secret,
|
sshKey *dagger.Secret,
|
||||||
|
knownHosts *dagger.Secret,
|
||||||
sshUser string,
|
sshUser string,
|
||||||
sshHost string,
|
sshHost string,
|
||||||
) *dagger.Directory {
|
) *dagger.Directory {
|
||||||
buildHistory := m.GenerateBuildHistory(ctx, sshKey, sshUser, sshHost)
|
buildHistory := m.GenerateBuildHistory(ctx, sshKey, knownHosts, sshUser, sshHost)
|
||||||
|
|
||||||
websiteSource := m.Source.Filter(dagger.DirectoryFilterOpts{
|
websiteSource := m.Source.Filter(dagger.DirectoryFilterOpts{
|
||||||
Include: []string{"website/"},
|
Include: []string{"website/"},
|
||||||
@@ -557,12 +561,13 @@ func (m *Ci) BuildWebsite(
|
|||||||
func (m *Ci) PublishWebsite(
|
func (m *Ci) PublishWebsite(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
sshKey *dagger.Secret,
|
sshKey *dagger.Secret,
|
||||||
|
knownHosts *dagger.Secret,
|
||||||
sshUser string,
|
sshUser string,
|
||||||
sshHost string,
|
sshHost string,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
public := m.BuildWebsite(ctx, sshKey, sshUser, sshHost)
|
public := m.BuildWebsite(ctx, sshKey, knownHosts, sshUser, sshHost)
|
||||||
|
|
||||||
return m.Deployer(sshKey).
|
return m.Deployer(sshKey, knownHosts).
|
||||||
WithDirectory("/public", public).
|
WithDirectory("/public", public).
|
||||||
WithExec([]string{"rsync", "-avz", "--delete",
|
WithExec([]string{"rsync", "-avz", "--delete",
|
||||||
"--exclude=*.apk", "--exclude=*.tar.gz",
|
"--exclude=*.apk", "--exclude=*.tar.gz",
|
||||||
@@ -588,6 +593,7 @@ func (m *Ci) BuildLinuxRelease() *dagger.Directory {
|
|||||||
func (m *Ci) DeployLinux(
|
func (m *Ci) DeployLinux(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
sshKey *dagger.Secret,
|
sshKey *dagger.Secret,
|
||||||
|
knownHosts *dagger.Secret,
|
||||||
sshUser string,
|
sshUser string,
|
||||||
sshHost string,
|
sshHost string,
|
||||||
commitHash string,
|
commitHash string,
|
||||||
@@ -598,11 +604,11 @@ func (m *Ci) DeployLinux(
|
|||||||
remoteDir := fmt.Sprintf("public_html/builds/%s", datePath)
|
remoteDir := fmt.Sprintf("public_html/builds/%s", datePath)
|
||||||
tarball := fmt.Sprintf("sharedinbox-linux-amd64-%s.tar.gz", commitHash)
|
tarball := fmt.Sprintf("sharedinbox-linux-amd64-%s.tar.gz", commitHash)
|
||||||
|
|
||||||
return m.Deployer(sshKey).
|
return m.Deployer(sshKey, knownHosts).
|
||||||
WithDirectory("/bundle", bundle).
|
WithDirectory("/bundle", bundle).
|
||||||
WithExec([]string{"/bin/sh", "-c", fmt.Sprintf("tar -czf /tmp/%s -C /bundle .", tarball)}).
|
WithExec([]string{"/bin/sh", "-c", fmt.Sprintf("tar -czf /tmp/%s -C /bundle .", tarball)}).
|
||||||
WithExec([]string{"ssh", "-o", "StrictHostKeyChecking=no", "-i", "/root/.ssh/id_ed25519", fmt.Sprintf("%s@%s", sshUser, sshHost), fmt.Sprintf("mkdir -p %s", remoteDir)}).
|
WithExec([]string{"ssh", "-i", "/root/.ssh/id_ed25519", fmt.Sprintf("%s@%s", sshUser, sshHost), fmt.Sprintf("mkdir -p %s", remoteDir)}).
|
||||||
WithExec([]string{"/bin/sh", "-c", fmt.Sprintf("scp -o StrictHostKeyChecking=no -i /root/.ssh/id_ed25519 /tmp/%s %s@%s:%s/%s", tarball, sshUser, sshHost, remoteDir, tarball)}).
|
WithExec([]string{"/bin/sh", "-c", fmt.Sprintf("scp -i /root/.ssh/id_ed25519 /tmp/%s %s@%s:%s/%s", tarball, sshUser, sshHost, remoteDir, tarball)}).
|
||||||
Stdout(ctx)
|
Stdout(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -625,6 +631,7 @@ func (m *Ci) BuildAndroidApk(keystoreBase64 *dagger.Secret, keystorePassword *da
|
|||||||
func (m *Ci) DeployApk(
|
func (m *Ci) DeployApk(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
sshKey *dagger.Secret,
|
sshKey *dagger.Secret,
|
||||||
|
knownHosts *dagger.Secret,
|
||||||
sshUser string,
|
sshUser string,
|
||||||
sshHost string,
|
sshHost string,
|
||||||
commitHash string,
|
commitHash string,
|
||||||
@@ -638,10 +645,10 @@ func (m *Ci) DeployApk(
|
|||||||
remoteDir := fmt.Sprintf("public_html/builds/%s", datePath)
|
remoteDir := fmt.Sprintf("public_html/builds/%s", datePath)
|
||||||
apkName := fmt.Sprintf("sharedinbox-mua-%s.apk", commitHash)
|
apkName := fmt.Sprintf("sharedinbox-mua-%s.apk", commitHash)
|
||||||
|
|
||||||
return m.Deployer(sshKey).
|
return m.Deployer(sshKey, knownHosts).
|
||||||
WithFile("/tmp/app.apk", apk).
|
WithFile("/tmp/app.apk", apk).
|
||||||
WithExec([]string{"ssh", "-o", "StrictHostKeyChecking=no", "-i", "/root/.ssh/id_ed25519", fmt.Sprintf("%s@%s", sshUser, sshHost), fmt.Sprintf("mkdir -p %s", remoteDir)}).
|
WithExec([]string{"ssh", "-i", "/root/.ssh/id_ed25519", fmt.Sprintf("%s@%s", sshUser, sshHost), fmt.Sprintf("mkdir -p %s", remoteDir)}).
|
||||||
WithExec([]string{"/bin/sh", "-c", fmt.Sprintf("scp -o StrictHostKeyChecking=no -i /root/.ssh/id_ed25519 /tmp/app.apk %s@%s:%s/%s", sshUser, sshHost, remoteDir, apkName)}).
|
WithExec([]string{"/bin/sh", "-c", fmt.Sprintf("scp -i /root/.ssh/id_ed25519 /tmp/app.apk %s@%s:%s/%s", sshUser, sshHost, remoteDir, apkName)}).
|
||||||
Stdout(ctx)
|
Stdout(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -738,7 +745,7 @@ func (m *Ci) UploadToPlayStore(
|
|||||||
From("python:3.12-alpine").
|
From("python:3.12-alpine").
|
||||||
WithExec([]string{"apk", "add", "--no-cache", "curl"}).
|
WithExec([]string{"apk", "add", "--no-cache", "curl"}).
|
||||||
WithMountedCache("/root/.cache/pip", dag.CacheVolume("pip-cache")).
|
WithMountedCache("/root/.cache/pip", dag.CacheVolume("pip-cache")).
|
||||||
WithExec([]string{"pip", "install", "requests", "google-auth"}).
|
WithExec([]string{"pip", "install", "google-auth", "requests"}).
|
||||||
WithFile("/src/build/app/outputs/bundle/release/app-release.aab", aab).
|
WithFile("/src/build/app/outputs/bundle/release/app-release.aab", aab).
|
||||||
WithFile("/src/scripts/deploy_playstore.py", scriptSource.File("scripts/deploy_playstore.py")).
|
WithFile("/src/scripts/deploy_playstore.py", scriptSource.File("scripts/deploy_playstore.py")).
|
||||||
WithSecretVariable("PLAY_STORE_CONFIG_JSON", playStoreConfig).
|
WithSecretVariable("PLAY_STORE_CONFIG_JSON", playStoreConfig).
|
||||||
@@ -834,16 +841,25 @@ flowchart TD
|
|||||||
integration --> check
|
integration --> check
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph forgejo ["Codeberg CI · .forgejo/workflows/ci.yml"]
|
subgraph forgejo_ci ["Codeberg CI · ci.yml (push/PR, source paths only)"]
|
||||||
ciCheck["check"]
|
ciCheck["check"]
|
||||||
buildLinux["build-linux\n(main only)"]
|
end
|
||||||
deployPS["deploy-playstore\n(main only)"]
|
|
||||||
pubWeb["publish-website\n(main only)"]
|
|
||||||
|
|
||||||
ciCheck --> buildLinux
|
subgraph forgejo_deploy ["Codeberg CI · deploy.yml (hourly schedule + workflow_dispatch)"]
|
||||||
ciCheck --> deployPS
|
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
|
||||||
buildLinux --> pubWeb
|
buildLinux --> pubWeb
|
||||||
deployPS --> pubWeb
|
deployPS --> pubWeb
|
||||||
|
deployApk --> pubWeb
|
||||||
end
|
end
|
||||||
|
|
||||||
check -- "task check-dagger" --> ciCheck
|
check -- "task check-dagger" --> ciCheck
|
||||||
|
|||||||
+9
-105
@@ -1,24 +1,17 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
Cron deploy script for sharedinbox website.
|
Cron deploy script for sharedinbox website.
|
||||||
Runs every 5 minutes; skips if origin/main has not changed since last successful deploy.
|
Runs every 5 minutes; skips if origin/main has not changed since last trigger.
|
||||||
Gives up and creates a Codeberg issue after 5 consecutive failures on the same commit.
|
Triggers the 'Deploy Website' Forgejo Actions workflow via fgj on each new commit.
|
||||||
|
Forgejo Actions handles failure reporting.
|
||||||
"""
|
"""
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from datetime import datetime, timezone
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
REPO_DIR = Path(__file__).parent.resolve()
|
REPO_DIR = Path(__file__).parent.resolve()
|
||||||
SHA_FILE = REPO_DIR / '.last_deployed_sha'
|
SHA_FILE = REPO_DIR / '.last_deployed_sha'
|
||||||
FAILED_SHA_FILE = REPO_DIR / '.last_failed_sha'
|
|
||||||
FAIL_COUNT_FILE = REPO_DIR / '.fail_count'
|
|
||||||
ERROR_FILE = REPO_DIR / '.last_deploy_error'
|
|
||||||
ISSUE_SHA_FILE = REPO_DIR / '.last_issue_sha'
|
|
||||||
|
|
||||||
MAX_FAILURES = 5
|
|
||||||
REPO = 'guettli/sharedinbox'
|
REPO = 'guettli/sharedinbox'
|
||||||
CODEBERG = 'https://codeberg.org'
|
|
||||||
|
|
||||||
|
|
||||||
def git(*args):
|
def git(*args):
|
||||||
@@ -32,70 +25,6 @@ def read(path: Path) -> str:
|
|||||||
return path.read_text().strip() if path.exists() else ''
|
return path.read_text().strip() if path.exists() else ''
|
||||||
|
|
||||||
|
|
||||||
def read_int(path: Path) -> int:
|
|
||||||
try:
|
|
||||||
return int(read(path))
|
|
||||||
except ValueError:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def issue_exists_for(sha: str) -> bool:
|
|
||||||
"""Check Codeberg for an open issue referencing this commit SHA."""
|
|
||||||
result = subprocess.run(
|
|
||||||
['tea', 'issue', 'list', '--repo', REPO, '--state', 'open',
|
|
||||||
'--limit', '50', '--output', 'simple'],
|
|
||||||
capture_output=True, text=True,
|
|
||||||
)
|
|
||||||
return sha[:8] in result.stdout
|
|
||||||
|
|
||||||
|
|
||||||
def create_issue(failed_sha: str, fail_count: int) -> None:
|
|
||||||
error_output = read(ERROR_FILE)
|
|
||||||
tail = '\n'.join(error_output.splitlines()[-40:]) if error_output else '(no output captured)'
|
|
||||||
commit_url = f'{CODEBERG}/{REPO}/commit/{failed_sha}'
|
|
||||||
script_url = f'{CODEBERG}/{REPO}/src/branch/main/deploy_cron.py'
|
|
||||||
timestamp = datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')
|
|
||||||
|
|
||||||
title = f'Deploy failed {fail_count}x on {failed_sha[:8]} — needs fix'
|
|
||||||
body = f"""\
|
|
||||||
## Deploy failure — action needed
|
|
||||||
|
|
||||||
The automated deploy cron failed **{fail_count} times** on commit \
|
|
||||||
[{failed_sha[:8]}]({commit_url}) and has stopped retrying.
|
|
||||||
|
|
||||||
| | |
|
|
||||||
|---|---|
|
|
||||||
| **Detected** | {timestamp} |
|
|
||||||
| **Failing commit** | [{failed_sha}]({commit_url}) |
|
|
||||||
| **Failures** | {fail_count} / {MAX_FAILURES} |
|
|
||||||
| **Deploy script** | [deploy_cron.py]({script_url}) |
|
|
||||||
| **Log file** | `~/si-deploy-cron/deploy.log` |
|
|
||||||
|
|
||||||
### Last deploy output
|
|
||||||
|
|
||||||
```
|
|
||||||
{tail}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Next steps
|
|
||||||
|
|
||||||
Push a fix to `main` — the cron (every 5 min) will retry automatically on the next commit.
|
|
||||||
"""
|
|
||||||
|
|
||||||
result = subprocess.run(
|
|
||||||
['tea', 'issue', 'create',
|
|
||||||
'--repo', REPO,
|
|
||||||
'--title', title,
|
|
||||||
'--description', body,
|
|
||||||
'--labels', 'State/Ready,Prio/High'],
|
|
||||||
capture_output=True, text=True,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
print(f'Failed to create issue: {result.stderr}', file=sys.stderr)
|
|
||||||
else:
|
|
||||||
print(f'Issue created: {result.stdout.strip()}')
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
try:
|
try:
|
||||||
git('fetch', 'origin', 'main')
|
git('fetch', 'origin', 'main')
|
||||||
@@ -103,48 +32,23 @@ def main():
|
|||||||
print(f'git fetch failed (transient?): {exc} — skipping this run.', file=sys.stderr)
|
print(f'git fetch failed (transient?): {exc} — skipping this run.', file=sys.stderr)
|
||||||
return
|
return
|
||||||
remote_sha = git('rev-parse', 'origin/main')
|
remote_sha = git('rev-parse', 'origin/main')
|
||||||
|
last_sha = read(SHA_FILE)
|
||||||
last_sha = read(SHA_FILE)
|
|
||||||
last_failed = read(FAILED_SHA_FILE)
|
|
||||||
fail_count = read_int(FAIL_COUNT_FILE) if remote_sha == last_failed else 0
|
|
||||||
last_issue = read(ISSUE_SHA_FILE)
|
|
||||||
|
|
||||||
if remote_sha == last_sha:
|
if remote_sha == last_sha:
|
||||||
print(f'No changes since {remote_sha[:8]}, skipping.')
|
print(f'No changes since {remote_sha[:8]}, skipping.')
|
||||||
return
|
return
|
||||||
|
|
||||||
if fail_count >= MAX_FAILURES:
|
print(f'New commit {remote_sha[:8]} (was {last_sha[:8] or "none"}) — triggering workflow...')
|
||||||
if remote_sha != last_issue and not issue_exists_for(remote_sha):
|
|
||||||
print(f'{remote_sha[:8]} failed {fail_count}x — creating issue.')
|
|
||||||
create_issue(remote_sha, fail_count)
|
|
||||||
ISSUE_SHA_FILE.write_text(remote_sha + '\n')
|
|
||||||
else:
|
|
||||||
print(f'{remote_sha[:8]} failed {fail_count}x, issue already exists, skipping.')
|
|
||||||
return
|
|
||||||
|
|
||||||
attempt = fail_count + 1
|
|
||||||
print(f'Deploying {remote_sha[:8]} (attempt {attempt}/{MAX_FAILURES}, was {last_sha[:8] or "none"})...')
|
|
||||||
git('pull', '--ff-only', 'origin', 'main')
|
|
||||||
|
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
['task', 'publish-website'],
|
['fgj', 'actions', 'workflow', 'run', 'website.yml', '-R', REPO],
|
||||||
cwd=REPO_DIR,
|
|
||||||
capture_output=True, text=True,
|
capture_output=True, text=True,
|
||||||
)
|
)
|
||||||
combined = result.stdout + result.stderr
|
|
||||||
print(combined, end='')
|
|
||||||
|
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
print(f'Deploy failed (exit {result.returncode}), attempt {attempt}/{MAX_FAILURES}', file=sys.stderr)
|
print(f'fgj workflow run failed: {result.stderr}', file=sys.stderr)
|
||||||
FAILED_SHA_FILE.write_text(remote_sha + '\n')
|
|
||||||
FAIL_COUNT_FILE.write_text(str(attempt) + '\n')
|
|
||||||
ERROR_FILE.write_text(combined)
|
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
SHA_FILE.write_text(remote_sha + '\n')
|
SHA_FILE.write_text(remote_sha + '\n')
|
||||||
for f in (FAILED_SHA_FILE, FAIL_COUNT_FILE, ERROR_FILE, ISSUE_SHA_FILE):
|
print('Workflow triggered.')
|
||||||
f.unlink(missing_ok=True)
|
|
||||||
print('Deploy complete.')
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
@@ -94,8 +94,9 @@
|
|||||||
sqlite
|
sqlite
|
||||||
# python3 base + Google Play API client (for scripts/deploy_playstore.py)
|
# python3 base + Google Play API client (for scripts/deploy_playstore.py)
|
||||||
(python3.withPackages (ps: with ps; [
|
(python3.withPackages (ps: with ps; [
|
||||||
google-auth
|
google-api-python-client
|
||||||
requests
|
google-auth-httplib2
|
||||||
|
httplib2
|
||||||
])) # used by stalwart-dev/start and deploy_playstore.py
|
])) # used by stalwart-dev/start and deploy_playstore.py
|
||||||
fgj # Codeberg/Forgejo CLI (like gh for GitHub)
|
fgj # Codeberg/Forgejo CLI (like gh for GitHub)
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -4,38 +4,39 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:sharedinbox/core/models/undo_action.dart';
|
import 'package:sharedinbox/core/models/undo_action.dart';
|
||||||
import 'package:sharedinbox/di.dart';
|
import 'package:sharedinbox/di.dart';
|
||||||
|
|
||||||
class UndoService extends StateNotifier<List<UndoAction>> {
|
class UndoService extends Notifier<List<UndoAction>> {
|
||||||
UndoService(this._ref) : super([]);
|
|
||||||
|
|
||||||
final Ref _ref;
|
|
||||||
static const int _maxHistory = 10;
|
static const int _maxHistory = 10;
|
||||||
|
|
||||||
// Resolves once init() has loaded persisted history. Default to an already-
|
// Resolves once build() has loaded persisted history.
|
||||||
// resolved future so operations are safe even if init() is never called.
|
late Future<void> _ready;
|
||||||
Future<void> _ready = Future.value();
|
|
||||||
|
|
||||||
Future<void> init() async {
|
@override
|
||||||
_ready = _ref.read(undoRepositoryProvider).getHistory().then((history) {
|
List<UndoAction> build() {
|
||||||
if (mounted) state = history;
|
_ready = ref.read(undoRepositoryProvider).getHistory().then((history) {
|
||||||
|
if (ref.mounted) state = history;
|
||||||
});
|
});
|
||||||
await _ready;
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Waits for the persisted history to finish loading. Called by tests to
|
||||||
|
/// ensure the provider is ready before asserting state.
|
||||||
|
Future<void> init() => _ready;
|
||||||
|
|
||||||
Future<void> pushAction(UndoAction action) async {
|
Future<void> pushAction(UndoAction action) async {
|
||||||
await _ready;
|
await _ready;
|
||||||
final newList = [...state, action];
|
final newList = [...state, action];
|
||||||
if (newList.length > _maxHistory) {
|
if (newList.length > _maxHistory) {
|
||||||
final removed = newList.removeAt(0);
|
final removed = newList.removeAt(0);
|
||||||
await _ref.read(undoRepositoryProvider).deleteAction(removed.id);
|
await ref.read(undoRepositoryProvider).deleteAction(removed.id);
|
||||||
}
|
}
|
||||||
state = newList;
|
state = newList;
|
||||||
await _ref.read(undoRepositoryProvider).saveAction(action);
|
await ref.read(undoRepositoryProvider).saveAction(action);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> clear() async {
|
Future<void> clear() async {
|
||||||
await _ready;
|
await _ready;
|
||||||
state = [];
|
state = [];
|
||||||
unawaited(_ref.read(undoRepositoryProvider).clearHistory());
|
unawaited(ref.read(undoRepositoryProvider).clearHistory());
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> undo({String? actionId}) async {
|
Future<void> undo({String? actionId}) async {
|
||||||
@@ -57,7 +58,7 @@ class UndoService extends StateNotifier<List<UndoAction>> {
|
|||||||
// happened and retry if the undo failed (e.g. after an IMAP sync reverted
|
// happened and retry if the undo failed (e.g. after an IMAP sync reverted
|
||||||
// the local change). The inverse action added below allows undoing the undo.
|
// the local change). The inverse action added below allows undoing the undo.
|
||||||
|
|
||||||
final repo = _ref.read(emailRepositoryProvider);
|
final repo = ref.read(emailRepositoryProvider);
|
||||||
|
|
||||||
for (final id in action.emailIds) {
|
for (final id in action.emailIds) {
|
||||||
// 1. Try to cancel the original change (if not started yet).
|
// 1. Try to cancel the original change (if not started yet).
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:enough_mail/enough_mail.dart' as imap;
|
import 'package:enough_mail/enough_mail.dart' as imap;
|
||||||
|
import 'package:flutter/services.dart' show MissingPluginException;
|
||||||
import 'package:sharedinbox/core/models/account.dart';
|
import 'package:sharedinbox/core/models/account.dart';
|
||||||
import 'package:sharedinbox/core/models/email.dart' show SyncEmailsResult;
|
import 'package:sharedinbox/core/models/email.dart' show SyncEmailsResult;
|
||||||
import 'package:sharedinbox/core/repositories/account_repository.dart';
|
import 'package:sharedinbox/core/repositories/account_repository.dart';
|
||||||
@@ -294,6 +295,7 @@ class _AccountSync implements _SyncLoop {
|
|||||||
|
|
||||||
bool _isPermanentError(Object e) {
|
bool _isPermanentError(Object e) {
|
||||||
if (isTlsConfigError(e)) return true;
|
if (isTlsConfigError(e)) return true;
|
||||||
|
if (e is MissingPluginException) return true;
|
||||||
final s = e.toString().toLowerCase();
|
final s = e.toString().toLowerCase();
|
||||||
// enough_mail doesn't always have typed exceptions for auth, so we check strings.
|
// enough_mail doesn't always have typed exceptions for auth, so we check strings.
|
||||||
return s.contains('invalid credentials') ||
|
return s.contains('invalid credentials') ||
|
||||||
@@ -546,6 +548,7 @@ class _JmapAccountSync implements _SyncLoop {
|
|||||||
|
|
||||||
bool _isPermanentError(Object e) {
|
bool _isPermanentError(Object e) {
|
||||||
if (isTlsConfigError(e)) return true;
|
if (isTlsConfigError(e)) return true;
|
||||||
|
if (e is MissingPluginException) return true;
|
||||||
final s = e.toString().toLowerCase();
|
final s = e.toString().toLowerCase();
|
||||||
return s.contains('invalid credentials') ||
|
return s.contains('invalid credentials') ||
|
||||||
s.contains('authentication failed') ||
|
s.contains('authentication failed') ||
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:drift/drift.dart';
|
|||||||
import 'package:drift/native.dart';
|
import 'package:drift/native.dart';
|
||||||
import 'package:enough_mail/enough_mail.dart' as imap;
|
import 'package:enough_mail/enough_mail.dart' as imap;
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
@@ -24,6 +25,9 @@ const _kResourceType = 'background_check';
|
|||||||
|
|
||||||
@pragma('vm:entry-point')
|
@pragma('vm:entry-point')
|
||||||
void callbackDispatcher() {
|
void callbackDispatcher() {
|
||||||
|
// Required so that path_provider and other plugins are available in this
|
||||||
|
// background isolate (issue #192).
|
||||||
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
Workmanager().executeTask((_, __) async {
|
Workmanager().executeTask((_, __) async {
|
||||||
try {
|
try {
|
||||||
await _doBackgroundSync();
|
await _doBackgroundSync();
|
||||||
|
|||||||
@@ -609,6 +609,17 @@ Future<String> _resolveDatabasePath() async {
|
|||||||
await Future<void>.delayed(Duration(milliseconds: ms));
|
await Future<void>.delayed(Duration(milliseconds: ms));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// On Android, path_provider can be permanently broken on some devices
|
||||||
|
// regardless of how long we wait (issue #192). Derive the path from
|
||||||
|
// /proc/self/cmdline (the Android process name == package name) without
|
||||||
|
// a platform channel as a last resort so the app can still open its DB.
|
||||||
|
if (Platform.isAndroid) {
|
||||||
|
final fallback = await _androidFallbackPath();
|
||||||
|
if (fallback != null) {
|
||||||
|
_dbPath = fallback;
|
||||||
|
return _dbPath!;
|
||||||
|
}
|
||||||
|
}
|
||||||
throw PlatformException(
|
throw PlatformException(
|
||||||
code: 'channel-error',
|
code: 'channel-error',
|
||||||
message: 'path_provider unavailable after ${delays.length + 1} attempts — '
|
message: 'path_provider unavailable after ${delays.length + 1} attempts — '
|
||||||
@@ -616,6 +627,45 @@ Future<String> _resolveDatabasePath() async {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reads /proc/self/cmdline to extract the Android package name, then
|
||||||
|
// constructs the standard app files-dir path without a platform channel.
|
||||||
|
// Returns null when the path cannot be determined or created.
|
||||||
|
Future<String?> _androidFallbackPath() async {
|
||||||
|
try {
|
||||||
|
final bytes = await File('/proc/self/cmdline').readAsBytes();
|
||||||
|
final end = bytes.indexOf(0);
|
||||||
|
final packageName = String.fromCharCodes(
|
||||||
|
end >= 0 ? bytes.sublist(0, end) : bytes,
|
||||||
|
).trim();
|
||||||
|
// A valid Android package name contains dots but not slashes.
|
||||||
|
if (packageName.isEmpty ||
|
||||||
|
!packageName.contains('.') ||
|
||||||
|
packageName.contains('/')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (final base in [
|
||||||
|
'/data/user/0/$packageName/files',
|
||||||
|
'/data/data/$packageName/files',
|
||||||
|
]) {
|
||||||
|
try {
|
||||||
|
await Directory(base).create(recursive: true);
|
||||||
|
return p.join(base, 'sharedinbox.db');
|
||||||
|
} catch (_) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// These functions are only called from unit tests (database_path_test.dart).
|
||||||
|
// They expose internals that cannot be reached via the public API.
|
||||||
|
Future<String> resolveDatabasePathForTesting() => _resolveDatabasePath();
|
||||||
|
void resetDatabasePathForTesting() => _dbPath = null;
|
||||||
|
Future<String?> androidFallbackPathForTesting() => _androidFallbackPath();
|
||||||
|
|
||||||
LazyDatabase _openConnection() {
|
LazyDatabase _openConnection() {
|
||||||
return LazyDatabase(() async {
|
return LazyDatabase(() async {
|
||||||
final file = File(await _resolveDatabasePath());
|
final file = File(await _resolveDatabasePath());
|
||||||
|
|||||||
+11
-12
@@ -11,6 +11,7 @@ import 'package:sharedinbox/core/repositories/email_repository.dart';
|
|||||||
import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
|
import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
|
||||||
import 'package:sharedinbox/core/repositories/search_history_repository.dart';
|
import 'package:sharedinbox/core/repositories/search_history_repository.dart';
|
||||||
import 'package:sharedinbox/core/repositories/share_key_repository.dart';
|
import 'package:sharedinbox/core/repositories/share_key_repository.dart';
|
||||||
|
import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
|
||||||
import 'package:sharedinbox/core/repositories/undo_repository.dart';
|
import 'package:sharedinbox/core/repositories/undo_repository.dart';
|
||||||
import 'package:sharedinbox/core/services/account_discovery_service.dart';
|
import 'package:sharedinbox/core/services/account_discovery_service.dart';
|
||||||
import 'package:sharedinbox/core/services/connection_test_service.dart';
|
import 'package:sharedinbox/core/services/connection_test_service.dart';
|
||||||
@@ -101,7 +102,7 @@ final searchHistoryRepositoryProvider =
|
|||||||
return SearchHistoryRepositoryImpl(ref.watch(dbProvider));
|
return SearchHistoryRepositoryImpl(ref.watch(dbProvider));
|
||||||
});
|
});
|
||||||
|
|
||||||
final syncLogRepositoryProvider = Provider((ref) {
|
final syncLogRepositoryProvider = Provider<SyncLogRepository>((ref) {
|
||||||
return SyncLogRepositoryImpl(ref.watch(dbProvider));
|
return SyncLogRepositoryImpl(ref.watch(dbProvider));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -181,11 +182,7 @@ final manageSieveProbeServiceProvider = Provider<ManageSieveProbeService>((
|
|||||||
});
|
});
|
||||||
|
|
||||||
final undoServiceProvider =
|
final undoServiceProvider =
|
||||||
StateNotifierProvider<UndoService, List<UndoAction>>((ref) {
|
NotifierProvider<UndoService, List<UndoAction>>(UndoService.new);
|
||||||
final service = UndoService(ref);
|
|
||||||
unawaited(service.init());
|
|
||||||
return service;
|
|
||||||
});
|
|
||||||
|
|
||||||
/// Loads email header + body and marks the email as seen.
|
/// Loads email header + body and marks the email as seen.
|
||||||
/// Owned by [EmailDetailScreen]; decouples data loading from the widget tree.
|
/// Owned by [EmailDetailScreen]; decouples data loading from the widget tree.
|
||||||
@@ -194,16 +191,18 @@ final emailDetailProvider = AsyncNotifierProvider.autoDispose
|
|||||||
EmailDetailNotifier.new,
|
EmailDetailNotifier.new,
|
||||||
);
|
);
|
||||||
|
|
||||||
class EmailDetailNotifier
|
class EmailDetailNotifier extends AsyncNotifier<(Email?, EmailBody)> {
|
||||||
extends AutoDisposeFamilyAsyncNotifier<(Email?, EmailBody), String> {
|
EmailDetailNotifier(this._emailId);
|
||||||
|
final String _emailId;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<(Email?, EmailBody)> build(String emailId) async {
|
Future<(Email?, EmailBody)> build() async {
|
||||||
final repo = ref.read(emailRepositoryProvider);
|
final repo = ref.read(emailRepositoryProvider);
|
||||||
final results = await Future.wait([
|
final results = await Future.wait([
|
||||||
repo.getEmail(emailId),
|
repo.getEmail(_emailId),
|
||||||
repo.getEmailBody(emailId),
|
repo.getEmailBody(_emailId),
|
||||||
]);
|
]);
|
||||||
unawaited(repo.setFlag(emailId, seen: true));
|
unawaited(repo.setFlag(_emailId, seen: true));
|
||||||
return (results[0] as Email?, results[1] as EmailBody);
|
return (results[0] as Email?, results[1] as EmailBody);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'dart:io';
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_riverpod/misc.dart' show Override;
|
||||||
|
|
||||||
import 'package:sharedinbox/core/services/notification_service.dart';
|
import 'package:sharedinbox/core/services/notification_service.dart';
|
||||||
import 'package:sharedinbox/core/sync/background_sync.dart';
|
import 'package:sharedinbox/core/sync/background_sync.dart';
|
||||||
|
|||||||
@@ -47,10 +47,14 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
|
|||||||
final osName = _capitalize(Platform.operatingSystem);
|
final osName = _capitalize(Platform.operatingSystem);
|
||||||
final isDark = MediaQuery.of(context).platformBrightness == Brightness.dark;
|
final isDark = MediaQuery.of(context).platformBrightness == Brightness.dark;
|
||||||
|
|
||||||
return '## sharedinbox.de\n\n'
|
final gitCommitLine = _gitHash.isNotEmpty
|
||||||
|
? '| Git Commit | [$_gitHash](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash) |\n'
|
||||||
|
: '';
|
||||||
|
return '## [sharedinbox.de](https://sharedinbox.de)\n\n'
|
||||||
'| Property | Value |\n'
|
'| Property | Value |\n'
|
||||||
'|----------|-------|\n'
|
'|----------|-------|\n'
|
||||||
'| App Version | $versionDisplay |\n'
|
'| App Version | $versionDisplay |\n'
|
||||||
|
'$gitCommitLine'
|
||||||
'| Platform | ${Platform.operatingSystem} |\n'
|
'| Platform | ${Platform.operatingSystem} |\n'
|
||||||
'| $osName Version | ${Platform.operatingSystemVersion} |\n'
|
'| $osName Version | ${Platform.operatingSystemVersion} |\n'
|
||||||
'| Resolution | ${physW}x$physH px'
|
'| Resolution | ${physW}x$physH px'
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
|
|||||||
bool _scannerActive = false;
|
bool _scannerActive = false;
|
||||||
|
|
||||||
MobileScannerController? _scannerController;
|
MobileScannerController? _scannerController;
|
||||||
|
// True when the scanner plugin fails to initialise at runtime (e.g.
|
||||||
|
// MissingPluginException on some Android builds).
|
||||||
|
bool _scannerFailed = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -76,8 +79,35 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_step = _Step.scanning;
|
_step = _Step.scanning;
|
||||||
_scannerActive = true;
|
_scannerActive = true;
|
||||||
_scannerController = MobileScannerController();
|
|
||||||
});
|
});
|
||||||
|
if (_cameraScanSupported()) {
|
||||||
|
unawaited(_initScanner());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-flight: start + stop the scanner to verify the plugin is available.
|
||||||
|
// Falls back to text entry on any exception (including MissingPluginException).
|
||||||
|
Future<void> _initScanner() async {
|
||||||
|
MobileScannerController? ctrl;
|
||||||
|
bool available = false;
|
||||||
|
try {
|
||||||
|
ctrl = MobileScannerController();
|
||||||
|
await ctrl.start();
|
||||||
|
await ctrl.stop();
|
||||||
|
available = true;
|
||||||
|
} catch (_) {
|
||||||
|
// Plugin not available on this device; text fallback will be shown.
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
await ctrl?.dispose();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
if (available) {
|
||||||
|
setState(() => _scannerController = MobileScannerController());
|
||||||
|
} else {
|
||||||
|
setState(() => _scannerFailed = true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onScanned(String rawValue) async {
|
Future<void> _onScanned(String rawValue) async {
|
||||||
@@ -266,11 +296,14 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildScannerView(BuildContext context) {
|
Widget _buildScannerView(BuildContext context) {
|
||||||
// On platforms where the camera scanner is not available (Linux desktop),
|
// Fall back to text input when the platform has no camera support or when
|
||||||
// fall back to a text-input field.
|
// the scanner plugin fails to initialise at runtime (MissingPluginException).
|
||||||
if (!_cameraScanSupported()) {
|
if (!_cameraScanSupported() || _scannerFailed) {
|
||||||
return _buildTextFallbackView(context);
|
return _buildTextFallbackView(context);
|
||||||
}
|
}
|
||||||
|
if (_scannerController == null) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
return Stack(
|
return Stack(
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
@@ -45,12 +45,40 @@ class _AccountSendScreenState extends ConsumerState<AccountSendScreen> {
|
|||||||
bool _scannerActive = true;
|
bool _scannerActive = true;
|
||||||
|
|
||||||
MobileScannerController? _scannerController;
|
MobileScannerController? _scannerController;
|
||||||
|
// True when the scanner plugin fails to initialise at runtime (e.g.
|
||||||
|
// MissingPluginException on some Android builds).
|
||||||
|
bool _scannerFailed = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
if (_cameraScanSupported()) {
|
if (_cameraScanSupported()) {
|
||||||
_scannerController = MobileScannerController();
|
unawaited(_initScanner());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-flight: start + stop the scanner to verify the plugin is available.
|
||||||
|
// Falls back to text entry on any exception (including MissingPluginException).
|
||||||
|
Future<void> _initScanner() async {
|
||||||
|
MobileScannerController? ctrl;
|
||||||
|
bool available = false;
|
||||||
|
try {
|
||||||
|
ctrl = MobileScannerController();
|
||||||
|
await ctrl.start();
|
||||||
|
await ctrl.stop();
|
||||||
|
available = true;
|
||||||
|
} catch (_) {
|
||||||
|
// Plugin not available on this device; text fallback will be shown.
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
await ctrl?.dispose();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
if (available) {
|
||||||
|
setState(() => _scannerController = MobileScannerController());
|
||||||
|
} else {
|
||||||
|
setState(() => _scannerFailed = true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,9 +206,12 @@ class _AccountSendScreenState extends ConsumerState<AccountSendScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildScanStep(BuildContext context) {
|
Widget _buildScanStep(BuildContext context) {
|
||||||
if (!_cameraScanSupported()) {
|
if (!_cameraScanSupported() || _scannerFailed) {
|
||||||
return _buildTextFallbackView(context);
|
return _buildTextFallbackView(context);
|
||||||
}
|
}
|
||||||
|
if (_scannerController == null) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
return Stack(
|
return Stack(
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
@@ -10,12 +10,12 @@ class CrashScreen extends StatelessWidget {
|
|||||||
super.key,
|
super.key,
|
||||||
required this.exception,
|
required this.exception,
|
||||||
required this.stackTrace,
|
required this.stackTrace,
|
||||||
|
this.gitHash = const String.fromEnvironment('GIT_HASH'),
|
||||||
});
|
});
|
||||||
|
|
||||||
final Object exception;
|
final Object exception;
|
||||||
final StackTrace? stackTrace;
|
final StackTrace? stackTrace;
|
||||||
|
final String gitHash;
|
||||||
static const _gitHash = String.fromEnvironment('GIT_HASH');
|
|
||||||
|
|
||||||
Future<String> _buildReport() async {
|
Future<String> _buildReport() async {
|
||||||
String version = 'unknown';
|
String version = 'unknown';
|
||||||
@@ -25,8 +25,8 @@ class CrashScreen extends StatelessWidget {
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
final platform =
|
final platform =
|
||||||
'${Platform.operatingSystem} ${Platform.operatingSystemVersion}';
|
'${Platform.operatingSystem} ${Platform.operatingSystemVersion}';
|
||||||
final gitLine = _gitHash.isNotEmpty
|
final gitLine = gitHash.isNotEmpty
|
||||||
? 'Git Commit: [$_gitHash](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash)\n'
|
? 'Git Commit: [$gitHash](https://codeberg.org/guettli/sharedinbox/commit/$gitHash)\n'
|
||||||
: '';
|
: '';
|
||||||
return 'App Version: $version\n'
|
return 'App Version: $version\n'
|
||||||
'$gitLine'
|
'$gitLine'
|
||||||
@@ -56,12 +56,27 @@ class CrashScreen extends StatelessWidget {
|
|||||||
style: Theme.of(ctx).textTheme.titleMedium,
|
style: Theme.of(ctx).textTheme.titleMedium,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
if (_gitHash.isNotEmpty) ...[
|
if (gitHash.isNotEmpty) ...[
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
const Text(
|
GestureDetector(
|
||||||
'Git Commit: $_gitHash',
|
onTap: () async {
|
||||||
style: TextStyle(fontSize: 12, color: Colors.grey),
|
final url = Uri.parse(
|
||||||
textAlign: TextAlign.center,
|
'https://codeberg.org/guettli/sharedinbox/commit/$gitHash',
|
||||||
|
);
|
||||||
|
await launchUrl(
|
||||||
|
url,
|
||||||
|
mode: LaunchMode.externalApplication,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: Text(
|
||||||
|
'Git Commit: $gitHash',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.blue,
|
||||||
|
decoration: TextDecoration.underline,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
@@ -106,32 +121,6 @@ class CrashScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
if (_gitHash.isNotEmpty) ...[
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
const Text(
|
|
||||||
'Git Commit:',
|
|
||||||
style: TextStyle(fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
GestureDetector(
|
|
||||||
onTap: () async {
|
|
||||||
final url = Uri.parse(
|
|
||||||
'https://codeberg.org/guettli/sharedinbox/commit/$_gitHash',
|
|
||||||
);
|
|
||||||
await launchUrl(
|
|
||||||
url,
|
|
||||||
mode: LaunchMode.externalApplication,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: const Text(
|
|
||||||
_gitHash,
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.blue,
|
|
||||||
decoration: TextDecoration.underline,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
|
|||||||
@@ -43,15 +43,15 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
ref.listen<AsyncValue<(Email?, EmailBody)>>(
|
ref.listen<AsyncValue<(Email?, EmailBody)>>(
|
||||||
emailDetailProvider(widget.emailId),
|
emailDetailProvider(widget.emailId),
|
||||||
(_, next) {
|
(_, next) {
|
||||||
final email = next.valueOrNull?.$1;
|
final email = next.value?.$1;
|
||||||
if (email != null && mounted) {
|
if (email != null && mounted) {
|
||||||
setState(() => _isFlagged = email.isFlagged);
|
setState(() => _isFlagged = email.isFlagged);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
final header = detail.valueOrNull?.$1;
|
final header = detail.value?.$1;
|
||||||
final body = detail.valueOrNull?.$2;
|
final body = detail.value?.$2;
|
||||||
|
|
||||||
final isMobile = defaultTargetPlatform == TargetPlatform.android ||
|
final isMobile = defaultTargetPlatform == TargetPlatform.android ||
|
||||||
defaultTargetPlatform == TargetPlatform.iOS;
|
defaultTargetPlatform == TargetPlatform.iOS;
|
||||||
|
|||||||
@@ -261,9 +261,9 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
|
|
||||||
Widget _buildSyncButton(EmailRepository emailRepo) {
|
Widget _buildSyncButton(EmailRepository emailRepo) {
|
||||||
final isSyncing =
|
final isSyncing =
|
||||||
ref.watch(isSyncingProvider(widget.accountId)).valueOrNull ?? false;
|
ref.watch(isSyncingProvider(widget.accountId)).value ?? false;
|
||||||
final hasError =
|
final hasError =
|
||||||
ref.watch(syncLastErrorProvider(widget.accountId)).valueOrNull != null;
|
ref.watch(syncLastErrorProvider(widget.accountId)).value != null;
|
||||||
return IconButton(
|
return IconButton(
|
||||||
tooltip: isSyncing
|
tooltip: isSyncing
|
||||||
? 'Syncing…'
|
? 'Syncing…'
|
||||||
@@ -350,7 +350,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
|
|
||||||
Widget _buildSyncErrorBanner() {
|
Widget _buildSyncErrorBanner() {
|
||||||
final errorAsync = ref.watch(syncLastErrorProvider(widget.accountId));
|
final errorAsync = ref.watch(syncLastErrorProvider(widget.accountId));
|
||||||
final error = errorAsync.valueOrNull;
|
final error = errorAsync.value;
|
||||||
if (error == null || error == _dismissedError) {
|
if (error == null || error == _dismissedError) {
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -415,10 +415,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: flutter_riverpod
|
name: flutter_riverpod
|
||||||
sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1"
|
sha256: "4e166be88e1dbbaa34a280bdb744aeae73b7ef25fdf8db7a3bb776760a3648e2"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.6.1"
|
version: "3.3.1"
|
||||||
flutter_secure_storage:
|
flutter_secure_storage:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -891,10 +891,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: riverpod
|
name: riverpod
|
||||||
sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959"
|
sha256: "8c22216be8ad3ef2b44af3a329693558c98eca7b8bd4ef495c92db0bba279f83"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.6.1"
|
version: "3.2.1"
|
||||||
share_plus:
|
share_plus:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|||||||
+1
-1
@@ -24,7 +24,7 @@ dependencies:
|
|||||||
path: ^1.9.1
|
path: ^1.9.1
|
||||||
|
|
||||||
# State management
|
# State management
|
||||||
flutter_riverpod: ^2.6.1
|
flutter_riverpod: ^3.0.0
|
||||||
|
|
||||||
# Navigation
|
# Navigation
|
||||||
go_router: ^17.2.3
|
go_router: ^17.2.3
|
||||||
|
|||||||
+173
-18
@@ -8,12 +8,15 @@ Flow
|
|||||||
a. Age > 1 h → kill it, set its issue to State/Question, exit 1
|
a. Age > 1 h → kill it, set its issue to State/Question, exit 1
|
||||||
b. Age ≤ 1 h → print status, exit 0 (let it keep working)
|
b. Age ≤ 1 h → print status, exit 0 (let it keep working)
|
||||||
2. No agent running → extract pending_issue from state (if any), then check CI
|
2. No agent running → extract pending_issue from state (if any), then check CI
|
||||||
a. CI is running → save pending-ci state, exit 0
|
a. pending_issue + open PR → check PR branch CI, merge/fix/wait as needed
|
||||||
b. Latest CI failed → start fix-CI agent (preserving pending_issue), exit 0
|
b. Catch-up: orphaned issue-N-fix PRs with passing CI → merge them
|
||||||
c. CI ok + pending_issue → close the issue (CI passed), exit 0
|
c. Main CI running → save pending-ci state, exit 0
|
||||||
d. CI ok (or no run yet) → find oldest Ready issue, start issue agent,
|
d. Main CI failed → start fix-CI agent (pushes fix to main), exit 0
|
||||||
save state, exit 0
|
e. Main CI ok + pending_issue → close the issue, exit 0 (dead code path —
|
||||||
e. No Ready issues → print "nothing to do", exit 0
|
section 2a always returns first)
|
||||||
|
f. Main CI ok (or no run yet) → find oldest Ready issue, start issue agent,
|
||||||
|
save state, exit 0
|
||||||
|
g. No Ready issues → print "nothing to do", exit 0
|
||||||
|
|
||||||
Issue agents must NOT close the issue themselves; the loop closes it after CI passes.
|
Issue agents must NOT close the issue themselves; the loop closes it after CI passes.
|
||||||
|
|
||||||
@@ -31,6 +34,7 @@ To resume the Claude conversation, look up the session UUID first:
|
|||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import shlex
|
import shlex
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
@@ -141,10 +145,19 @@ def _ready_issues() -> list[dict]:
|
|||||||
return ready
|
return ready
|
||||||
|
|
||||||
|
|
||||||
def _latest_ci_run() -> dict | None:
|
def _latest_main_ci_run() -> dict | None:
|
||||||
data = _tea_get(f"repos/{REPO}/actions/runs?limit=1")
|
"""Return the latest CI run on the main branch (excludes PR and schedule runs).
|
||||||
|
|
||||||
|
Using the global latest run (limit=1) is wrong: a passing or failing run
|
||||||
|
on a PR branch could mask the true state of main. We filter to push
|
||||||
|
events on the 'main' prettyref so section-3 logic only reacts to main.
|
||||||
|
"""
|
||||||
|
data = _tea_get(f"repos/{REPO}/actions/runs?limit=20")
|
||||||
runs = (data or {}).get("workflow_runs", [])
|
runs = (data or {}).get("workflow_runs", [])
|
||||||
return runs[0] if runs else None
|
for run in runs:
|
||||||
|
if run.get("event") == "push" and run.get("prettyref") == "main":
|
||||||
|
return run
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _latest_ci_run_for_branch(branch: str) -> dict | None:
|
def _latest_ci_run_for_branch(branch: str) -> dict | None:
|
||||||
@@ -164,7 +177,7 @@ def _latest_ci_run_for_branch(branch: str) -> dict | None:
|
|||||||
return run
|
return run
|
||||||
except (json.JSONDecodeError, AttributeError):
|
except (json.JSONDecodeError, AttributeError):
|
||||||
pass
|
pass
|
||||||
else:
|
elif run.get("event") == "push":
|
||||||
if run.get("prettyref") == branch:
|
if run.get("prettyref") == branch:
|
||||||
return run
|
return run
|
||||||
return None
|
return None
|
||||||
@@ -188,6 +201,40 @@ def _find_pr_for_branch(branch: str, state: str = "open") -> dict | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _open_issue_prs() -> list[dict]:
|
||||||
|
"""Return all open PRs with issue-{N}-fix branches, oldest-first."""
|
||||||
|
result = subprocess.run(
|
||||||
|
["fgj", "--hostname", "codeberg.org", "pr", "list",
|
||||||
|
"--repo", REPO, "--state", "open", "--json"],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
if result.returncode != 0 or not result.stdout.strip():
|
||||||
|
return []
|
||||||
|
prs = json.loads(result.stdout)
|
||||||
|
issue_prs = []
|
||||||
|
for pr in prs:
|
||||||
|
head = pr.get("head", {})
|
||||||
|
ref = head.get("ref") or head.get("label", "").split(":")[-1]
|
||||||
|
if re.match(r"^issue-\d+-fix$", ref or ""):
|
||||||
|
issue_prs.append(pr)
|
||||||
|
issue_prs.sort(key=lambda p: p["number"])
|
||||||
|
return issue_prs
|
||||||
|
|
||||||
|
|
||||||
|
def _latest_ci_run_for_pr(pr_number: int) -> dict | None:
|
||||||
|
"""Return the latest CI run triggered by a pull_request event for the given PR number."""
|
||||||
|
data = _tea_get(f"repos/{REPO}/actions/runs?event=pull_request&limit=50")
|
||||||
|
runs = (data or {}).get("workflow_runs", [])
|
||||||
|
for run in runs:
|
||||||
|
try:
|
||||||
|
payload = json.loads(run.get("event_payload", "{}"))
|
||||||
|
if payload.get("pull_request", {}).get("number") == pr_number:
|
||||||
|
return run
|
||||||
|
except (json.JSONDecodeError, AttributeError):
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _merge_pr(pr_number: int) -> None:
|
def _merge_pr(pr_number: int) -> None:
|
||||||
"""Squash-merge a PR via fgj."""
|
"""Squash-merge a PR via fgj."""
|
||||||
_fgj("pr", "merge", str(pr_number), "--repo", REPO, "--merge-method", "squash")
|
_fgj("pr", "merge", str(pr_number), "--repo", REPO, "--merge-method", "squash")
|
||||||
@@ -298,6 +345,15 @@ def _agent_alive(state: dict) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _is_claude_process(pid: int) -> bool:
|
||||||
|
"""Return True if pid's comm name indicates it is a claude/node process."""
|
||||||
|
try:
|
||||||
|
comm = Path(f"/proc/{pid}/comm").read_text().strip()
|
||||||
|
return comm in ("claude", "node")
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _agent_age_seconds(state: dict) -> float:
|
def _agent_age_seconds(state: dict) -> float:
|
||||||
"""Seconds elapsed since the agent was launched, from the state file timestamp."""
|
"""Seconds elapsed since the agent was launched, from the state file timestamp."""
|
||||||
try:
|
try:
|
||||||
@@ -332,11 +388,13 @@ def _git_summary() -> str:
|
|||||||
def _kill_agent(state: dict) -> None:
|
def _kill_agent(state: dict) -> None:
|
||||||
"""Forcefully stop the running agent."""
|
"""Forcefully stop the running agent."""
|
||||||
pid = state.get("pid")
|
pid = state.get("pid")
|
||||||
if pid:
|
if pid and _is_claude_process(pid):
|
||||||
try:
|
try:
|
||||||
os.kill(pid, 9)
|
os.kill(pid, 9)
|
||||||
except ProcessLookupError:
|
except ProcessLookupError:
|
||||||
pass
|
pass
|
||||||
|
elif pid:
|
||||||
|
print(f"WARNING: pid {pid} is not a claude process — skipping kill to avoid hitting recycled PID")
|
||||||
|
|
||||||
|
|
||||||
# ── subcommands ───────────────────────────────────────────────────────────────
|
# ── subcommands ───────────────────────────────────────────────────────────────
|
||||||
@@ -474,6 +532,9 @@ def _run_loop() -> int:
|
|||||||
"Fetch the CI logs using the task ci-logs command or the Codeberg API. "
|
"Fetch the CI logs using the task ci-logs command or the Codeberg API. "
|
||||||
"Identify the failure, fix it, commit, and push to the same branch. "
|
"Identify the failure, fix it, commit, and push to the same branch. "
|
||||||
"Do NOT push to main, do NOT close the issue, do NOT merge the PR. "
|
"Do NOT push to main, do NOT close the issue, do NOT merge the PR. "
|
||||||
|
"Do NOT reference any issue numbers in commit messages "
|
||||||
|
"(no 'closes #N', 'fixes #N', or similar) — auto-closing the wrong "
|
||||||
|
"issue via a commit message would be a bug. "
|
||||||
"Verify locally with 'task check' before pushing. "
|
"Verify locally with 'task check' before pushing. "
|
||||||
"When done, stop."
|
"When done, stop."
|
||||||
)
|
)
|
||||||
@@ -512,7 +573,25 @@ def _run_loop() -> int:
|
|||||||
|
|
||||||
# CI passed on the PR branch — squash-merge and close.
|
# CI passed on the PR branch — squash-merge and close.
|
||||||
print(f"CI passed {_ci_run_url(pr_run['id'])} on branch {branch!r} — merging PR #{pr_number}.")
|
print(f"CI passed {_ci_run_url(pr_run['id'])} on branch {branch!r} — merging PR #{pr_number}.")
|
||||||
_merge_pr(pr_number)
|
try:
|
||||||
|
_merge_pr(pr_number)
|
||||||
|
except RuntimeError as e:
|
||||||
|
print(f"Merge of PR #{pr_number} failed: {e} — setting to State/Question.")
|
||||||
|
_set_labels(pending_issue, add=[LABEL_QUESTION], remove=[LABEL_IN_PROGRESS])
|
||||||
|
_comment_issue(
|
||||||
|
pending_issue,
|
||||||
|
f"Automatic merge of PR #{pr_number} failed: {e}. Please merge manually.",
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
if _find_pr_for_branch(branch):
|
||||||
|
print(f"PR #{pr_number} is still open after merge attempt — setting to State/Question.")
|
||||||
|
_set_labels(pending_issue, add=[LABEL_QUESTION], remove=[LABEL_IN_PROGRESS])
|
||||||
|
_comment_issue(
|
||||||
|
pending_issue,
|
||||||
|
f"Automatic merge of PR #{pr_number} failed (PR is still open after the "
|
||||||
|
"merge command). Please merge manually.",
|
||||||
|
)
|
||||||
|
return 0
|
||||||
_close_issue(pending_issue)
|
_close_issue(pending_issue)
|
||||||
print(f"Merged PR #{pr_number} and closed {_issue_url(pending_issue)}.")
|
print(f"Merged PR #{pr_number} and closed {_issue_url(pending_issue)}.")
|
||||||
return 0
|
return 0
|
||||||
@@ -538,8 +617,59 @@ def _run_loop() -> int:
|
|||||||
)
|
)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
# ── 3. Global CI check (agent pushed to main, or no pending issue) ────────
|
# ── 2b. Catch-up: scan open issue-N-fix PRs orphaned by a cleared state ─────
|
||||||
run = _latest_ci_run()
|
# This handles PRs whose CI has passed but were never merged because the
|
||||||
|
# state file was cleared (loop restart, killed agent, manual intervention).
|
||||||
|
open_prs = _open_issue_prs()
|
||||||
|
for pr in open_prs:
|
||||||
|
pr_number = pr["number"]
|
||||||
|
pr_url = f"{REPO_URL}/pulls/{pr_number}"
|
||||||
|
head = pr.get("head", {})
|
||||||
|
branch = head.get("ref") or head.get("label", "").split(":")[-1]
|
||||||
|
m = re.match(r"^issue-(\d+)-fix$", branch or "")
|
||||||
|
issue_num = int(m.group(1)) if m else None
|
||||||
|
pr_run = _latest_ci_run_for_pr(pr_number)
|
||||||
|
|
||||||
|
if pr_run and pr_run.get("status") == "running":
|
||||||
|
print(f"Catch-up: CI {_ci_run_url(pr_run['id'])} on PR #{pr_number} still running. Waiting.")
|
||||||
|
_write_state(None, issue_num, "pending-ci")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if pr_run and pr_run.get("status") in ("failure", "error"):
|
||||||
|
print(f"Catch-up: CI {_ci_run_url(pr_run['id'])} on PR #{pr_number} failed — skipping.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if pr_run and pr_run.get("status") == "success":
|
||||||
|
print(f"Catch-up: CI passed on PR #{pr_number} ({pr_url}) — merging.")
|
||||||
|
try:
|
||||||
|
_merge_pr(pr_number)
|
||||||
|
except RuntimeError as e:
|
||||||
|
print(f"Catch-up: merge of PR #{pr_number} failed: {e} — skipping.")
|
||||||
|
continue
|
||||||
|
# Verify the merge actually happened; fgj can exit 0 without merging
|
||||||
|
# (e.g. branch-protection rules not satisfied).
|
||||||
|
if _find_pr_for_branch(branch):
|
||||||
|
print(
|
||||||
|
f"Catch-up: PR #{pr_number} is still open after merge attempt "
|
||||||
|
"— skipping to avoid infinite retry."
|
||||||
|
)
|
||||||
|
if issue_num:
|
||||||
|
_set_labels(issue_num, add=[LABEL_QUESTION], remove=[LABEL_IN_PROGRESS])
|
||||||
|
_comment_issue(
|
||||||
|
issue_num,
|
||||||
|
f"Automatic merge of PR #{pr_number} failed (PR is still open "
|
||||||
|
"after the merge command). Please merge manually.",
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if issue_num:
|
||||||
|
_close_issue(issue_num)
|
||||||
|
print(f"Merged PR #{pr_number} and closed issue #{issue_num}.")
|
||||||
|
else:
|
||||||
|
print(f"Merged PR #{pr_number}.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# ── 3. Global CI check (main branch only) ────────────────────────────────
|
||||||
|
run = _latest_main_ci_run()
|
||||||
|
|
||||||
if run and run.get("status") == "running":
|
if run and run.get("status") == "running":
|
||||||
print(f"CI run {_ci_run_url(run['id'])} is still running. Waiting.")
|
print(f"CI run {_ci_run_url(run['id'])} is still running. Waiting.")
|
||||||
@@ -548,17 +678,39 @@ def _run_loop() -> int:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
if run and run.get("status") in ("failure", "error"):
|
if run and run.get("status") in ("failure", "error"):
|
||||||
|
# Guard: if the same main CI run has been failing since the last ci-fix
|
||||||
|
# agent started, that agent pushed to a branch instead of main. Before
|
||||||
|
# spawning another agent, check whether any CI run is currently in
|
||||||
|
# progress (the branch run) and wait if so.
|
||||||
|
if ci_run_id_at_start is not None and run["id"] == ci_run_id_at_start:
|
||||||
|
check = _tea_get(f"repos/{REPO}/actions/runs?limit=5")
|
||||||
|
in_flight = [
|
||||||
|
r for r in (check or {}).get("workflow_runs", [])
|
||||||
|
if r.get("status") == "running"
|
||||||
|
]
|
||||||
|
if in_flight:
|
||||||
|
print(
|
||||||
|
f"Main CI still shows the same failed run {run['id']}; "
|
||||||
|
f"{_ci_run_url(in_flight[0]['id'])} is running "
|
||||||
|
"(previous ci-fix pushed to a branch). Waiting."
|
||||||
|
)
|
||||||
|
return 0
|
||||||
print(f"CI run {_ci_run_url(run['id'])} failed — starting fix agent.")
|
print(f"CI run {_ci_run_url(run['id'])} failed — starting fix agent.")
|
||||||
prompt = (
|
prompt = (
|
||||||
"The Codeberg CI for guettli/sharedinbox just failed. "
|
"The Codeberg CI for guettli/sharedinbox just failed on the main branch. "
|
||||||
f"The CI run ID is {run['id']}. "
|
f"The CI run ID is {run['id']}. "
|
||||||
"Fetch the CI logs using the task ci-logs command or the Codeberg API. "
|
"Fetch the CI logs using the task ci-logs command or the Codeberg API. "
|
||||||
"Identify the failure, fix it, commit, and push. "
|
"Identify the failure, fix it, commit, and push directly to main. "
|
||||||
"Verify locally with 'task check' before pushing. "
|
"Verify locally with 'task check' before pushing. "
|
||||||
|
"Do NOT reference any issue numbers in commit messages "
|
||||||
|
"(no 'closes #N', 'fixes #N', or similar) — this is a CI fix, "
|
||||||
|
"not an issue fix, and auto-closing an issue via a commit message would be a bug. "
|
||||||
|
"Do NOT close any issues. "
|
||||||
"When done, stop."
|
"When done, stop."
|
||||||
)
|
)
|
||||||
pid = _start_agent(prompt, "ci-fix")
|
pid = _start_agent(prompt, "ci-fix")
|
||||||
_write_state(pid, pending_issue, "ci-fix", session_name="ci-fix")
|
_write_state(pid, pending_issue, "ci-fix", session_name="ci-fix",
|
||||||
|
ci_run_id=run["id"] if run else None)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
# CI is ok (or no run).
|
# CI is ok (or no run).
|
||||||
@@ -617,7 +769,10 @@ Instructions:
|
|||||||
- Implement the required change, following the existing code style.
|
- Implement the required change, following the existing code style.
|
||||||
- Write or update tests as appropriate.
|
- Write or update tests as appropriate.
|
||||||
- Run 'task check' locally and fix any failures before committing.
|
- Run 'task check' locally and fix any failures before committing.
|
||||||
- Commit with a descriptive message referencing the issue number (e.g. "feat: ... (#{issue_number})").
|
- Commit with a descriptive message and include (#{issue_number}) in the title,
|
||||||
|
e.g. "feat: description (#{issue_number})".
|
||||||
|
Do NOT use "Closes #N" or "Fixes #N" keywords — the loop closes the issue
|
||||||
|
after CI passes; using those keywords would close it prematurely or wrongly.
|
||||||
- Create a branch named `issue-{issue_number}-fix`, push your changes there, and open a PR against main:
|
- Create a branch named `issue-{issue_number}-fix`, push your changes there, and open a PR against main:
|
||||||
git checkout -b issue-{issue_number}-fix
|
git checkout -b issue-{issue_number}-fix
|
||||||
git push -u origin issue-{issue_number}-fix
|
git push -u origin issue-{issue_number}-fix
|
||||||
|
|||||||
+60
-67
@@ -6,76 +6,49 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
|
||||||
import requests
|
|
||||||
from google.auth.transport.requests import AuthorizedSession
|
from google.auth.transport.requests import AuthorizedSession
|
||||||
from google.oauth2 import service_account
|
from google.oauth2 import service_account
|
||||||
|
|
||||||
PACKAGE_NAME = "de.sharedinbox.mua"
|
PACKAGE_NAME = "de.sharedinbox.mua"
|
||||||
AAB_PATH = "build/app/outputs/bundle/release/app-release.aab"
|
AAB_PATH = "build/app/outputs/bundle/release/app-release.aab"
|
||||||
TRACK = "internal"
|
TRACK = "internal"
|
||||||
_TIMEOUT = 300 # seconds — AAB uploads can be large
|
|
||||||
_MAX_UPLOAD_ATTEMPTS = 3
|
|
||||||
_BASE = "https://androidpublisher.googleapis.com/androidpublisher/v3/applications"
|
_BASE = "https://androidpublisher.googleapis.com/androidpublisher/v3/applications"
|
||||||
_UPLOAD_BASE = "https://androidpublisher.googleapis.com/upload/androidpublisher/v3/applications"
|
_UPLOAD_BASE = "https://androidpublisher.googleapis.com/upload/androidpublisher/v3/applications"
|
||||||
|
_MAX_UPLOAD_ATTEMPTS = 3
|
||||||
|
|
||||||
|
|
||||||
def _make_session(config_json: str) -> AuthorizedSession:
|
def _upload_aab_resumable(session, package, edit_id, aab_path):
|
||||||
creds = service_account.Credentials.from_service_account_info(
|
"""Upload AAB using the Google resumable upload protocol."""
|
||||||
json.loads(config_json),
|
file_size = os.path.getsize(aab_path)
|
||||||
scopes=["https://www.googleapis.com/auth/androidpublisher"],
|
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,
|
||||||
)
|
)
|
||||||
return AuthorizedSession(creds)
|
init_resp.raise_for_status()
|
||||||
|
upload_url = init_resp.headers["Location"]
|
||||||
|
|
||||||
|
# Step 2: upload the file in a single PUT to the session URI
|
||||||
def _upload_aab(session: AuthorizedSession, edit_id: str) -> int:
|
with open(aab_path, "rb") as f:
|
||||||
"""Resumable upload of the AAB. Returns the version code."""
|
upload_resp = session.put(
|
||||||
file_size = os.path.getsize(AAB_PATH)
|
upload_url,
|
||||||
|
data=f,
|
||||||
with open(AAB_PATH, "rb") as f:
|
headers={
|
||||||
data = f.read()
|
"Content-Type": "application/octet-stream",
|
||||||
|
"Content-Length": str(file_size),
|
||||||
last_exc = None
|
},
|
||||||
for attempt in range(_MAX_UPLOAD_ATTEMPTS):
|
timeout=600,
|
||||||
try:
|
)
|
||||||
# Each attempt needs a fresh resumable upload URL — the previous URL expires on failure.
|
upload_resp.raise_for_status()
|
||||||
init_resp = session.post(
|
return upload_resp.json()
|
||||||
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():
|
def main():
|
||||||
@@ -88,25 +61,45 @@ def main():
|
|||||||
print(f"Error: AAB not found at {AAB_PATH}", file=sys.stderr)
|
print(f"Error: AAB not found at {AAB_PATH}", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
session = _make_session(config_json)
|
creds = service_account.Credentials.from_service_account_info(
|
||||||
|
json.loads(config_json),
|
||||||
edit_resp = session.post(
|
scopes=["https://www.googleapis.com/auth/androidpublisher"],
|
||||||
f"{_BASE}/{PACKAGE_NAME}/edits",
|
|
||||||
json={},
|
|
||||||
timeout=30,
|
|
||||||
)
|
)
|
||||||
|
session = AuthorizedSession(creds)
|
||||||
|
|
||||||
|
edit_resp = session.post(f"{_BASE}/{PACKAGE_NAME}/edits", json={}, timeout=30)
|
||||||
edit_resp.raise_for_status()
|
edit_resp.raise_for_status()
|
||||||
edit_id = edit_resp.json()["id"]
|
edit_id = edit_resp.json()["id"]
|
||||||
|
|
||||||
version_code = _upload_aab(session, edit_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"]
|
||||||
print(f"Uploaded AAB, version code: {version_code}")
|
print(f"Uploaded AAB, version code: {version_code}")
|
||||||
|
|
||||||
tracks_resp = session.put(
|
track_resp = session.put(
|
||||||
f"{_BASE}/{PACKAGE_NAME}/edits/{edit_id}/tracks/{TRACK}",
|
f"{_BASE}/{PACKAGE_NAME}/edits/{edit_id}/tracks/{TRACK}",
|
||||||
json={"releases": [{"versionCodes": [version_code], "status": "completed"}]},
|
json={"releases": [{"versionCodes": [version_code], "status": "completed"}]},
|
||||||
timeout=30,
|
timeout=30,
|
||||||
)
|
)
|
||||||
tracks_resp.raise_for_status()
|
track_resp.raise_for_status()
|
||||||
|
|
||||||
commit_resp = session.post(
|
commit_resp = session.post(
|
||||||
f"{_BASE}/{PACKAGE_NAME}/edits/{edit_id}:commit",
|
f"{_BASE}/{PACKAGE_NAME}/edits/{edit_id}:commit",
|
||||||
|
|||||||
@@ -33,9 +33,6 @@ def list_remote_files(ssh_user: str, ssh_host: str, pattern: str) -> list[str]:
|
|||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[
|
[
|
||||||
"ssh",
|
"ssh",
|
||||||
"-v",
|
|
||||||
"-o", "StrictHostKeyChecking=no",
|
|
||||||
"-i", "/root/.ssh/id_ed25519",
|
|
||||||
f"{ssh_user}@{ssh_host}",
|
f"{ssh_user}@{ssh_host}",
|
||||||
f"find {REMOTE_BUILDS_DIR} -name '{pattern}' -type f | sort",
|
f"find {REMOTE_BUILDS_DIR} -name '{pattern}' -type f | sort",
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -14,14 +14,42 @@ if [ "$host" == "$port" ]; then
|
|||||||
port="8774"
|
port="8774"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Probing $host:$port..."
|
MAX_PROBE_ATTEMPTS=5
|
||||||
if ! nc -zw 3 "$host" "$port" 2>/dev/null; then
|
PROBE_DELAY=30
|
||||||
echo "Error: No Dagger server responded on $host:$port"
|
for attempt in $(seq 1 $MAX_PROBE_ATTEMPTS); do
|
||||||
exit 1
|
echo "Probing $host:$port (attempt $attempt/$MAX_PROBE_ATTEMPTS)..."
|
||||||
fi
|
if nc -zw 5 "$host" "$port" 2>/dev/null; then
|
||||||
echo "Found active Dagger server on $host:$port"
|
echo "Found active server on $host:$port"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if [ "$attempt" -eq "$MAX_PROBE_ATTEMPTS" ]; then
|
||||||
|
echo "Warning: No Dagger server responded on $host:$port after $MAX_PROBE_ATTEMPTS attempts"
|
||||||
|
echo "Remote engine unavailable — CI will use the local Dagger engine."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "Dagger server not responding, waiting ${PROBE_DELAY}s before retry..."
|
||||||
|
sleep $PROBE_DELAY
|
||||||
|
done
|
||||||
|
|
||||||
# 2. Setup TLS credentials (passed as env vars from secrets)
|
# 2a. Try plain TCP connection first (works when server is a plain TCP proxy, no TLS)
|
||||||
|
echo "Trying plain TCP Dagger connection at tcp://$host:$port..."
|
||||||
|
if _DAGGER_RUNNER_HOST="tcp://$host:$port" \
|
||||||
|
_EXPERIMENTAL_DAGGER_RUNNER_HOST="tcp://$host:$port" \
|
||||||
|
timeout 8 dagger version >/dev/null 2>&1; then
|
||||||
|
echo "Plain TCP Dagger connection succeeded — no TLS stunnel needed."
|
||||||
|
if [ -n "${GITHUB_ENV:-}" ]; then
|
||||||
|
echo "_EXPERIMENTAL_DAGGER_RUNNER_HOST=tcp://$host:$port" >> "$GITHUB_ENV"
|
||||||
|
echo "_DAGGER_RUNNER_HOST=tcp://$host:$port" >> "$GITHUB_ENV"
|
||||||
|
else
|
||||||
|
export _EXPERIMENTAL_DAGGER_RUNNER_HOST="tcp://$host:$port"
|
||||||
|
export _DAGGER_RUNNER_HOST="tcp://$host:$port"
|
||||||
|
echo "Dagger configured at tcp://$host:$port (plain TCP)"
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "Plain TCP connection not available; trying TLS stunnel..."
|
||||||
|
|
||||||
|
# 2b. Setup TLS credentials (passed as env vars from secrets)
|
||||||
mkdir -p /tmp/dagger-tls
|
mkdir -p /tmp/dagger-tls
|
||||||
echo "$DAGGER_CA_CERT" > /tmp/dagger-tls/ca.crt
|
echo "$DAGGER_CA_CERT" > /tmp/dagger-tls/ca.crt
|
||||||
echo "$DAGGER_CLIENT_CERT" > /tmp/dagger-tls/client.crt
|
echo "$DAGGER_CLIENT_CERT" > /tmp/dagger-tls/client.crt
|
||||||
|
|||||||
+82
-17
@@ -88,21 +88,47 @@ class TestAgentAlive(unittest.TestCase):
|
|||||||
self.assertFalse(agent_loop._agent_alive({"pid": None}))
|
self.assertFalse(agent_loop._agent_alive({"pid": None}))
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsClaudeProcess(unittest.TestCase):
|
||||||
|
def test_returns_true_for_claude_comm(self):
|
||||||
|
with patch.object(agent_loop.Path, "read_text", return_value="claude\n"):
|
||||||
|
self.assertTrue(agent_loop._is_claude_process(1234))
|
||||||
|
|
||||||
|
def test_returns_true_for_node_comm(self):
|
||||||
|
with patch.object(agent_loop.Path, "read_text", return_value="node\n"):
|
||||||
|
self.assertTrue(agent_loop._is_claude_process(1234))
|
||||||
|
|
||||||
|
def test_returns_false_for_other_process(self):
|
||||||
|
with patch.object(agent_loop.Path, "read_text", return_value="bash\n"):
|
||||||
|
self.assertFalse(agent_loop._is_claude_process(1234))
|
||||||
|
|
||||||
|
def test_returns_false_when_proc_missing(self):
|
||||||
|
with patch.object(agent_loop.Path, "read_text", side_effect=OSError):
|
||||||
|
self.assertFalse(agent_loop._is_claude_process(1234))
|
||||||
|
|
||||||
|
|
||||||
class TestKillAgent(unittest.TestCase):
|
class TestKillAgent(unittest.TestCase):
|
||||||
def test_kill_sends_sigkill(self):
|
def test_kill_sends_sigkill(self):
|
||||||
with patch("agent_loop.os.kill") as mock_kill:
|
with patch("agent_loop._is_claude_process", return_value=True):
|
||||||
agent_loop._kill_agent({"pid": 1234})
|
with patch("agent_loop.os.kill") as mock_kill:
|
||||||
mock_kill.assert_called_once_with(1234, 9)
|
agent_loop._kill_agent({"pid": 1234})
|
||||||
|
mock_kill.assert_called_once_with(1234, 9)
|
||||||
|
|
||||||
def test_kill_ignores_missing_process(self):
|
def test_kill_ignores_missing_process(self):
|
||||||
with patch("agent_loop.os.kill", side_effect=ProcessLookupError):
|
with patch("agent_loop._is_claude_process", return_value=True):
|
||||||
agent_loop._kill_agent({"pid": 1234}) # Should not raise.
|
with patch("agent_loop.os.kill", side_effect=ProcessLookupError):
|
||||||
|
agent_loop._kill_agent({"pid": 1234}) # Should not raise.
|
||||||
|
|
||||||
def test_kill_noop_when_no_pid(self):
|
def test_kill_noop_when_no_pid(self):
|
||||||
with patch("agent_loop.os.kill") as mock_kill:
|
with patch("agent_loop.os.kill") as mock_kill:
|
||||||
agent_loop._kill_agent({})
|
agent_loop._kill_agent({})
|
||||||
mock_kill.assert_not_called()
|
mock_kill.assert_not_called()
|
||||||
|
|
||||||
|
def test_kill_skips_recycled_pid(self):
|
||||||
|
with patch("agent_loop._is_claude_process", return_value=False):
|
||||||
|
with patch("agent_loop.os.kill") as mock_kill:
|
||||||
|
agent_loop._kill_agent({"pid": 1234})
|
||||||
|
mock_kill.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
class TestStartAgent(unittest.TestCase):
|
class TestStartAgent(unittest.TestCase):
|
||||||
def _make_mock_proc(self, pid=42):
|
def _make_mock_proc(self, pid=42):
|
||||||
@@ -174,7 +200,8 @@ class TestMain(unittest.TestCase):
|
|||||||
return 55
|
return 55
|
||||||
|
|
||||||
with patch("agent_loop._read_state", return_value=None), \
|
with patch("agent_loop._read_state", return_value=None), \
|
||||||
patch("agent_loop._latest_ci_run", return_value=None), \
|
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||||
|
patch("agent_loop._latest_main_ci_run", return_value=None), \
|
||||||
patch("agent_loop._ready_issues", return_value=[self._make_issue(10)]), \
|
patch("agent_loop._ready_issues", return_value=[self._make_issue(10)]), \
|
||||||
patch("agent_loop._set_labels", side_effect=fake_set_labels), \
|
patch("agent_loop._set_labels", side_effect=fake_set_labels), \
|
||||||
patch("agent_loop._start_agent", side_effect=fake_start_agent), \
|
patch("agent_loop._start_agent", side_effect=fake_start_agent), \
|
||||||
@@ -200,7 +227,8 @@ class TestMain(unittest.TestCase):
|
|||||||
captured["remove"] = remove
|
captured["remove"] = remove
|
||||||
|
|
||||||
with patch("agent_loop._read_state", return_value=None), \
|
with patch("agent_loop._read_state", return_value=None), \
|
||||||
patch("agent_loop._latest_ci_run", return_value=None), \
|
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||||
|
patch("agent_loop._latest_main_ci_run", return_value=None), \
|
||||||
patch("agent_loop._ready_issues", return_value=[self._make_issue(7)]), \
|
patch("agent_loop._ready_issues", return_value=[self._make_issue(7)]), \
|
||||||
patch("agent_loop._set_labels", side_effect=fake_set_labels), \
|
patch("agent_loop._set_labels", side_effect=fake_set_labels), \
|
||||||
patch("agent_loop._start_agent", return_value=99), \
|
patch("agent_loop._start_agent", return_value=99), \
|
||||||
@@ -213,7 +241,8 @@ class TestMain(unittest.TestCase):
|
|||||||
def test_no_ready_issues_does_nothing(self):
|
def test_no_ready_issues_does_nothing(self):
|
||||||
"""main() exits cleanly with 0 when there are no ready issues."""
|
"""main() exits cleanly with 0 when there are no ready issues."""
|
||||||
with patch("agent_loop._read_state", return_value=None), \
|
with patch("agent_loop._read_state", return_value=None), \
|
||||||
patch("agent_loop._latest_ci_run", return_value=None), \
|
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||||
|
patch("agent_loop._latest_main_ci_run", return_value=None), \
|
||||||
patch("agent_loop._ready_issues", return_value=[]), \
|
patch("agent_loop._ready_issues", return_value=[]), \
|
||||||
patch("agent_loop._set_labels") as mock_labels, \
|
patch("agent_loop._set_labels") as mock_labels, \
|
||||||
patch("agent_loop._start_agent") as mock_start:
|
patch("agent_loop._start_agent") as mock_start:
|
||||||
@@ -232,7 +261,8 @@ class TestMain(unittest.TestCase):
|
|||||||
return 77
|
return 77
|
||||||
|
|
||||||
with patch("agent_loop._read_state", return_value=None), \
|
with patch("agent_loop._read_state", return_value=None), \
|
||||||
patch("agent_loop._latest_ci_run", return_value=None), \
|
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||||
|
patch("agent_loop._latest_main_ci_run", return_value=None), \
|
||||||
patch("agent_loop._ready_issues", return_value=[self._make_issue(42)]), \
|
patch("agent_loop._ready_issues", return_value=[self._make_issue(42)]), \
|
||||||
patch("agent_loop._set_labels"), \
|
patch("agent_loop._set_labels"), \
|
||||||
patch("agent_loop._start_agent", side_effect=fake_start_agent), \
|
patch("agent_loop._start_agent", side_effect=fake_start_agent), \
|
||||||
@@ -266,8 +296,9 @@ class TestPendingCi(unittest.TestCase):
|
|||||||
|
|
||||||
def test_closes_issue_when_ci_passes_after_agent_finishes(self):
|
def test_closes_issue_when_ci_passes_after_agent_finishes(self):
|
||||||
"""After issue agent finishes, loop merges the PR and closes the issue once CI is green."""
|
"""After issue agent finishes, loop merges the PR and closes the issue once CI is green."""
|
||||||
|
# First call: PR found open. Second call (post-merge verification): PR closed.
|
||||||
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
|
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
|
||||||
patch("agent_loop._find_pr_for_branch", side_effect=self._find_pr_open), \
|
patch("agent_loop._find_pr_for_branch", side_effect=[self._open_pr(), None]), \
|
||||||
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 1, "status": "success"}), \
|
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 1, "status": "success"}), \
|
||||||
patch("agent_loop._merge_pr") as mock_merge, \
|
patch("agent_loop._merge_pr") as mock_merge, \
|
||||||
patch("agent_loop._close_issue") as mock_close, \
|
patch("agent_loop._close_issue") as mock_close, \
|
||||||
@@ -282,7 +313,7 @@ class TestPendingCi(unittest.TestCase):
|
|||||||
"""'CI passed' line includes the CI run URL when a run is available."""
|
"""'CI passed' line includes the CI run URL when a run is available."""
|
||||||
buf = io.StringIO()
|
buf = io.StringIO()
|
||||||
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
|
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
|
||||||
patch("agent_loop._find_pr_for_branch", side_effect=self._find_pr_open), \
|
patch("agent_loop._find_pr_for_branch", side_effect=[self._open_pr(), None]), \
|
||||||
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 4145144, "status": "success"}), \
|
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 4145144, "status": "success"}), \
|
||||||
patch("agent_loop._merge_pr"), \
|
patch("agent_loop._merge_pr"), \
|
||||||
patch("agent_loop._close_issue"), \
|
patch("agent_loop._close_issue"), \
|
||||||
@@ -392,7 +423,7 @@ class TestPendingCi(unittest.TestCase):
|
|||||||
def test_closes_issue_after_ci_fix_and_ci_passes(self):
|
def test_closes_issue_after_ci_fix_and_ci_passes(self):
|
||||||
"""After ci-fix agent finishes and CI passes on PR branch, the pending issue is closed."""
|
"""After ci-fix agent finishes and CI passes on PR branch, the pending issue is closed."""
|
||||||
with patch("agent_loop._read_state", return_value=self._dead_state(10, "ci-fix")), \
|
with patch("agent_loop._read_state", return_value=self._dead_state(10, "ci-fix")), \
|
||||||
patch("agent_loop._find_pr_for_branch", side_effect=self._find_pr_open), \
|
patch("agent_loop._find_pr_for_branch", side_effect=[self._open_pr(), None]), \
|
||||||
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 1, "status": "success"}), \
|
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 1, "status": "success"}), \
|
||||||
patch("agent_loop._merge_pr") as mock_merge, \
|
patch("agent_loop._merge_pr") as mock_merge, \
|
||||||
patch("agent_loop._close_issue") as mock_close, \
|
patch("agent_loop._close_issue") as mock_close, \
|
||||||
@@ -409,7 +440,8 @@ class TestPendingCi(unittest.TestCase):
|
|||||||
"pid": 999999999, "issue": None, "started_at": "2026-01-01T00:00:00+00:00",
|
"pid": 999999999, "issue": None, "started_at": "2026-01-01T00:00:00+00:00",
|
||||||
"type": "ci-fix",
|
"type": "ci-fix",
|
||||||
}), \
|
}), \
|
||||||
patch("agent_loop._latest_ci_run", return_value={"id": 1, "status": "success"}), \
|
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||||
|
patch("agent_loop._latest_main_ci_run", return_value={"id": 1, "status": "success"}), \
|
||||||
patch("agent_loop._close_issue") as mock_close, \
|
patch("agent_loop._close_issue") as mock_close, \
|
||||||
patch("agent_loop._ready_issues", return_value=[]), \
|
patch("agent_loop._ready_issues", return_value=[]), \
|
||||||
patch("agent_loop._clear_state"):
|
patch("agent_loop._clear_state"):
|
||||||
@@ -425,7 +457,8 @@ class TestOutputFormat(unittest.TestCase):
|
|||||||
def test_output_starts_with_header(self):
|
def test_output_starts_with_header(self):
|
||||||
buf = io.StringIO()
|
buf = io.StringIO()
|
||||||
with patch("agent_loop._read_state", return_value=None), \
|
with patch("agent_loop._read_state", return_value=None), \
|
||||||
patch("agent_loop._latest_ci_run", return_value=None), \
|
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||||
|
patch("agent_loop._latest_main_ci_run", return_value=None), \
|
||||||
patch("agent_loop._ready_issues", return_value=[]), \
|
patch("agent_loop._ready_issues", return_value=[]), \
|
||||||
contextlib.redirect_stdout(buf):
|
contextlib.redirect_stdout(buf):
|
||||||
agent_loop._run_loop()
|
agent_loop._run_loop()
|
||||||
@@ -436,7 +469,8 @@ class TestOutputFormat(unittest.TestCase):
|
|||||||
def test_no_agent_loop_prefix_in_output(self):
|
def test_no_agent_loop_prefix_in_output(self):
|
||||||
buf = io.StringIO()
|
buf = io.StringIO()
|
||||||
with patch("agent_loop._read_state", return_value=None), \
|
with patch("agent_loop._read_state", return_value=None), \
|
||||||
patch("agent_loop._latest_ci_run", return_value=None), \
|
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||||
|
patch("agent_loop._latest_main_ci_run", return_value=None), \
|
||||||
patch("agent_loop._ready_issues", return_value=[]), \
|
patch("agent_loop._ready_issues", return_value=[]), \
|
||||||
contextlib.redirect_stdout(buf):
|
contextlib.redirect_stdout(buf):
|
||||||
agent_loop._run_loop()
|
agent_loop._run_loop()
|
||||||
@@ -446,7 +480,8 @@ class TestOutputFormat(unittest.TestCase):
|
|||||||
run = {"id": 4145144, "status": "running"}
|
run = {"id": 4145144, "status": "running"}
|
||||||
buf = io.StringIO()
|
buf = io.StringIO()
|
||||||
with patch("agent_loop._read_state", return_value=None), \
|
with patch("agent_loop._read_state", return_value=None), \
|
||||||
patch("agent_loop._latest_ci_run", return_value=run), \
|
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||||
|
patch("agent_loop._latest_main_ci_run", return_value=run), \
|
||||||
contextlib.redirect_stdout(buf):
|
contextlib.redirect_stdout(buf):
|
||||||
agent_loop._run_loop()
|
agent_loop._run_loop()
|
||||||
self.assertIn("https://codeberg.org/guettli/sharedinbox/actions/runs/4145144",
|
self.assertIn("https://codeberg.org/guettli/sharedinbox/actions/runs/4145144",
|
||||||
@@ -456,7 +491,8 @@ class TestOutputFormat(unittest.TestCase):
|
|||||||
issue = {"number": 128, "title": "Fix something", "body": "", "labels": []}
|
issue = {"number": 128, "title": "Fix something", "body": "", "labels": []}
|
||||||
buf = io.StringIO()
|
buf = io.StringIO()
|
||||||
with patch("agent_loop._read_state", return_value=None), \
|
with patch("agent_loop._read_state", return_value=None), \
|
||||||
patch("agent_loop._latest_ci_run", return_value=None), \
|
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||||
|
patch("agent_loop._latest_main_ci_run", return_value=None), \
|
||||||
patch("agent_loop._ready_issues", return_value=[issue]), \
|
patch("agent_loop._ready_issues", return_value=[issue]), \
|
||||||
patch("agent_loop._set_labels"), \
|
patch("agent_loop._set_labels"), \
|
||||||
patch("agent_loop._start_agent", return_value=99), \
|
patch("agent_loop._start_agent", return_value=99), \
|
||||||
@@ -468,6 +504,35 @@ class TestOutputFormat(unittest.TestCase):
|
|||||||
self.assertIn("Fix something", output)
|
self.assertIn("Fix something", output)
|
||||||
|
|
||||||
|
|
||||||
|
class TestLatestMainCiRun(unittest.TestCase):
|
||||||
|
"""_latest_main_ci_run() must return only push-to-main runs, ignoring schedule/deploy workflows."""
|
||||||
|
|
||||||
|
def test_skips_schedule_runs_returns_push_to_main(self):
|
||||||
|
runs = [
|
||||||
|
{"event": "schedule", "prettyref": "main", "status": "success", "id": 1},
|
||||||
|
{"event": "push", "prettyref": "main", "status": "success", "id": 2},
|
||||||
|
]
|
||||||
|
with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}):
|
||||||
|
result = agent_loop._latest_main_ci_run()
|
||||||
|
self.assertIsNotNone(result)
|
||||||
|
self.assertEqual(result["id"], 2)
|
||||||
|
|
||||||
|
def test_returns_none_when_only_schedule_runs_exist(self):
|
||||||
|
runs = [
|
||||||
|
{"event": "schedule", "prettyref": "main", "status": "success", "id": 1},
|
||||||
|
]
|
||||||
|
with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}):
|
||||||
|
result = agent_loop._latest_main_ci_run()
|
||||||
|
self.assertIsNone(result)
|
||||||
|
|
||||||
|
def test_returns_push_to_main_run(self):
|
||||||
|
runs = [{"event": "push", "prettyref": "main", "status": "running", "id": 42}]
|
||||||
|
with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}):
|
||||||
|
result = agent_loop._latest_main_ci_run()
|
||||||
|
self.assertIsNotNone(result)
|
||||||
|
self.assertEqual(result["id"], 42)
|
||||||
|
|
||||||
|
|
||||||
class TestLatestCiRunForBranch(unittest.TestCase):
|
class TestLatestCiRunForBranch(unittest.TestCase):
|
||||||
"""Tests for _latest_ci_run_for_branch — Forgejo API field mapping."""
|
"""Tests for _latest_ci_run_for_branch — Forgejo API field mapping."""
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
#!/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()
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/services.dart' show MissingPluginException;
|
||||||
import 'package:mockito/annotations.dart';
|
import 'package:mockito/annotations.dart';
|
||||||
|
import 'package:sharedinbox/core/models/account.dart';
|
||||||
import 'package:sharedinbox/core/models/email.dart';
|
import 'package:sharedinbox/core/models/email.dart';
|
||||||
import 'package:sharedinbox/core/models/mailbox.dart';
|
import 'package:sharedinbox/core/models/mailbox.dart';
|
||||||
import 'package:sharedinbox/core/repositories/account_repository.dart';
|
import 'package:sharedinbox/core/repositories/account_repository.dart';
|
||||||
@@ -30,6 +32,40 @@ void main() {
|
|||||||
// This is hard to test without real loops, but we can verify it doesn't crash.
|
// This is hard to test without real loops, but we can verify it doesn't crash.
|
||||||
manager.syncNow('unknown');
|
manager.syncNow('unknown');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Regression test for issue #200: when flutter_secure_storage throws
|
||||||
|
// MissingPluginException (channel unavailable on the device), the IMAP sync
|
||||||
|
// loop must stop permanently instead of retrying indefinitely with backoff.
|
||||||
|
test(
|
||||||
|
'MissingPluginException from secure storage stops IMAP sync loop permanently',
|
||||||
|
() async {
|
||||||
|
final syncLog = FakeSyncLogRepository();
|
||||||
|
|
||||||
|
final m = AccountSyncManager(
|
||||||
|
_AccountRepositoryWithMissingPlugin(),
|
||||||
|
FakeMailboxRepositoryWithInbox(),
|
||||||
|
FakeEmailRepository(),
|
||||||
|
syncLog: syncLog,
|
||||||
|
);
|
||||||
|
|
||||||
|
m.start();
|
||||||
|
|
||||||
|
// Allow the first sync cycle to run and fail.
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||||
|
|
||||||
|
expect(syncLog.logs, hasLength(1));
|
||||||
|
expect(syncLog.logs.first.success, isFalse);
|
||||||
|
|
||||||
|
// Kicking the loop should have no effect once it has stopped permanently.
|
||||||
|
m.syncNow('1');
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||||
|
|
||||||
|
// Before the fix: kick triggers a retry → 2 log entries.
|
||||||
|
// After the fix: loop is permanently stopped → still exactly 1 entry.
|
||||||
|
expect(syncLog.logs, hasLength(1));
|
||||||
|
|
||||||
|
m.dispose();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
class FakeEmailRepository implements EmailRepository {
|
class FakeEmailRepository implements EmailRepository {
|
||||||
@@ -187,3 +223,34 @@ class FakeMailboxRepositoryWithInbox implements MailboxRepository {
|
|||||||
@override
|
@override
|
||||||
Future<void> clearForResync(String accountId) async {}
|
Future<void> clearForResync(String accountId) async {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _AccountRepositoryWithMissingPlugin implements AccountRepository {
|
||||||
|
static const _account = Account(
|
||||||
|
id: '1',
|
||||||
|
displayName: 'Test',
|
||||||
|
email: 'test@example.com',
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<List<Account>> observeAccounts() => Stream.value([_account]);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Account?> getAccount(String id) async => _account;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String> getPassword(String accountId) => Future.error(
|
||||||
|
MissingPluginException(
|
||||||
|
'No implementation found for method read on channel '
|
||||||
|
'plugins.it.nomads.com/flutter_secure_storage',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> addAccount(Account account, String password) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> updateAccount(Account account, {String? password}) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> removeAccount(String id) async {}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:fake_async/fake_async.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
|
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
|
||||||
@@ -19,6 +23,30 @@ class _UnavailablePathProvider extends Fake
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fake PathProviderPlatform that fails the first [failCount] calls, then
|
||||||
|
// returns a fixed path. Used to exercise the retry loop in
|
||||||
|
// _resolveDatabasePath() without waiting for real timers.
|
||||||
|
class _SucceedAfterNPathProvider extends Fake
|
||||||
|
with MockPlatformInterfaceMixin
|
||||||
|
implements PathProviderPlatform {
|
||||||
|
_SucceedAfterNPathProvider({required this.failCount});
|
||||||
|
|
||||||
|
final int failCount;
|
||||||
|
int _callCount = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> getApplicationSupportPath() async {
|
||||||
|
_callCount++;
|
||||||
|
if (_callCount <= failCount) {
|
||||||
|
throw PlatformException(
|
||||||
|
code: 'channel-error',
|
||||||
|
message: 'Simulated: path_provider channel not ready',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return '/tmp/test_app_support';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
TestWidgetsFlutterBinding.ensureInitialized();
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
@@ -38,4 +66,91 @@ void main() {
|
|||||||
await expectLater(initDatabasePath(), completes);
|
await expectLater(initDatabasePath(), completes);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Tests for _resolveDatabasePath() — the lazy retry path called on first DB
|
||||||
|
// access when initDatabasePath() already failed. fake_async lets us advance
|
||||||
|
// the back-off timers without waiting real-world milliseconds.
|
||||||
|
|
||||||
|
test(
|
||||||
|
'_resolveDatabasePath retries and eventually succeeds after transient failures',
|
||||||
|
() {
|
||||||
|
resetDatabasePathForTesting();
|
||||||
|
final prev = PathProviderPlatform.instance;
|
||||||
|
// Fail 3 times, succeed on the 4th call. The delays in
|
||||||
|
// _resolveDatabasePath are [200, 500, 1000, 2000, 4000] ms, so three
|
||||||
|
// failures cost 200+500+1000 = 1700 ms before the fourth attempt.
|
||||||
|
PathProviderPlatform.instance = _SucceedAfterNPathProvider(failCount: 3);
|
||||||
|
addTearDown(() {
|
||||||
|
PathProviderPlatform.instance = prev;
|
||||||
|
resetDatabasePathForTesting();
|
||||||
|
});
|
||||||
|
|
||||||
|
fakeAsync((fake) {
|
||||||
|
String? result;
|
||||||
|
unawaited(resolveDatabasePathForTesting().then((r) => result = r));
|
||||||
|
|
||||||
|
// Advance fake time through the three back-off delays.
|
||||||
|
fake.elapse(const Duration(milliseconds: 200 + 500 + 1000 + 1));
|
||||||
|
|
||||||
|
expect(result, isNotNull);
|
||||||
|
expect(result, endsWith('sharedinbox.db'));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'_resolveDatabasePath throws PlatformException after exhausting all retries',
|
||||||
|
() {
|
||||||
|
resetDatabasePathForTesting();
|
||||||
|
final prev = PathProviderPlatform.instance;
|
||||||
|
PathProviderPlatform.instance = _UnavailablePathProvider();
|
||||||
|
addTearDown(() {
|
||||||
|
PathProviderPlatform.instance = prev;
|
||||||
|
resetDatabasePathForTesting();
|
||||||
|
});
|
||||||
|
|
||||||
|
fakeAsync((fake) {
|
||||||
|
Object? caughtError;
|
||||||
|
unawaited(
|
||||||
|
resolveDatabasePathForTesting().catchError((Object e) {
|
||||||
|
caughtError = e;
|
||||||
|
return ''; // ignored; satisfies the Future<String> return type
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Advance past all five back-off delays: 200+500+1000+2000+4000 ms.
|
||||||
|
fake.elapse(
|
||||||
|
const Duration(milliseconds: 200 + 500 + 1000 + 2000 + 4000 + 1),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(caughtError, isA<PlatformException>());
|
||||||
|
expect(
|
||||||
|
(caughtError! as PlatformException).message,
|
||||||
|
contains('cannot open database'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
// The Android fallback runs only on Android, so on the host machine the
|
||||||
|
// exception is still thrown after all retries. Skip on Android to avoid
|
||||||
|
// depending on /data/user/0/... being absent in the test environment.
|
||||||
|
skip: Platform.isAndroid,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Regression test for issue #192: _androidFallbackPath must return null when
|
||||||
|
// the process cmdline does not look like an Android package name (e.g. on
|
||||||
|
// the host test machine where the process is the Dart executable).
|
||||||
|
test(
|
||||||
|
'_androidFallbackPath returns null when process name is not a package name',
|
||||||
|
() async {
|
||||||
|
// On non-Android platforms the host process cmdline is a file-system path
|
||||||
|
// (starts with '/'), which the fallback correctly rejects. On Android
|
||||||
|
// the process IS named after the package — the fallback is free to
|
||||||
|
// succeed or return null depending on the device state; we do not assert
|
||||||
|
// here so as not to constrain Android behaviour.
|
||||||
|
if (!Platform.isAndroid) {
|
||||||
|
final result = await androidFallbackPathForTesting();
|
||||||
|
expect(result, isNull);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -151,6 +151,10 @@ void main() {
|
|||||||
expect(clipboardText, contains('Dark Mode'));
|
expect(clipboardText, contains('Dark Mode'));
|
||||||
expect(clipboardText, contains('IMAP Accounts'));
|
expect(clipboardText, contains('IMAP Accounts'));
|
||||||
expect(clipboardText, contains('JMAP Accounts'));
|
expect(clipboardText, contains('JMAP Accounts'));
|
||||||
|
expect(
|
||||||
|
clipboardText,
|
||||||
|
contains('[sharedinbox.de](https://sharedinbox.de)'),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('AboutScreen create-issue button opens Codeberg URL', (
|
testWidgets('AboutScreen create-issue button opens Codeberg URL', (
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_riverpod/misc.dart' show Override;
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
|||||||
@@ -123,6 +123,50 @@ void main() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'CrashScreen shows git hash as clickable link above stacktrace',
|
||||||
|
(tester) async {
|
||||||
|
tester.view.physicalSize = const Size(800, 1200);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(() => tester.view.resetPhysicalSize());
|
||||||
|
|
||||||
|
final mock = MockUrlLauncher();
|
||||||
|
UrlLauncherPlatform.instance = mock;
|
||||||
|
|
||||||
|
const exception = 'TestException: git hash test';
|
||||||
|
final stackTrace = StackTrace.current;
|
||||||
|
const testHash = 'abc1234';
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
CrashScreen(
|
||||||
|
exception: exception,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
gitHash: testHash,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Git hash link should be present
|
||||||
|
final gitLinkFinder = find.textContaining('Git Commit: abc1234');
|
||||||
|
expect(gitLinkFinder, findsOneWidget);
|
||||||
|
|
||||||
|
// Link must appear above the stack trace
|
||||||
|
final stackTraceFinder = find.text('Stack Trace:');
|
||||||
|
expect(
|
||||||
|
tester.getTopLeft(gitLinkFinder).dy,
|
||||||
|
lessThan(tester.getTopLeft(stackTraceFinder).dy),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Tapping the link should open the Codeberg commit URL
|
||||||
|
await tester.tap(gitLinkFinder);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
mock.launchedUrl,
|
||||||
|
equals('https://codeberg.org/guettli/sharedinbox/commit/abc1234'),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
testWidgets(
|
testWidgets(
|
||||||
'CrashScreen used as root widget — buttons work without ScaffoldMessenger crash',
|
'CrashScreen used as root widget — buttons work without ScaffoldMessenger crash',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import 'dart:convert';
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/misc.dart' show Override;
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
|
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/misc.dart' show Override;
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
import 'package:sharedinbox/core/models/email.dart';
|
import 'package:sharedinbox/core/models/email.dart';
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_riverpod/misc.dart' show Override;
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import 'package:sharedinbox/core/models/account.dart';
|
import 'package:sharedinbox/core/models/account.dart';
|
||||||
@@ -19,6 +20,7 @@ import 'package:sharedinbox/core/repositories/email_repository.dart';
|
|||||||
import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
|
import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
|
||||||
import 'package:sharedinbox/core/repositories/search_history_repository.dart';
|
import 'package:sharedinbox/core/repositories/search_history_repository.dart';
|
||||||
import 'package:sharedinbox/core/repositories/share_key_repository.dart';
|
import 'package:sharedinbox/core/repositories/share_key_repository.dart';
|
||||||
|
import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
|
||||||
import 'package:sharedinbox/core/services/account_discovery_service.dart';
|
import 'package:sharedinbox/core/services/account_discovery_service.dart';
|
||||||
import 'package:sharedinbox/core/services/connection_test_service.dart';
|
import 'package:sharedinbox/core/services/connection_test_service.dart';
|
||||||
import 'package:sharedinbox/core/services/managesieve_probe_service.dart';
|
import 'package:sharedinbox/core/services/managesieve_probe_service.dart';
|
||||||
@@ -473,10 +475,18 @@ Widget buildApp({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return ProviderScope(
|
return ProviderScope(
|
||||||
// Always neutralise the ManageSieve probe so widget tests never open a
|
// Defaults come first so tests can override them via [overrides].
|
||||||
// real socket. Tests that need to assert on probe behaviour should supply
|
//
|
||||||
// their own override before this default in [overrides].
|
// syncHealthProvider and syncLogRepositoryProvider are backed by Drift
|
||||||
|
// StreamQueries. When a StreamProvider that wraps a Drift query is disposed,
|
||||||
|
// Drift schedules a Timer.run() for cache debouncing. Flutter's test
|
||||||
|
// framework then fails the test with "A Timer is still pending". Replacing
|
||||||
|
// these with simple synchronous streams avoids the pending-timer assertion.
|
||||||
overrides: [
|
overrides: [
|
||||||
|
syncHealthProvider.overrideWith((ref, _) => Stream.value(null)),
|
||||||
|
syncLogRepositoryProvider.overrideWithValue(
|
||||||
|
const NoOpSyncLogRepository(),
|
||||||
|
),
|
||||||
...overrides,
|
...overrides,
|
||||||
manageSieveProbeServiceProvider.overrideWith(
|
manageSieveProbeServiceProvider.overrideWith(
|
||||||
(ref) => _NoOpManageSieveProbeService(),
|
(ref) => _NoOpManageSieveProbeService(),
|
||||||
|
|||||||
Reference in New Issue
Block a user