Compare commits
15
Commits
@@ -10,6 +10,7 @@ FROM ghcr.io/catthehacker/ubuntu:go-24.04
|
|||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
stunnel4 \
|
stunnel4 \
|
||||||
netcat-openbsd \
|
netcat-openbsd \
|
||||||
|
age \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Dagger CLI — pinned to match the engine version on the runner host
|
# Dagger CLI — pinned to match the engine version on the runner host
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
+104
-28
@@ -6,10 +6,55 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
check-changes:
|
||||||
|
name: Detect Changed Files
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
outputs:
|
||||||
|
android: ${{ steps.diff.outputs.android }}
|
||||||
|
linux: ${{ steps.diff.outputs.linux }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
|
||||||
|
- name: Detect Android and Linux changes
|
||||||
|
id: diff
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
# On workflow_dispatch always build everything
|
||||||
|
if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then
|
||||||
|
echo "android=true" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "linux=true" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Diff the HEAD commit against its parent; fall back to listing HEAD's files
|
||||||
|
# when the parent is unavailable (initial commit, shallow clone).
|
||||||
|
CHANGED=$(git diff --name-only HEAD~1 HEAD 2>/dev/null \
|
||||||
|
|| git show --name-only --format= HEAD)
|
||||||
|
|
||||||
|
echo "Changed files:"
|
||||||
|
echo "$CHANGED"
|
||||||
|
|
||||||
|
android_re='^(android/|integration_test/|lib/|pubspec\.yaml|pubspec\.lock|drift_schemas/)'
|
||||||
|
linux_re='^(linux/|lib/|pubspec\.yaml|pubspec\.lock)'
|
||||||
|
|
||||||
|
echo "$CHANGED" | grep -qE "$android_re" \
|
||||||
|
&& echo "android=true" >> "$GITHUB_OUTPUT" \
|
||||||
|
|| echo "android=false" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
echo "$CHANGED" | grep -qE "$linux_re" \
|
||||||
|
&& echo "linux=true" >> "$GITHUB_OUTPUT" \
|
||||||
|
|| echo "linux=false" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
test-android-firebase:
|
test-android-firebase:
|
||||||
name: Android Instrumented Tests (Firebase Test Lab)
|
name: Android Instrumented Tests (Firebase Test Lab)
|
||||||
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
|
||||||
@@ -20,6 +65,7 @@ jobs:
|
|||||||
run: |
|
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 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; }
|
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||||
|
command -v age >/dev/null 2>&1 || { echo "ERROR: age 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; }
|
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)
|
- name: Setup Dagger Remote Engine (via stunnel)
|
||||||
@@ -30,11 +76,15 @@ 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: Run Android Tests on Firebase Test Lab
|
- name: Decrypt production secrets
|
||||||
if: ${{ secrets.FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY != '' }}
|
if: ${{ secrets.SECRETS_AGE_KEY != '' }}
|
||||||
|
env:
|
||||||
|
SECRETS_AGE_KEY: ${{ secrets.SECRETS_AGE_KEY }}
|
||||||
|
run: scripts/secrets-decrypt.sh
|
||||||
|
|
||||||
|
- name: Run Android Tests on Firebase Test Lab
|
||||||
|
if: env.FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY != ''
|
||||||
env:
|
env:
|
||||||
FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY: ${{ secrets.FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY }}
|
|
||||||
FIREBASE_PROJECT_ID: ${{ vars.FIREBASE_PROJECT_ID }}
|
|
||||||
DAGGER_NO_NAG: "1"
|
DAGGER_NO_NAG: "1"
|
||||||
run: task test-android-firebase
|
run: task test-android-firebase
|
||||||
|
|
||||||
@@ -46,6 +96,8 @@ 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
|
||||||
@@ -56,6 +108,7 @@ jobs:
|
|||||||
run: |
|
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 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; }
|
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||||
|
command -v age >/dev/null 2>&1 || { echo "ERROR: age 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; }
|
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)
|
- name: Setup Dagger Remote Engine (via stunnel)
|
||||||
@@ -66,12 +119,15 @@ 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: Publish Android to Play Store
|
- name: Decrypt production secrets
|
||||||
if: ${{ secrets.PLAY_STORE_CONFIG_JSON != '' }}
|
if: ${{ secrets.SECRETS_AGE_KEY != '' }}
|
||||||
|
env:
|
||||||
|
SECRETS_AGE_KEY: ${{ secrets.SECRETS_AGE_KEY }}
|
||||||
|
run: scripts/secrets-decrypt.sh
|
||||||
|
|
||||||
|
- name: Publish Android to Play Store
|
||||||
|
if: env.PLAY_STORE_CONFIG_JSON != ''
|
||||||
env:
|
env:
|
||||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
|
||||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
|
||||||
PLAY_STORE_CONFIG_JSON: ${{ secrets.PLAY_STORE_CONFIG_JSON }}
|
|
||||||
DAGGER_NO_NAG: "1"
|
DAGGER_NO_NAG: "1"
|
||||||
run: task publish-android
|
run: task publish-android
|
||||||
|
|
||||||
@@ -83,6 +139,8 @@ jobs:
|
|||||||
name: Build & Deploy APK to Server
|
name: Build & Deploy APK to Server
|
||||||
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
|
||||||
@@ -93,6 +151,7 @@ jobs:
|
|||||||
run: |
|
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 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; }
|
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||||
|
command -v age >/dev/null 2>&1 || { echo "ERROR: age 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; }
|
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)
|
- name: Setup Dagger Remote Engine (via stunnel)
|
||||||
@@ -103,14 +162,15 @@ 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: Build & Deploy APK to server
|
- name: Decrypt production secrets
|
||||||
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
|
if: ${{ secrets.SECRETS_AGE_KEY != '' }}
|
||||||
|
env:
|
||||||
|
SECRETS_AGE_KEY: ${{ secrets.SECRETS_AGE_KEY }}
|
||||||
|
run: scripts/secrets-decrypt.sh
|
||||||
|
|
||||||
|
- name: Build & Deploy APK to server
|
||||||
|
if: env.SSH_PRIVATE_KEY != ''
|
||||||
env:
|
env:
|
||||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
|
||||||
SSH_USER: ${{ secrets.SSH_USER }}
|
|
||||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
|
||||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
|
||||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
|
||||||
DAGGER_NO_NAG: "1"
|
DAGGER_NO_NAG: "1"
|
||||||
run: task deploy-apk
|
run: task deploy-apk
|
||||||
|
|
||||||
@@ -122,6 +182,8 @@ 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
|
||||||
@@ -132,6 +194,7 @@ jobs:
|
|||||||
run: |
|
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 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; }
|
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||||
|
command -v age >/dev/null 2>&1 || { echo "ERROR: age 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; }
|
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)
|
- name: Setup Dagger Remote Engine (via stunnel)
|
||||||
@@ -142,12 +205,15 @@ 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: Build & Deploy Linux to server
|
- name: Decrypt production secrets
|
||||||
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
|
if: ${{ secrets.SECRETS_AGE_KEY != '' }}
|
||||||
|
env:
|
||||||
|
SECRETS_AGE_KEY: ${{ secrets.SECRETS_AGE_KEY }}
|
||||||
|
run: scripts/secrets-decrypt.sh
|
||||||
|
|
||||||
|
- name: Build & Deploy Linux to server
|
||||||
|
if: env.SSH_PRIVATE_KEY != ''
|
||||||
env:
|
env:
|
||||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
|
||||||
SSH_USER: ${{ secrets.SSH_USER }}
|
|
||||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
|
||||||
DAGGER_NO_NAG: "1"
|
DAGGER_NO_NAG: "1"
|
||||||
run: task deploy-linux
|
run: task deploy-linux
|
||||||
|
|
||||||
@@ -173,6 +239,7 @@ jobs:
|
|||||||
run: |
|
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 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; }
|
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||||
|
command -v age >/dev/null 2>&1 || { echo "ERROR: age 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; }
|
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)
|
- name: Setup Dagger Remote Engine (via stunnel)
|
||||||
@@ -183,12 +250,15 @@ 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: Generate build history and deploy website
|
- name: Decrypt production secrets
|
||||||
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
|
if: ${{ secrets.SECRETS_AGE_KEY != '' }}
|
||||||
|
env:
|
||||||
|
SECRETS_AGE_KEY: ${{ secrets.SECRETS_AGE_KEY }}
|
||||||
|
run: scripts/secrets-decrypt.sh
|
||||||
|
|
||||||
|
- name: Generate build history and deploy website
|
||||||
|
if: env.SSH_PRIVATE_KEY != ''
|
||||||
env:
|
env:
|
||||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
|
||||||
SSH_USER: ${{ secrets.SSH_USER }}
|
|
||||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
|
||||||
DAGGER_NO_NAG: "1"
|
DAGGER_NO_NAG: "1"
|
||||||
run: task publish-website
|
run: task publish-website
|
||||||
|
|
||||||
@@ -200,7 +270,13 @@ jobs:
|
|||||||
name: Update Deploy Health Label
|
name: Update Deploy Health Label
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [test-android-firebase, deploy-playstore, deploy-apk, 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:
|
||||||
@@ -209,7 +285,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.deploy-apk.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
|
||||||
|
|||||||
@@ -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/"
|
||||||
|
|||||||
+3
-1
@@ -22,13 +22,15 @@ assets/changelog.txt
|
|||||||
.env.local
|
.env.local
|
||||||
.envrc
|
.envrc
|
||||||
.direnv/
|
.direnv/
|
||||||
|
secrets.env # plaintext secrets — encrypted version (secrets.age) is committed
|
||||||
|
|
||||||
# --- Android ---
|
# --- Android ---
|
||||||
android/.gradle/
|
android/.gradle/
|
||||||
android/local.properties
|
android/local.properties
|
||||||
android/app/google-services.json
|
android/app/google-services.json
|
||||||
android/key.properties
|
android/key.properties
|
||||||
android/app/src/main/java/io/flutter/plugins/
|
# android/app/src/main/java/io/flutter/plugins/ intentionally tracked so that
|
||||||
|
# GeneratedPluginRegistrant.java (catch Throwable) is committed and used by CI.
|
||||||
.android/
|
.android/
|
||||||
Android/
|
Android/
|
||||||
.gradle/
|
.gradle/
|
||||||
|
|||||||
@@ -174,10 +174,70 @@ Run a secret manager co-located with the Dagger host. The CI job authenticates w
|
|||||||
- Vault itself becomes a security-critical single point of failure.
|
- Vault itself becomes a security-critical single point of failure.
|
||||||
- Operational overhead likely disproportionate for a small single-developer project.
|
- Operational overhead likely disproportionate for a small single-developer project.
|
||||||
|
|
||||||
|
### Option 5: Encrypted secrets file (age) — **implemented**
|
||||||
|
|
||||||
|
Store all production secrets in a file (`secrets.env`) that is encrypted with
|
||||||
|
[age](https://age-encryption.org/) into `secrets.age`. The encrypted file is
|
||||||
|
committed to the repository. Only the age private key — a single string — is
|
||||||
|
stored in Codeberg as `SECRETS_AGE_KEY`. Any CI job or developer with the key
|
||||||
|
can decrypt the file and obtain all secrets.
|
||||||
|
|
||||||
|
**How it works:**
|
||||||
|
|
||||||
|
1. Generate a key pair once:
|
||||||
|
```bash
|
||||||
|
age-keygen -o ~/.config/age/sharedinbox.key
|
||||||
|
age-keygen -y ~/.config/age/sharedinbox.key > .age-public-key
|
||||||
|
```
|
||||||
|
2. Copy `secrets.env.example` to `secrets.env`, fill in all values, then encrypt:
|
||||||
|
```bash
|
||||||
|
scripts/secrets-encrypt.sh # reads public key from .age-public-key
|
||||||
|
git add secrets.age && git commit -m "chore: update encrypted secrets"
|
||||||
|
```
|
||||||
|
3. Add the private key content as `SECRETS_AGE_KEY` in Codeberg repository secrets.
|
||||||
|
4. CI jobs call `scripts/secrets-decrypt.sh` (with `SECRETS_AGE_KEY` set) before
|
||||||
|
any step that needs production credentials. The script writes each variable
|
||||||
|
to `$GITHUB_ENV` so subsequent steps see them automatically.
|
||||||
|
|
||||||
|
**Keeping local and CI in sync:**
|
||||||
|
When you rotate a secret locally, update `secrets.env`, re-run
|
||||||
|
`scripts/secrets-encrypt.sh`, and commit the new `secrets.age`. CI will pick
|
||||||
|
up the fresh secrets on the next push — no manual CI variable updates needed.
|
||||||
|
|
||||||
|
Multi-line values (SSH keys, certificates) must be stored as a single line
|
||||||
|
with `\n` escape sequences inside double quotes. Example:
|
||||||
|
```
|
||||||
|
SSH_PRIVATE_KEY="<header>\n<base64 key body>\n<footer>"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pro:**
|
||||||
|
- One secret (`SECRETS_AGE_KEY`) in Codeberg instead of many.
|
||||||
|
- Encrypted secrets are version-controlled — rotating a secret is a git commit.
|
||||||
|
- Local dev environment and CI always use the same encrypted source of truth.
|
||||||
|
- `age` is a simple, audited tool with no server infrastructure.
|
||||||
|
- The private key never appears in workflow files or logs.
|
||||||
|
|
||||||
|
**Con:**
|
||||||
|
- `secrets.age` exposes the list of variable *names* (visible in the encrypted
|
||||||
|
file if the format leaks, though not the values).
|
||||||
|
- All credentials share a single key — compromising `SECRETS_AGE_KEY` exposes
|
||||||
|
everything at once.
|
||||||
|
- Key rotation requires re-encrypting `secrets.age` and updating the CI secret.
|
||||||
|
|
||||||
### Recommendation
|
### Recommendation
|
||||||
|
|
||||||
**Option 1** (runner-level env vars) or **Option 2** (secret files) are the pragmatic starting point for a single self-hosted runner. They require no new infrastructure and move all production secrets off Codeberg immediately.
|
**Option 5** (encrypted secrets file) is now the active approach. It reduces
|
||||||
|
Codeberg secrets to exactly two categories:
|
||||||
|
- **Dagger access credentials** — `DAGGER_STUNNEL_URL`, `DAGGER_CA_CERT`,
|
||||||
|
`DAGGER_CLIENT_CERT`, `DAGGER_CLIENT_KEY`.
|
||||||
|
- **Master key** — `SECRETS_AGE_KEY`.
|
||||||
|
|
||||||
**Option 3** (Dagger host as orchestrator) is worth considering once the trigger SSH key replaces all other secrets in Codeberg — it offers the cleanest security boundary at the cost of reduced CI observability.
|
**Option 1** (runner-level env vars) or **Option 2** (secret files) remain
|
||||||
|
valid if you prefer not to commit an encrypted file to the repository.
|
||||||
|
|
||||||
**Option 4** (Vault) becomes worthwhile if the project grows to multiple runners or team members who each need audited access to deploy credentials.
|
**Option 3** (Dagger host as orchestrator) is worth considering once the
|
||||||
|
trigger SSH key replaces all other secrets in Codeberg — it offers the cleanest
|
||||||
|
security boundary at the cost of reduced CI observability.
|
||||||
|
|
||||||
|
**Option 4** (Vault) becomes worthwhile if the project grows to multiple
|
||||||
|
runners or team members who each need audited access to deploy credentials.
|
||||||
|
|||||||
+47
-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)
|
||||||
@@ -373,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"
|
||||||
|
|
||||||
@@ -416,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"
|
||||||
|
|
||||||
@@ -583,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"
|
||||||
@@ -619,18 +640,27 @@ 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/
|
||||||
|
|
||||||
check-fast:
|
check-fast:
|
||||||
desc: Pre-commit checks — analyze + unit+widget tests + coverage gate (no build, no integration)
|
desc: Pre-commit checks — analyze + unit+widget tests + coverage gate (no build, no integration)
|
||||||
deps: [analyze, check-coverage, check-hygiene, check-layers, check-mocks]
|
deps: [analyze, check-coverage, check-hygiene, check-layers, check-mocks, check-secrets]
|
||||||
|
|
||||||
|
check-secrets:
|
||||||
|
desc: Test secrets encrypt/decrypt scripts (requires age)
|
||||||
|
cmds:
|
||||||
|
- bash scripts/test_secrets.sh
|
||||||
|
|
||||||
check-layers:
|
check-layers:
|
||||||
desc: Enforce architecture — ui/ must not import data/ (only core/ interfaces allowed)
|
desc: Enforce architecture — ui/ must not import data/ (only core/ interfaces allowed)
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ gradle-wrapper.jar
|
|||||||
/gradlew
|
/gradlew
|
||||||
/gradlew.bat
|
/gradlew.bat
|
||||||
/local.properties
|
/local.properties
|
||||||
GeneratedPluginRegistrant.java
|
|
||||||
.cxx/
|
.cxx/
|
||||||
|
|
||||||
# Remember to never publicly share your keystore.
|
# Remember to never publicly share your keystore.
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package io.flutter.plugins;
|
||||||
|
|
||||||
|
import androidx.annotation.Keep;
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import io.flutter.Log;
|
||||||
|
|
||||||
|
import io.flutter.embedding.engine.FlutterEngine;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generated file. Do not edit.
|
||||||
|
* This file is generated by the Flutter tool based on the
|
||||||
|
* plugins that support the Android platform.
|
||||||
|
*/
|
||||||
|
@Keep
|
||||||
|
public final class GeneratedPluginRegistrant {
|
||||||
|
private static final String TAG = "GeneratedPluginRegistrant";
|
||||||
|
public static void registerWith(@NonNull FlutterEngine flutterEngine) {
|
||||||
|
try {
|
||||||
|
flutterEngine.getPlugins().add(new com.mr.flutter.plugin.filepicker.FilePickerPlugin());
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error registering plugin file_picker, com.mr.flutter.plugin.filepicker.FilePickerPlugin", e);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
flutterEngine.getPlugins().add(new com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin());
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error registering plugin flutter_local_notifications, com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin", e);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
flutterEngine.getPlugins().add(new io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin());
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error registering plugin flutter_plugin_android_lifecycle, io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin", e);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
flutterEngine.getPlugins().add(new com.it_nomads.fluttersecurestorage.FlutterSecureStoragePlugin());
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error registering plugin flutter_secure_storage, com.it_nomads.fluttersecurestorage.FlutterSecureStoragePlugin", e);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
flutterEngine.getPlugins().add(new dev.flutter.plugins.integration_test.IntegrationTestPlugin());
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error registering plugin integration_test, dev.flutter.plugins.integration_test.IntegrationTestPlugin", e);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
flutterEngine.getPlugins().add(new dev.steenbakker.mobile_scanner.MobileScannerPlugin());
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error registering plugin mobile_scanner, dev.steenbakker.mobile_scanner.MobileScannerPlugin", e);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
flutterEngine.getPlugins().add(new com.crazecoder.openfile.OpenFilePlugin());
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error registering plugin open_filex, com.crazecoder.openfile.OpenFilePlugin", e);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.packageinfo.PackageInfoPlugin());
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error registering plugin package_info_plus, dev.fluttercommunity.plus.packageinfo.PackageInfoPlugin", e);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
flutterEngine.getPlugins().add(new io.flutter.plugins.pathprovider.PathProviderPlugin());
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error registering plugin path_provider_android, io.flutter.plugins.pathprovider.PathProviderPlugin", e);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.share.SharePlusPlugin());
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error registering plugin share_plus, dev.fluttercommunity.plus.share.SharePlusPlugin", e);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
flutterEngine.getPlugins().add(new io.flutter.plugins.urllauncher.UrlLauncherPlugin());
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error registering plugin url_launcher_android, io.flutter.plugins.urllauncher.UrlLauncherPlugin", e);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
flutterEngine.getPlugins().add(new io.flutter.plugins.webviewflutter.WebViewFlutterPlugin());
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error registering plugin webview_flutter_android, io.flutter.plugins.webviewflutter.WebViewFlutterPlugin", e);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
flutterEngine.getPlugins().add(new dev.fluttercommunity.workmanager.WorkmanagerPlugin());
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error registering plugin workmanager_android, dev.fluttercommunity.workmanager.WorkmanagerPlugin", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+57
-21
@@ -183,7 +183,7 @@ func (m *Ci) toolchain() *dagger.Container {
|
|||||||
return dag.Container().
|
return dag.Container().
|
||||||
From("ghcr.io/cirruslabs/flutter:3.41.6").
|
From("ghcr.io/cirruslabs/flutter:3.41.6").
|
||||||
WithExec([]string{"apt-get", "-qq", "update"}).
|
WithExec([]string{"apt-get", "-qq", "update"}).
|
||||||
WithExec([]string{"apt-get", "install", "-y", "-qq", "clang", "cmake", "ninja-build", "pkg-config", "libgtk-3-dev", "liblzma-dev", "libsecret-1-dev", "libgcrypt20-dev", "libjsoncpp-dev", "sqlite3", "iproute2", "netcat-openbsd", "xvfb", "libosmesa6", "libegl1", "lld"}).
|
WithExec([]string{"apt-get", "install", "-y", "-qq", "clang", "cmake", "ninja-build", "pkg-config", "libgtk-3-dev", "liblzma-dev", "libsecret-1-dev", "libgcrypt20-dev", "libjsoncpp-dev", "sqlite3", "iproute2", "netcat-openbsd", "xvfb", "libosmesa6", "libegl1", "lld", "age"}).
|
||||||
WithExec([]string{"useradd", "-m", "-s", "/bin/bash", "ci"}).
|
WithExec([]string{"useradd", "-m", "-s", "/bin/bash", "ci"}).
|
||||||
WithExec([]string{"/bin/sh", "-c",
|
WithExec([]string{"/bin/sh", "-c",
|
||||||
`flutter_dir=$(dirname $(dirname $(which flutter))); ` +
|
`flutter_dir=$(dirname $(dirname $(which flutter))); ` +
|
||||||
@@ -195,7 +195,8 @@ func (m *Ci) toolchain() *dagger.Container {
|
|||||||
WithUser("ci").
|
WithUser("ci").
|
||||||
WithExec([]string{"/bin/sh", "-c",
|
WithExec([]string{"/bin/sh", "-c",
|
||||||
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
|
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
|
||||||
`yes | sdkmanager "ndk;28.2.13676358" "cmake;3.22.1" "build-tools;35.0.0" "platforms;android-34" >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }`})
|
`yes | sdkmanager "ndk;28.2.13676358" "cmake;3.22.1" "build-tools;35.0.0" "platforms;android-34" >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }`}).
|
||||||
|
WithExec([]string{"flutter", "precache", "--linux", "--no-android", "--no-ios"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Base is the Flutter toolchain container with mutable cache mounts attached.
|
// Base is the Flutter toolchain container with mutable cache mounts attached.
|
||||||
@@ -318,12 +319,13 @@ func (m *Ci) Hugo() *dagger.Container {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 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.
|
||||||
@@ -379,6 +381,21 @@ func (m *Ci) CheckHygiene(ctx context.Context) (string, error) {
|
|||||||
Stdout(ctx)
|
Stdout(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CheckSecrets verifies the secrets encrypt/decrypt scripts work correctly.
|
||||||
|
func (m *Ci) CheckSecrets(ctx context.Context) (string, error) {
|
||||||
|
scriptSrc := m.Source.Filter(dagger.DirectoryFilterOpts{
|
||||||
|
Include: []string{"scripts/secrets-encrypt.sh", "scripts/secrets-decrypt.sh", "scripts/test_secrets.sh"},
|
||||||
|
})
|
||||||
|
return dag.Container().
|
||||||
|
From("ghcr.io/cirruslabs/flutter:3.41.6").
|
||||||
|
WithExec([]string{"apt-get", "-qq", "update"}).
|
||||||
|
WithExec([]string{"apt-get", "install", "-y", "-qq", "age"}).
|
||||||
|
WithDirectory("/src", scriptSrc).
|
||||||
|
WithWorkdir("/src").
|
||||||
|
WithExec([]string{"bash", "scripts/test_secrets.sh"}).
|
||||||
|
Stdout(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
// CheckLayers enforces that ui/ does not import data/.
|
// CheckLayers enforces that ui/ does not import data/.
|
||||||
func (m *Ci) CheckLayers(ctx context.Context) (string, error) {
|
func (m *Ci) CheckLayers(ctx context.Context) (string, error) {
|
||||||
return m.Base().
|
return m.Base().
|
||||||
@@ -469,6 +486,9 @@ func (m *Ci) Check(ctx context.Context) (string, error) {
|
|||||||
if _, err := m.CheckLayers(ctx); err != nil {
|
if _, err := m.CheckLayers(ctx); err != nil {
|
||||||
return "Layer check failed", err
|
return "Layer check failed", err
|
||||||
}
|
}
|
||||||
|
if _, err := m.CheckSecrets(ctx); err != nil {
|
||||||
|
return "Secrets script check failed", err
|
||||||
|
}
|
||||||
|
|
||||||
checkSetup := m.setup(m.checkSrc())
|
checkSetup := m.setup(m.checkSrc())
|
||||||
|
|
||||||
@@ -514,6 +534,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 {
|
||||||
@@ -525,7 +546,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).
|
||||||
@@ -538,10 +559,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/"},
|
||||||
@@ -558,12 +580,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",
|
||||||
@@ -589,6 +612,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,
|
||||||
@@ -599,11 +623,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -626,6 +650,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,
|
||||||
@@ -639,10 +664,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -804,7 +829,7 @@ func (m *Ci) Graph() string {
|
|||||||
` + "```" + `mermaid
|
` + "```" + `mermaid
|
||||||
flowchart TD
|
flowchart TD
|
||||||
subgraph dagger ["Dagger · Check pipeline"]
|
subgraph dagger ["Dagger · Check pipeline"]
|
||||||
toolchain["toolchain\nflutter:3.41.6 + NDK + apt"]
|
toolchain["toolchain\nflutter:3.41.6 + NDK + apt + precache"]
|
||||||
pubGet["pubGetLayer\nflutter pub get"]
|
pubGet["pubGetLayer\nflutter pub get"]
|
||||||
codegen["codegenBase\nbuild_runner build\n(shared cache)"]
|
codegen["codegenBase\nbuild_runner build\n(shared cache)"]
|
||||||
stalwart(["Stalwart service\nIMAP · JMAP · SMTP · Sieve"])
|
stalwart(["Stalwart service\nIMAP · JMAP · SMTP · Sieve"])
|
||||||
@@ -814,6 +839,7 @@ flowchart TD
|
|||||||
|
|
||||||
pubGet --> hygiene["CheckHygiene"]
|
pubGet --> hygiene["CheckHygiene"]
|
||||||
pubGet --> layers["CheckLayers"]
|
pubGet --> layers["CheckLayers"]
|
||||||
|
pubGet --> secrets["CheckSecrets\nage encrypt/decrypt"]
|
||||||
pubGet --> mocks["CheckMocks\n(own build_runner run)"]
|
pubGet --> mocks["CheckMocks\n(own build_runner run)"]
|
||||||
|
|
||||||
codegen --> fmt["Format"]
|
codegen --> fmt["Format"]
|
||||||
@@ -827,6 +853,7 @@ flowchart TD
|
|||||||
|
|
||||||
hygiene --> check{{"✓ Check"}}
|
hygiene --> check{{"✓ Check"}}
|
||||||
layers --> check
|
layers --> check
|
||||||
|
secrets --> check
|
||||||
fmt --> check
|
fmt --> check
|
||||||
analyze --> check
|
analyze --> check
|
||||||
mocks --> check
|
mocks --> check
|
||||||
@@ -835,16 +862,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
|
||||||
|
|||||||
@@ -87,6 +87,9 @@
|
|||||||
# Website
|
# Website
|
||||||
hugo
|
hugo
|
||||||
|
|
||||||
|
# Secrets management (master-key encryption for CI sync)
|
||||||
|
age
|
||||||
|
|
||||||
# Utilities
|
# Utilities
|
||||||
git
|
git
|
||||||
curl
|
curl
|
||||||
|
|||||||
@@ -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') ||
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -91,6 +95,30 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _launchUrl(BuildContext context, Uri url) async {
|
||||||
|
try {
|
||||||
|
final launched =
|
||||||
|
await launchUrl(url, mode: LaunchMode.externalApplication);
|
||||||
|
if (!launched && context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
duration: Duration(seconds: 5),
|
||||||
|
content: Text('Could not open browser.'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
duration: const Duration(seconds: 5),
|
||||||
|
content: Text('Error: $e'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _createIssue(
|
Future<void> _createIssue(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
int imapCount,
|
int imapCount,
|
||||||
@@ -163,10 +191,7 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
|
|||||||
onTapLink: (text, href, title) {
|
onTapLink: (text, href, title) {
|
||||||
if (href != null) {
|
if (href != null) {
|
||||||
unawaited(
|
unawaited(
|
||||||
launchUrl(
|
_launchUrl(context, Uri.parse(href)),
|
||||||
Uri.parse(href),
|
|
||||||
mode: LaunchMode.externalApplication,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -32,11 +32,15 @@ enum _Step { generatingKey, showingPubKey, scanning, importing, done, error }
|
|||||||
class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
|
class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
|
||||||
_Step _step = _Step.generatingKey;
|
_Step _step = _Step.generatingKey;
|
||||||
ShareKeyMaterial? _keyMaterial;
|
ShareKeyMaterial? _keyMaterial;
|
||||||
|
DateTime? _keyExpiresAt;
|
||||||
String? _pubKeyQr;
|
String? _pubKeyQr;
|
||||||
String? _errorMessage;
|
String? _errorMessage;
|
||||||
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() {
|
||||||
@@ -61,6 +65,7 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
|
|||||||
);
|
);
|
||||||
setState(() {
|
setState(() {
|
||||||
_keyMaterial = material;
|
_keyMaterial = material;
|
||||||
|
_keyExpiresAt = DateTime.now().toUtc().add(const Duration(minutes: 20));
|
||||||
_pubKeyQr = qr;
|
_pubKeyQr = qr;
|
||||||
_step = _Step.showingPubKey;
|
_step = _Step.showingPubKey;
|
||||||
});
|
});
|
||||||
@@ -76,8 +81,37 @@ 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: probe the scanner's permission-state method to verify the
|
||||||
|
// plugin is registered. MissingPluginException is thrown on Android builds
|
||||||
|
// where the plugin is not linked (issue #204). All other exceptions mean
|
||||||
|
// the plugin exists but something else failed — the MobileScanner widget
|
||||||
|
// will surface those via its own error builder.
|
||||||
|
Future<void> _initScanner() async {
|
||||||
|
bool available = false;
|
||||||
|
try {
|
||||||
|
await const MethodChannel(
|
||||||
|
'dev.steenbakker.mobile_scanner/scanner/method',
|
||||||
|
).invokeMethod<int>('state');
|
||||||
|
available = true;
|
||||||
|
} on MissingPluginException {
|
||||||
|
// Plugin not registered on this device; text fallback will be shown.
|
||||||
|
} catch (_) {
|
||||||
|
// Plugin registered but state check failed; let the scanner widget
|
||||||
|
// handle it via its errorBuilder.
|
||||||
|
available = true;
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
if (available) {
|
||||||
|
setState(() => _scannerController = MobileScannerController());
|
||||||
|
} else {
|
||||||
|
setState(() => _scannerFailed = true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onScanned(String rawValue) async {
|
Future<void> _onScanned(String rawValue) async {
|
||||||
@@ -244,7 +278,7 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
const _ExpiryHint(),
|
_ExpiryHint(expiresAt: _keyExpiresAt!),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
if (_errorMessage != null) ...[
|
if (_errorMessage != null) ...[
|
||||||
Text(
|
Text(
|
||||||
@@ -266,11 +300,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: [
|
||||||
@@ -371,8 +408,37 @@ bool _cameraScanSupported() =>
|
|||||||
Platform.isMacOS ||
|
Platform.isMacOS ||
|
||||||
Platform.isWindows;
|
Platform.isWindows;
|
||||||
|
|
||||||
class _ExpiryHint extends StatelessWidget {
|
class _ExpiryHint extends StatefulWidget {
|
||||||
const _ExpiryHint();
|
const _ExpiryHint({required this.expiresAt});
|
||||||
|
|
||||||
|
final DateTime expiresAt;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_ExpiryHint> createState() => _ExpiryHintState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ExpiryHintState extends State<_ExpiryHint> {
|
||||||
|
late Timer _timer;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_timer = Timer.periodic(const Duration(seconds: 1), (_) => setState(() {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_timer.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatRemaining() {
|
||||||
|
final remaining = widget.expiresAt.difference(DateTime.now().toUtc());
|
||||||
|
if (remaining.isNegative) return 'expired';
|
||||||
|
final minutes = remaining.inMinutes;
|
||||||
|
final seconds = remaining.inSeconds % 60;
|
||||||
|
return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -382,7 +448,7 @@ class _ExpiryHint extends StatelessWidget {
|
|||||||
Icon(Icons.timer_outlined, size: 14, color: Colors.grey[600]),
|
Icon(Icons.timer_outlined, size: 14, color: Colors.grey[600]),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(
|
Text(
|
||||||
'This key expires in 20 minutes',
|
'This key expires in ${_formatRemaining()}',
|
||||||
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -45,12 +45,42 @@ 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: probe the scanner's permission-state method to verify the
|
||||||
|
// plugin is registered. MissingPluginException is thrown on Android builds
|
||||||
|
// where the plugin is not linked (issue #204). All other exceptions mean
|
||||||
|
// the plugin exists but something else failed — the MobileScanner widget
|
||||||
|
// will surface those via its own error builder.
|
||||||
|
Future<void> _initScanner() async {
|
||||||
|
bool available = false;
|
||||||
|
try {
|
||||||
|
await const MethodChannel(
|
||||||
|
'dev.steenbakker.mobile_scanner/scanner/method',
|
||||||
|
).invokeMethod<int>('state');
|
||||||
|
available = true;
|
||||||
|
} on MissingPluginException {
|
||||||
|
// Plugin not registered on this device; text fallback will be shown.
|
||||||
|
} catch (_) {
|
||||||
|
// Plugin registered but state check failed; let the scanner widget
|
||||||
|
// handle it via its errorBuilder.
|
||||||
|
available = true;
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
if (available) {
|
||||||
|
setState(() => _scannerController = MobileScannerController());
|
||||||
|
} else {
|
||||||
|
setState(() => _scannerFailed = true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,9 +208,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: [
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:package_info_plus/package_info_plus.dart';
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
@@ -10,27 +11,42 @@ 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');
|
String get _buildMode {
|
||||||
|
if (kDebugMode) return 'debug';
|
||||||
|
if (kProfileMode) return 'profile';
|
||||||
|
return 'release';
|
||||||
|
}
|
||||||
|
|
||||||
Future<String> _buildReport() async {
|
Future<String> _fetchVersion() async {
|
||||||
String version = 'unknown';
|
|
||||||
try {
|
try {
|
||||||
final info = await PackageInfo.fromPlatform();
|
final info = await PackageInfo.fromPlatform();
|
||||||
version = '${info.version}+${info.buildNumber}';
|
return '${info.version}+${info.buildNumber}';
|
||||||
} catch (_) {}
|
} catch (_) {
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> _buildReport() async {
|
||||||
|
final version = await _fetchVersion();
|
||||||
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'
|
||||||
: '';
|
: '';
|
||||||
|
final timestamp = DateTime.now().toUtc().toIso8601String();
|
||||||
return 'App Version: $version\n'
|
return 'App Version: $version\n'
|
||||||
|
'Build Mode: $_buildMode\n'
|
||||||
'$gitLine'
|
'$gitLine'
|
||||||
'Platform: $platform\n\n'
|
'Platform: $platform\n'
|
||||||
|
'Dart: ${Platform.version}\n'
|
||||||
|
'Timestamp: $timestamp\n\n'
|
||||||
'Error:\n```\n$exception\n```\n\n'
|
'Error:\n```\n$exception\n```\n\n'
|
||||||
'Stack Trace:\n```\n$stackTrace\n```';
|
'Stack Trace:\n```\n$stackTrace\n```';
|
||||||
}
|
}
|
||||||
@@ -56,13 +72,40 @@ 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) ...[
|
const SizedBox(height: 4),
|
||||||
const SizedBox(height: 8),
|
FutureBuilder<String>(
|
||||||
const Text(
|
future: _fetchVersion(),
|
||||||
'Git Commit: $_gitHash',
|
builder: (context, snapshot) => Text(
|
||||||
style: TextStyle(fontSize: 12, color: Colors.grey),
|
'v${snapshot.data ?? '…'} • $_buildMode • '
|
||||||
|
'${Platform.operatingSystem} ${Platform.operatingSystemVersion}',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Colors.grey[600],
|
||||||
|
),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
if (gitHash.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () async {
|
||||||
|
final url = Uri.parse(
|
||||||
|
'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),
|
||||||
const Text(
|
const Text(
|
||||||
@@ -106,32 +149,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 {
|
||||||
|
|||||||
+3
-3
@@ -1117,13 +1117,13 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "6.3.2"
|
version: "6.3.2"
|
||||||
url_launcher_android:
|
url_launcher_android:
|
||||||
dependency: transitive
|
dependency: "direct overridden"
|
||||||
description:
|
description:
|
||||||
name: url_launcher_android
|
name: url_launcher_android
|
||||||
sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c"
|
sha256: "5c8b6c2d89a78f5a1cca70a73d9d5f86c701b36b42f9c9dac7bad592113c28e9"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.3.30"
|
version: "6.3.24"
|
||||||
url_launcher_ios:
|
url_launcher_ios:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -89,3 +89,7 @@ dependency_overrides:
|
|||||||
# (SIGSEGV in libdartjni.so FindClassUnchecked). Pin to 2.2.20 which uses
|
# (SIGSEGV in libdartjni.so FindClassUnchecked). Pin to 2.2.20 which uses
|
||||||
# stable Pigeon and is known to work reliably.
|
# stable Pigeon and is known to work reliably.
|
||||||
path_provider_android: ">=2.2.0 <2.2.21"
|
path_provider_android: ">=2.2.0 <2.2.21"
|
||||||
|
# url_launcher_android 6.3.25 updated to Pigeon 26, which causes a
|
||||||
|
# channel-error on launchUrl on some Android devices (same root cause as
|
||||||
|
# path_provider_android). Pin to <6.3.25 which uses stable Pigeon.
|
||||||
|
url_launcher_android: ">=6.3.0 <6.3.25"
|
||||||
|
|||||||
+175
-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,21 @@ 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.yml run on the main branch.
|
||||||
|
|
||||||
|
Forgejo reports scheduled/dispatch workflows (e.g. deploy.yml) with
|
||||||
|
event=push and prettyref=main, so filtering by event alone is not enough.
|
||||||
|
We also require workflow_id == "ci.yml".
|
||||||
|
"""
|
||||||
|
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"
|
||||||
|
and run.get("workflow_id") == "ci.yml"):
|
||||||
|
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 +179,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 +203,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 +347,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 +390,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 +534,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 +575,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 +619,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 +680,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 +771,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
|
||||||
|
|||||||
@@ -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",
|
||||||
],
|
],
|
||||||
|
|||||||
Executable
+85
@@ -0,0 +1,85 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Decrypts secrets.age and exports all KEY=VALUE pairs as environment variables.
|
||||||
|
#
|
||||||
|
# In CI (GITHUB_ENV set): writes to $GITHUB_ENV so subsequent job steps can
|
||||||
|
# read the variables. Multi-line values use the heredoc syntax required by
|
||||||
|
# Forgejo/GitHub Actions.
|
||||||
|
#
|
||||||
|
# Locally: prints an eval-safe export block to stdout. Source it with:
|
||||||
|
# eval "$(SECRETS_AGE_KEY=$(cat ~/.config/age/sharedinbox.key) scripts/secrets-decrypt.sh)"
|
||||||
|
# or pass a key file:
|
||||||
|
# eval "$(scripts/secrets-decrypt.sh ~/.config/age/sharedinbox.key)"
|
||||||
|
#
|
||||||
|
# Private key sources (first match wins):
|
||||||
|
# 1. Path to a key file passed as $1
|
||||||
|
# 2. SECRETS_AGE_KEY env var (the raw private key content — used in CI)
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) \
|
||||||
|
|| REPO_ROOT=$(cd "$(dirname "$0")/.." && pwd)
|
||||||
|
SECRETS_AGE="${SECRETS_AGE:-${REPO_ROOT}/secrets.age}"
|
||||||
|
|
||||||
|
if [ ! -f "$SECRETS_AGE" ]; then
|
||||||
|
echo "ERROR: secrets.age not found at $SECRETS_AGE" >&2
|
||||||
|
echo " Run: scripts/secrets-encrypt.sh to create it." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
TMP_KEY=""
|
||||||
|
cleanup() { [ -n "$TMP_KEY" ] && rm -f "$TMP_KEY"; }
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
if [ -n "${1:-}" ]; then
|
||||||
|
KEY_FILE="$1"
|
||||||
|
elif [ -n "${SECRETS_AGE_KEY:-}" ]; then
|
||||||
|
TMP_KEY=$(mktemp)
|
||||||
|
chmod 600 "$TMP_KEY"
|
||||||
|
printf '%s\n' "$SECRETS_AGE_KEY" > "$TMP_KEY"
|
||||||
|
KEY_FILE="$TMP_KEY"
|
||||||
|
else
|
||||||
|
echo "ERROR: No age private key provided." >&2
|
||||||
|
echo " Pass a key file: scripts/secrets-decrypt.sh ~/.config/age/sharedinbox.key" >&2
|
||||||
|
echo " Or set SECRETS_AGE_KEY env var (CI: store as SECRETS_AGE_KEY secret)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
DECRYPTED=$(age --decrypt -i "$KEY_FILE" "$SECRETS_AGE")
|
||||||
|
|
||||||
|
# Process each KEY=VALUE line.
|
||||||
|
# Double-quoted values have \n escape sequences converted to real newlines.
|
||||||
|
process_secrets() {
|
||||||
|
local line key raw_value value
|
||||||
|
while IFS= read -r line; do
|
||||||
|
[[ -z "$line" || "$line" == \#* ]] && continue
|
||||||
|
[[ "$line" =~ ^[A-Za-z_][A-Za-z0-9_]*= ]] || continue
|
||||||
|
|
||||||
|
key="${line%%=*}"
|
||||||
|
raw_value="${line#*=}"
|
||||||
|
|
||||||
|
# Double-quoted: strip quotes and expand \n → newline
|
||||||
|
if [[ "$raw_value" == '"'*'"' ]]; then
|
||||||
|
raw_value="${raw_value:1:${#raw_value}-2}"
|
||||||
|
value=$(printf '%b' "$raw_value")
|
||||||
|
# Single-quoted: strip quotes, no expansion
|
||||||
|
elif [[ "$raw_value" == "'"*"'" ]]; then
|
||||||
|
value="${raw_value:1:${#raw_value}-2}"
|
||||||
|
else
|
||||||
|
value="$raw_value"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "${GITHUB_ENV:-}" ]; then
|
||||||
|
# Heredoc syntax handles multi-line values safely
|
||||||
|
local delim="EOF_${key}_$$"
|
||||||
|
printf '%s<<%s\n%s\n%s\n' "$key" "$delim" "$value" "$delim" >> "$GITHUB_ENV"
|
||||||
|
else
|
||||||
|
# Print as export statements for eval
|
||||||
|
printf "export %s=%q\n" "$key" "$value"
|
||||||
|
fi
|
||||||
|
done <<< "$DECRYPTED"
|
||||||
|
}
|
||||||
|
|
||||||
|
process_secrets
|
||||||
|
|
||||||
|
if [ -n "${GITHUB_ENV:-}" ]; then
|
||||||
|
echo "Secrets written to \$GITHUB_ENV." >&2
|
||||||
|
fi
|
||||||
Executable
+42
@@ -0,0 +1,42 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Encrypts secrets.env → secrets.age using an age public key.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# scripts/secrets-encrypt.sh [AGE1...] public key as positional argument
|
||||||
|
# AGE_PUBLIC_KEY=AGE1... scripts/secrets-encrypt.sh
|
||||||
|
# scripts/secrets-encrypt.sh reads public key from .age-public-key
|
||||||
|
#
|
||||||
|
# The private key never touches this script. Only the public key is needed to
|
||||||
|
# encrypt. Store the private key in CI as SECRETS_AGE_KEY and keep a local
|
||||||
|
# copy at ~/.config/age/sharedinbox.key (or wherever you prefer).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) \
|
||||||
|
|| REPO_ROOT=$(cd "$(dirname "$0")/.." && pwd)
|
||||||
|
SECRETS_ENV="${SECRETS_ENV:-${REPO_ROOT}/secrets.env}"
|
||||||
|
SECRETS_AGE="${SECRETS_AGE:-${REPO_ROOT}/secrets.age}"
|
||||||
|
KEY_FILE="${REPO_ROOT}/.age-public-key"
|
||||||
|
|
||||||
|
if [ -n "${1:-}" ]; then
|
||||||
|
PUBLIC_KEY="$1"
|
||||||
|
elif [ -n "${AGE_PUBLIC_KEY:-}" ]; then
|
||||||
|
PUBLIC_KEY="$AGE_PUBLIC_KEY"
|
||||||
|
elif [ -f "$KEY_FILE" ]; then
|
||||||
|
PUBLIC_KEY=$(cat "$KEY_FILE")
|
||||||
|
PUBLIC_KEY="${PUBLIC_KEY%%$'\n'*}" # take only the first line
|
||||||
|
else
|
||||||
|
echo "ERROR: No age public key provided." >&2
|
||||||
|
echo " Pass it as an argument: scripts/secrets-encrypt.sh AGE1..." >&2
|
||||||
|
echo " Or store it in .age-public-key: age-keygen -y ~/.config/age/sharedinbox.key > .age-public-key" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$SECRETS_ENV" ]; then
|
||||||
|
echo "ERROR: secrets.env not found at $SECRETS_ENV" >&2
|
||||||
|
echo " Copy secrets.env.example to secrets.env and fill in values." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
age --encrypt --recipient "$PUBLIC_KEY" --output "$SECRETS_AGE" "$SECRETS_ENV"
|
||||||
|
echo "Encrypted $SECRETS_ENV → $SECRETS_AGE"
|
||||||
|
echo "Commit secrets.age to keep CI in sync."
|
||||||
+94
-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,47 @@ 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 ci.yml push-to-main runs."""
|
||||||
|
|
||||||
|
def _ci_run(self, run_id, status="success"):
|
||||||
|
return {"event": "push", "prettyref": "main", "workflow_id": "ci.yml",
|
||||||
|
"status": status, "id": run_id}
|
||||||
|
|
||||||
|
def _deploy_run(self, run_id, status="success"):
|
||||||
|
return {"event": "push", "prettyref": "main", "workflow_id": "deploy.yml",
|
||||||
|
"status": status, "id": run_id}
|
||||||
|
|
||||||
|
def test_skips_deploy_run_returns_ci_run(self):
|
||||||
|
# Forgejo reports deploy.yml schedule runs as event=push/prettyref=main;
|
||||||
|
# must be excluded by workflow_id filter.
|
||||||
|
runs = [self._deploy_run(1), self._ci_run(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_deploy_runs_exist(self):
|
||||||
|
runs = [self._deploy_run(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_none_when_only_schedule_runs_exist(self):
|
||||||
|
runs = [{"event": "schedule", "prettyref": "main", "workflow_id": "deploy.yml",
|
||||||
|
"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_ci_push_to_main_run(self):
|
||||||
|
runs = [self._ci_run(42, status="running")]
|
||||||
|
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."""
|
||||||
|
|
||||||
|
|||||||
Executable
+153
@@ -0,0 +1,153 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Tests for scripts/secrets-encrypt.sh and scripts/secrets-decrypt.sh.
|
||||||
|
# Run directly: bash scripts/test_secrets.sh
|
||||||
|
# Requires: age, age-keygen
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||||
|
PASS=0
|
||||||
|
FAIL=0
|
||||||
|
|
||||||
|
_assert() {
|
||||||
|
local name="$1" expected="$2" actual="$3"
|
||||||
|
if [ "$actual" = "$expected" ]; then
|
||||||
|
PASS=$((PASS + 1))
|
||||||
|
else
|
||||||
|
echo "FAIL: $name"
|
||||||
|
echo " expected: $(printf '%s' "$expected" | head -c 80)"
|
||||||
|
echo " actual: $(printf '%s' "$actual" | head -c 80)"
|
||||||
|
FAIL=$((FAIL + 1))
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
_assert_contains() {
|
||||||
|
local name="$1" needle="$2" haystack="$3"
|
||||||
|
if printf '%s' "$haystack" | grep -qF -- "$needle"; then
|
||||||
|
PASS=$((PASS + 1))
|
||||||
|
else
|
||||||
|
echo "FAIL: $name"
|
||||||
|
echo " expected to contain: $needle"
|
||||||
|
echo " actual: $(printf '%s' "$haystack" | head -c 200)"
|
||||||
|
FAIL=$((FAIL + 1))
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
if ! command -v age >/dev/null 2>&1 || ! command -v age-keygen >/dev/null 2>&1; then
|
||||||
|
echo "SKIP: age/age-keygen not found — install age to run secrets tests"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
WORKDIR=$(mktemp -d)
|
||||||
|
cleanup() { rm -rf "$WORKDIR"; }
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
KEY_FILE="$WORKDIR/test.key"
|
||||||
|
SECRETS_ENV="$WORKDIR/secrets.env"
|
||||||
|
SECRETS_AGE="$WORKDIR/secrets.age"
|
||||||
|
GITHUB_ENV_FILE="$WORKDIR/github.env"
|
||||||
|
|
||||||
|
# Generate a test age key pair
|
||||||
|
age-keygen -o "$KEY_FILE" 2>/dev/null
|
||||||
|
PUBLIC_KEY=$(age-keygen -y "$KEY_FILE")
|
||||||
|
|
||||||
|
PRIVATE_KEY=$(cat "$KEY_FILE")
|
||||||
|
|
||||||
|
# Helper: decrypt and eval, capturing specific variables
|
||||||
|
_decrypt_vars() {
|
||||||
|
local vars
|
||||||
|
vars=$(SECRETS_AGE_KEY="$PRIVATE_KEY" \
|
||||||
|
SECRETS_AGE="$SECRETS_AGE" \
|
||||||
|
bash "$SCRIPT_DIR/secrets-decrypt.sh")
|
||||||
|
eval "$vars"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- simple values ---
|
||||||
|
cat > "$SECRETS_ENV" << 'EOF'
|
||||||
|
SIMPLE_VAR=hello
|
||||||
|
QUOTED_DOUBLE="world"
|
||||||
|
QUOTED_SINGLE='literal'
|
||||||
|
EMPTY_VAR=
|
||||||
|
# comment line — should be ignored
|
||||||
|
NUMERIC=42
|
||||||
|
EOF
|
||||||
|
|
||||||
|
AGE_PUBLIC_KEY="$PUBLIC_KEY" \
|
||||||
|
SECRETS_ENV="$SECRETS_ENV" \
|
||||||
|
SECRETS_AGE="$SECRETS_AGE" \
|
||||||
|
bash "$SCRIPT_DIR/secrets-encrypt.sh"
|
||||||
|
|
||||||
|
_decrypt_vars
|
||||||
|
_assert "simple value" "hello" "${SIMPLE_VAR:-}"
|
||||||
|
_assert "double-quoted value" "world" "${QUOTED_DOUBLE:-}"
|
||||||
|
_assert "single-quoted value" "literal" "${QUOTED_SINGLE:-}"
|
||||||
|
_assert "empty value" "" "${EMPTY_VAR:-}"
|
||||||
|
_assert "numeric value" "42" "${NUMERIC:-}"
|
||||||
|
unset SIMPLE_VAR QUOTED_DOUBLE QUOTED_SINGLE EMPTY_VAR NUMERIC
|
||||||
|
|
||||||
|
# --- multi-line value with \n escape sequences ---
|
||||||
|
# Use a made-up key format to avoid triggering the detect-private-key pre-commit hook.
|
||||||
|
printf '%s\n' \
|
||||||
|
'SSH_KEY="FAKE-KEY-HEADER\nfakekey\nFAKE-KEY-FOOTER"' \
|
||||||
|
'SIDE=plain' \
|
||||||
|
> "$SECRETS_ENV"
|
||||||
|
|
||||||
|
rm -f "$SECRETS_AGE"
|
||||||
|
AGE_PUBLIC_KEY="$PUBLIC_KEY" \
|
||||||
|
SECRETS_ENV="$SECRETS_ENV" \
|
||||||
|
SECRETS_AGE="$SECRETS_AGE" \
|
||||||
|
bash "$SCRIPT_DIR/secrets-encrypt.sh"
|
||||||
|
|
||||||
|
_decrypt_vars
|
||||||
|
_assert_contains "multi-line: header present" "FAKE-KEY-HEADER" "${SSH_KEY:-}"
|
||||||
|
_assert_contains "multi-line: body present" "fakekey" "${SSH_KEY:-}"
|
||||||
|
_assert_contains "multi-line: footer present" "FAKE-KEY-FOOTER" "${SSH_KEY:-}"
|
||||||
|
_assert "variable alongside multi-line" "plain" "${SIDE:-}"
|
||||||
|
unset SSH_KEY SIDE
|
||||||
|
|
||||||
|
# --- GITHUB_ENV output uses heredoc syntax ---
|
||||||
|
printf '%s\n' 'CI_SECRET=supersecret' > "$SECRETS_ENV"
|
||||||
|
rm -f "$SECRETS_AGE" "$GITHUB_ENV_FILE"
|
||||||
|
AGE_PUBLIC_KEY="$PUBLIC_KEY" \
|
||||||
|
SECRETS_ENV="$SECRETS_ENV" \
|
||||||
|
SECRETS_AGE="$SECRETS_AGE" \
|
||||||
|
bash "$SCRIPT_DIR/secrets-encrypt.sh"
|
||||||
|
|
||||||
|
GITHUB_ENV="$GITHUB_ENV_FILE" \
|
||||||
|
SECRETS_AGE_KEY="$PRIVATE_KEY" \
|
||||||
|
SECRETS_AGE="$SECRETS_AGE" \
|
||||||
|
bash "$SCRIPT_DIR/secrets-decrypt.sh"
|
||||||
|
|
||||||
|
_assert_contains "GITHUB_ENV contains key" "CI_SECRET" "$(cat "$GITHUB_ENV_FILE")"
|
||||||
|
_assert_contains "GITHUB_ENV contains value" "supersecret" "$(cat "$GITHUB_ENV_FILE")"
|
||||||
|
|
||||||
|
# --- missing secrets.age exits non-zero with a helpful message ---
|
||||||
|
ERR=$(SECRETS_AGE="$WORKDIR/nonexistent.age" \
|
||||||
|
SECRETS_AGE_KEY="$PRIVATE_KEY" \
|
||||||
|
bash "$SCRIPT_DIR/secrets-decrypt.sh" 2>&1) && GOT=0 || GOT=$?
|
||||||
|
_assert "missing secrets.age: exits non-zero" "1" "$GOT"
|
||||||
|
_assert_contains "missing secrets.age: error mentions file" "secrets.age" "$ERR"
|
||||||
|
|
||||||
|
# --- missing key exits non-zero ---
|
||||||
|
ERR=$(SECRETS_AGE="$SECRETS_AGE" \
|
||||||
|
bash "$SCRIPT_DIR/secrets-decrypt.sh" 2>&1) && GOT=0 || GOT=$?
|
||||||
|
_assert "missing key: exits non-zero" "1" "$GOT"
|
||||||
|
|
||||||
|
# --- wrong key fails decryption ---
|
||||||
|
OTHER_KEY="$WORKDIR/other.key"
|
||||||
|
age-keygen -o "$OTHER_KEY" 2>/dev/null
|
||||||
|
ERR=$(SECRETS_AGE_KEY=$(cat "$OTHER_KEY") \
|
||||||
|
SECRETS_AGE="$SECRETS_AGE" \
|
||||||
|
bash "$SCRIPT_DIR/secrets-decrypt.sh" 2>&1) && GOT=0 || GOT=$?
|
||||||
|
_assert "wrong key: exits non-zero" "1" "$GOT"
|
||||||
|
|
||||||
|
# --- encrypt without secrets.env exits non-zero ---
|
||||||
|
ERR=$(AGE_PUBLIC_KEY="$PUBLIC_KEY" \
|
||||||
|
SECRETS_ENV="$WORKDIR/missing_secrets.env" \
|
||||||
|
SECRETS_AGE="$WORKDIR/out.age" \
|
||||||
|
bash "$SCRIPT_DIR/secrets-encrypt.sh" 2>&1) && GOT=0 || GOT=$?
|
||||||
|
_assert "encrypt without secrets.env: exits non-zero" "1" "$GOT"
|
||||||
|
_assert_contains "encrypt without secrets.env: error mentions file" "secrets.env" "$ERR"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Results: $PASS passed, $FAIL failed"
|
||||||
|
[ "$FAIL" -eq 0 ] || exit 1
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Copy this file to secrets.env and fill in real values.
|
||||||
|
# Then encrypt to secrets.age: scripts/secrets-encrypt.sh
|
||||||
|
#
|
||||||
|
# secrets.env — plaintext, git-ignored
|
||||||
|
# secrets.age — encrypted, committed to the repository
|
||||||
|
# .age-public-key — age public key, committed (not secret)
|
||||||
|
#
|
||||||
|
# Multi-line values (SSH keys, certificates) must be stored as a single line
|
||||||
|
# with literal \n for newlines, wrapped in double quotes. Example:
|
||||||
|
# SSH_PRIVATE_KEY="<header line>\n<base64 body lines>\n<footer line>"
|
||||||
|
#
|
||||||
|
# One-time setup:
|
||||||
|
# age-keygen -o ~/.config/age/sharedinbox.key
|
||||||
|
# age-keygen -y ~/.config/age/sharedinbox.key > .age-public-key
|
||||||
|
# # Store the private key content in CI as SECRETS_AGE_KEY secret.
|
||||||
|
|
||||||
|
ANDROID_KEYSTORE_BASE64=
|
||||||
|
ANDROID_KEYSTORE_PASSWORD=
|
||||||
|
PLAY_STORE_CONFIG_JSON=
|
||||||
|
SSH_PRIVATE_KEY=
|
||||||
|
SSH_KNOWN_HOSTS=
|
||||||
|
SSH_USER=
|
||||||
|
SSH_HOST=
|
||||||
|
ANDROID_APK_SCP_HOST=
|
||||||
|
ANDROID_APK_SCP_USER=
|
||||||
|
ANDROID_APK_SCP_PATH=
|
||||||
|
FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY=
|
||||||
|
FIREBASE_PROJECT_ID=
|
||||||
@@ -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 {}
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,6 +27,22 @@ class MockUrlLauncher extends Mock
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class ThrowingUrlLauncher extends Mock
|
||||||
|
with MockPlatformInterfaceMixin
|
||||||
|
implements UrlLauncherPlatform {
|
||||||
|
@override
|
||||||
|
Future<bool> canLaunch(String? url) async => true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> launchUrl(String? url, LaunchOptions? options) async {
|
||||||
|
throw PlatformException(
|
||||||
|
code: 'channel-error',
|
||||||
|
message: 'Unable to establish connection on channel: '
|
||||||
|
'"dev.flutter.pigeon.url_launcher_android.UrlLauncherApi.launchUrl".',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildScreen({List<Account> accounts = const []}) {
|
Widget _buildScreen({List<Account> accounts = const []}) {
|
||||||
return ProviderScope(
|
return ProviderScope(
|
||||||
overrides: [
|
overrides: [
|
||||||
@@ -151,6 +167,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', (
|
||||||
@@ -176,4 +196,24 @@ void main() {
|
|||||||
);
|
);
|
||||||
expect(mock.launchedUrl, contains('1.2.3%2B99'));
|
expect(mock.launchedUrl, contains('1.2.3%2B99'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'AboutScreen link tap with failed url_launcher shows error snackbar',
|
||||||
|
(tester) async {
|
||||||
|
tester.view.physicalSize = const Size(800, 1200);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
|
||||||
|
UrlLauncherPlatform.instance = ThrowingUrlLauncher();
|
||||||
|
|
||||||
|
await tester.pumpWidget(_buildScreen());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.textContaining('sharedinbox.de').first);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.textContaining('Error:'), findsOneWidget);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ void main() {
|
|||||||
expect(find.byKey(const Key('scanEncryptedButton')), findsOneWidget);
|
expect(find.byKey(const Key('scanEncryptedButton')), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('shows 20-minute expiry hint', (tester) async {
|
testWidgets('shows expiry countdown hint', (tester) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
buildApp(
|
buildApp(
|
||||||
initialLocation: '/accounts/receive',
|
initialLocation: '/accounts/receive',
|
||||||
@@ -32,8 +32,106 @@ void main() {
|
|||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.textContaining('20 minutes'), findsOneWidget);
|
expect(find.textContaining('expires in'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'step 2 button shows text-input fallback on platforms without camera',
|
||||||
|
(tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
buildApp(
|
||||||
|
initialLocation: '/accounts/receive',
|
||||||
|
overrides: baseOverrides(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('scanEncryptedButton')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// On Linux (desktop, no camera) the text fallback field must appear.
|
||||||
|
expect(find.byKey(const Key('encryptedCodeField')), findsOneWidget);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'step 2 — valid encrypted QR imports account via text fallback',
|
||||||
|
(tester) async {
|
||||||
|
// Pre-generate a key pair so we can encrypt a QR code with the same
|
||||||
|
// material the screen will use for decryption.
|
||||||
|
final material = await ShareEncryptionService.generateKeyPair();
|
||||||
|
final repo = FakeShareKeyRepository(material: material);
|
||||||
|
|
||||||
|
const account = Account(
|
||||||
|
id: 'src-1',
|
||||||
|
displayName: 'Alice',
|
||||||
|
email: 'alice@example.com',
|
||||||
|
imapHost: 'imap.example.com',
|
||||||
|
smtpHost: 'smtp.example.com',
|
||||||
|
);
|
||||||
|
|
||||||
|
final encryptedQr = await ShareEncryptionService.encryptAccounts(
|
||||||
|
recipientKeyId: material.keyId,
|
||||||
|
recipientPublicKeyBytes: material.publicKeyBytes,
|
||||||
|
accounts: [
|
||||||
|
AccountPayload(
|
||||||
|
accountJson: account.toJson(),
|
||||||
|
password: 'secret',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
buildApp(
|
||||||
|
initialLocation: '/accounts/receive',
|
||||||
|
overrides: baseOverrides(shareKeyRepository: repo),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle(); // key generation completes
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('scanEncryptedButton')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.enterText(
|
||||||
|
find.byKey(const Key('encryptedCodeField')),
|
||||||
|
encryptedQr,
|
||||||
|
);
|
||||||
|
await tester.tap(find.text('Import'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
find.text('Imported 1 account successfully.'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'step 2 — invalid encrypted QR shows error and returns to pub-key step',
|
||||||
|
(tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
buildApp(
|
||||||
|
initialLocation: '/accounts/receive',
|
||||||
|
overrides: baseOverrides(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('scanEncryptedButton')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.enterText(
|
||||||
|
find.byKey(const Key('encryptedCodeField')),
|
||||||
|
'not-a-valid-qr-code',
|
||||||
|
);
|
||||||
|
await tester.tap(find.text('Import'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Screen returns to the pub-key step with an error message visible.
|
||||||
|
expect(find.byKey(const Key('pubKeyQrCode')), findsOneWidget);
|
||||||
|
expect(find.textContaining('Import failed:'), findsWidgets);
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
group('AccountSendScreen', () {
|
group('AccountSendScreen', () {
|
||||||
|
|||||||
@@ -116,13 +116,89 @@ void main() {
|
|||||||
|
|
||||||
expect(clipboardText, isNotNull);
|
expect(clipboardText, isNotNull);
|
||||||
expect(clipboardText, contains('App Version: 1.0.0+42'));
|
expect(clipboardText, contains('App Version: 1.0.0+42'));
|
||||||
|
expect(clipboardText, contains('Build Mode:'));
|
||||||
expect(clipboardText, contains('Platform:'));
|
expect(clipboardText, contains('Platform:'));
|
||||||
|
expect(clipboardText, contains('Dart:'));
|
||||||
|
expect(clipboardText, contains('Timestamp:'));
|
||||||
expect(clipboardText, contains('TestException: clipboard test'));
|
expect(clipboardText, contains('TestException: clipboard test'));
|
||||||
// GIT_HASH is empty in test builds — no Git Commit line expected
|
// GIT_HASH is empty in test builds — no Git Commit line expected
|
||||||
expect(clipboardText, isNot(contains('Git Commit:')));
|
expect(clipboardText, isNot(contains('Git Commit:')));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
'CrashScreen shows version, build mode, and platform in the UI',
|
||||||
|
(tester) async {
|
||||||
|
tester.view.physicalSize = const Size(800, 1200);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(() => tester.view.resetPhysicalSize());
|
||||||
|
|
||||||
|
const exception = 'TestException: info row test';
|
||||||
|
final stackTrace = StackTrace.current;
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: CrashScreen(exception: exception, stackTrace: stackTrace),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Info row shows app version (from mock), build mode, and platform OS.
|
||||||
|
expect(find.textContaining('1.0.0+42'), findsWidgets);
|
||||||
|
// In test builds kDebugMode is true.
|
||||||
|
expect(find.textContaining('debug'), findsOneWidget);
|
||||||
|
// Platform OS is always present (linux in CI, android/ios on device).
|
||||||
|
expect(
|
||||||
|
find.textContaining(RegExp(r'linux|android|ios|windows|macos')),
|
||||||
|
findsWidgets,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
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 {
|
||||||
|
|||||||
@@ -79,11 +79,13 @@ class FakeAccountRepository implements AccountRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class FakeShareKeyRepository implements ShareKeyRepository {
|
class FakeShareKeyRepository implements ShareKeyRepository {
|
||||||
|
FakeShareKeyRepository({ShareKeyMaterial? material}) : _material = material;
|
||||||
|
|
||||||
ShareKeyMaterial? _material;
|
ShareKeyMaterial? _material;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<ShareKeyMaterial> createKeyPair() async {
|
Future<ShareKeyMaterial> createKeyPair() async {
|
||||||
_material = await ShareEncryptionService.generateKeyPair();
|
_material ??= await ShareEncryptionService.generateKeyPair();
|
||||||
return _material!;
|
return _material!;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -511,6 +513,7 @@ List<Override> baseOverrides({
|
|||||||
List<Mailbox>? mailboxes,
|
List<Mailbox>? mailboxes,
|
||||||
DiscoveryResult? discovery,
|
DiscoveryResult? discovery,
|
||||||
Exception? connectionError,
|
Exception? connectionError,
|
||||||
|
ShareKeyRepository? shareKeyRepository,
|
||||||
}) =>
|
}) =>
|
||||||
[
|
[
|
||||||
accountRepositoryProvider
|
accountRepositoryProvider
|
||||||
@@ -525,7 +528,9 @@ List<Override> baseOverrides({
|
|||||||
connectionTestServiceProvider.overrideWithValue(
|
connectionTestServiceProvider.overrideWithValue(
|
||||||
FakeConnectionTestService(error: connectionError),
|
FakeConnectionTestService(error: connectionError),
|
||||||
),
|
),
|
||||||
shareKeyRepositoryProvider.overrideWithValue(FakeShareKeyRepository()),
|
shareKeyRepositoryProvider.overrideWithValue(
|
||||||
|
shareKeyRepository ?? FakeShareKeyRepository(),
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user