Compare commits
179
Commits
+8
-10
@@ -1,20 +1,18 @@
|
||||
# Dagger context ignore file.
|
||||
# Since we use explicit inclusion in ci/main.go (Base function),
|
||||
# we only need to ignore large or sensitive directories here to
|
||||
# avoid unnecessary upload overhead to the Dagger engine.
|
||||
|
||||
# Version control
|
||||
.git/
|
||||
|
||||
# Build artifacts
|
||||
build/
|
||||
.dart_tool/
|
||||
.fvm/
|
||||
.pub-cache/
|
||||
node_modules/
|
||||
ios/Pods/
|
||||
macos/Pods/
|
||||
coverage/
|
||||
linux/flutter/ephemeral/
|
||||
website/public/
|
||||
website/resources/
|
||||
.task/
|
||||
.fvm/
|
||||
|
||||
# Sensitive files
|
||||
# Secrets
|
||||
.env*
|
||||
.ssh/
|
||||
.envrc
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# Source: https://codeberg.org/guettli/sharedinbox/src/branch/main/.forgejo/Dockerfile
|
||||
# Install at on the act-runner host on: /etc/forgejo/runner/Dockerfile
|
||||
#
|
||||
# In systemd service:
|
||||
# ExecStartPre=docker build -t forgejo-act-runner:latest /etc/forgejo/runner
|
||||
# ExecStart=/usr/local/bin/forgejo-runner daemon --config /etc/forgejo/config.yml
|
||||
FROM ghcr.io/catthehacker/ubuntu:go-24.04
|
||||
|
||||
# Infrastructure tools required by CI workflows
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
stunnel4 \
|
||||
netcat-openbsd \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Dagger CLI — pinned to match the engine version on the runner host
|
||||
RUN curl -fsSL https://dl.dagger.io/dagger/install.sh \
|
||||
| DAGGER_VERSION=0.20.8 BIN_DIR=/usr/local/bin sh
|
||||
|
||||
# Task runner
|
||||
RUN curl -fsSL https://taskfile.dev/install.sh \
|
||||
| sh -s -- -b /usr/local/bin v3.48.0
|
||||
@@ -1,39 +0,0 @@
|
||||
# We switched to Dagger. Running the emulator tests in Dagger does not really work
|
||||
# We will use an external service for device testing.
|
||||
# TODO: Switch to device testing. First choose a service. Maybe codemagic.io
|
||||
name: Android Emulator Tests (Disabled)
|
||||
|
||||
on:
|
||||
workflow_dispatch: # Manual trigger only
|
||||
|
||||
jobs:
|
||||
integration-android:
|
||||
name: Android Emulator Integration Tests
|
||||
runs-on: self-hosted
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 50
|
||||
|
||||
- name: Enable Nix flakes
|
||||
run: |
|
||||
mkdir -p ~/.config/nix
|
||||
echo "experimental-features = nix-command flakes" >> ~/.config/nix/nix.conf
|
||||
|
||||
- name: Install Android SDK
|
||||
run: |
|
||||
SDK="${ANDROID_HOME:-$HOME/Android/Sdk}"
|
||||
if [ ! -d "$SDK/platforms/android-34" ]; then
|
||||
wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O /tmp/cmdtools.zip
|
||||
mkdir -p "$SDK/cmdline-tools"
|
||||
unzip -q /tmp/cmdtools.zip -d "$SDK/cmdline-tools"
|
||||
[ -d "$SDK/cmdline-tools/cmdline-tools" ] && mv "$SDK/cmdline-tools/cmdline-tools" "$SDK/cmdline-tools/latest"
|
||||
yes | "$SDK/cmdline-tools/latest/bin/sdkmanager" --licenses >/dev/null 2>&1 || true
|
||||
"$SDK/cmdline-tools/latest/bin/sdkmanager" "emulator" "system-images;android-34;google_apis;x86_64"
|
||||
"$SDK/cmdline-tools/latest/bin/sdkmanager" "platform-tools" "build-tools;34.0.0" "platforms;android-34"
|
||||
fi
|
||||
|
||||
- name: Run Android Integration Tests
|
||||
run: nix develop --no-warn-dirty --command task integration-android
|
||||
+19
-118
@@ -8,132 +8,33 @@ on:
|
||||
jobs:
|
||||
check:
|
||||
name: Full Project Check
|
||||
runs-on: self-hosted
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 50
|
||||
|
||||
- name: Enable Nix flakes
|
||||
- name: Check runner tools
|
||||
run: |
|
||||
mkdir -p ~/.config/nix
|
||||
echo "experimental-features = nix-command flakes" >> ~/.config/nix/nix.conf
|
||||
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
|
||||
|
||||
- name: Setup Dagger Remote Engine (via stunnel)
|
||||
env:
|
||||
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
|
||||
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
|
||||
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
|
||||
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
|
||||
run: scripts/setup_dagger_remote.sh
|
||||
|
||||
- name: Run Full Check Suite
|
||||
run: nix develop --no-warn-dirty --command dagger call --progress=plain -m ci check --source .
|
||||
|
||||
build-linux:
|
||||
name: Build Linux Release
|
||||
runs-on: self-hosted
|
||||
needs: check
|
||||
if: github.ref == 'refs/heads/main'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 50
|
||||
|
||||
- name: Enable Nix flakes
|
||||
run: |
|
||||
mkdir -p ~/.config/nix
|
||||
echo "experimental-features = nix-command flakes" >> ~/.config/nix/nix.conf
|
||||
|
||||
- name: Build & Deploy Linux to server
|
||||
continue-on-error: true
|
||||
env:
|
||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
SSH_USER: ${{ secrets.SSH_USER }}
|
||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||
run: |
|
||||
HASH=$(git rev-parse --short HEAD)
|
||||
nix develop --no-warn-dirty --command dagger call --progress=plain -m ci deploy-linux --source . --ssh-key env:SSH_PRIVATE_KEY --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH"
|
||||
DAGGER_NO_NAG: "1"
|
||||
run: task check-dagger
|
||||
|
||||
deploy-playstore:
|
||||
name: Build & Deploy to Play Store
|
||||
runs-on: self-hosted
|
||||
needs: check
|
||||
if: github.ref == 'refs/heads/main'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 50
|
||||
|
||||
- name: Enable Nix flakes
|
||||
run: |
|
||||
mkdir -p ~/.config/nix
|
||||
echo "experimental-features = nix-command flakes" >> ~/.config/nix/nix.conf
|
||||
|
||||
- name: Install Android SDK (cached on runner between runs)
|
||||
run: |
|
||||
SDK="${ANDROID_HOME:-$HOME/Android/Sdk}"
|
||||
if [ ! -d "$SDK/platforms/android-34" ]; then
|
||||
echo "Android SDK not found, installing..."
|
||||
wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O /tmp/cmdtools.zip
|
||||
mkdir -p "$SDK/cmdline-tools"
|
||||
unzip -q /tmp/cmdtools.zip -d "$SDK/cmdline-tools"
|
||||
[ -d "$SDK/cmdline-tools/cmdline-tools" ] && mv "$SDK/cmdline-tools/cmdline-tools" "$SDK/cmdline-tools/latest"
|
||||
yes | "$SDK/cmdline-tools/latest/bin/sdkmanager" --licenses >/dev/null 2>&1 || true
|
||||
"$SDK/cmdline-tools/latest/bin/sdkmanager" "platform-tools" "build-tools;34.0.0" "platforms;android-34"
|
||||
else
|
||||
echo "Android SDK cached, skipping install."
|
||||
fi
|
||||
|
||||
- name: Prepare Keystore
|
||||
env:
|
||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
run: |
|
||||
if [ -n "$ANDROID_KEYSTORE_BASE64" ]; then
|
||||
echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > android/app/upload-keystore.jks
|
||||
else
|
||||
echo "Error: ANDROID_KEYSTORE_BASE64 secret is not set."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Build & Deploy to Play Store
|
||||
env:
|
||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
PLAY_STORE_CONFIG_JSON: ${{ secrets.PLAY_STORE_CONFIG_JSON }}
|
||||
run: |
|
||||
nix develop --no-warn-dirty --command dagger call --progress=plain -m ci publish-android --source . --play-store-config env:PLAY_STORE_CONFIG_JSON
|
||||
|
||||
- name: Build & Deploy APK to server
|
||||
continue-on-error: true
|
||||
env:
|
||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
SSH_USER: ${{ secrets.SSH_USER }}
|
||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
run: |
|
||||
HASH=$(git rev-parse --short HEAD)
|
||||
nix develop --no-warn-dirty --command dagger call --progress=plain -m ci deploy-apk --source . --ssh-key env:SSH_PRIVATE_KEY --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH"
|
||||
|
||||
publish-website:
|
||||
name: Publish Website Build History
|
||||
runs-on: self-hosted
|
||||
needs: [build-linux, deploy-playstore]
|
||||
if: |
|
||||
always() &&
|
||||
github.ref == 'refs/heads/main' &&
|
||||
(needs.build-linux.result == 'success' || needs.deploy-playstore.result == 'success')
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Enable Nix flakes
|
||||
run: |
|
||||
mkdir -p ~/.config/nix
|
||||
echo "experimental-features = nix-command flakes" >> ~/.config/nix/nix.conf
|
||||
|
||||
- name: Generate build history and deploy website
|
||||
continue-on-error: true
|
||||
env:
|
||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
SSH_USER: ${{ secrets.SSH_USER }}
|
||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||
run: |
|
||||
nix develop --no-warn-dirty --command dagger call --progress=plain -m ci publish-website --source . --ssh-key env:SSH_PRIVATE_KEY --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST"
|
||||
- name: Cleanup TLS credentials
|
||||
if: always()
|
||||
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 * * * *' # every hour on the hour
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test-android-firebase:
|
||||
name: Android Instrumented Tests (Firebase Test Lab)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 50
|
||||
|
||||
- name: Check runner tools
|
||||
run: |
|
||||
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
|
||||
|
||||
- name: Setup Dagger Remote Engine (via stunnel)
|
||||
env:
|
||||
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
|
||||
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
|
||||
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
|
||||
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
|
||||
run: scripts/setup_dagger_remote.sh
|
||||
|
||||
- name: Run Android Tests on Firebase Test Lab
|
||||
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"
|
||||
run: task test-android-firebase
|
||||
|
||||
- name: Cleanup TLS credentials
|
||||
if: always()
|
||||
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
||||
|
||||
deploy-playstore:
|
||||
name: Build & Deploy to Play Store
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 50
|
||||
|
||||
- name: Check runner tools
|
||||
run: |
|
||||
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
|
||||
|
||||
- name: Setup Dagger Remote Engine (via stunnel)
|
||||
env:
|
||||
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
|
||||
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
|
||||
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
|
||||
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
|
||||
run: scripts/setup_dagger_remote.sh
|
||||
|
||||
- name: Publish Android to Play Store
|
||||
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"
|
||||
run: task publish-android
|
||||
|
||||
- name: Build & Deploy APK to server
|
||||
continue-on-error: true
|
||||
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"
|
||||
run: task deploy-apk
|
||||
|
||||
- name: Cleanup TLS credentials
|
||||
if: always()
|
||||
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
||||
|
||||
build-linux:
|
||||
name: Build Linux Release
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 50
|
||||
|
||||
- name: Check runner tools
|
||||
run: |
|
||||
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
|
||||
|
||||
- name: Setup Dagger Remote Engine (via stunnel)
|
||||
env:
|
||||
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
|
||||
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
|
||||
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
|
||||
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
|
||||
run: scripts/setup_dagger_remote.sh
|
||||
|
||||
- name: Build & Deploy Linux to server
|
||||
continue-on-error: true
|
||||
env:
|
||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
SSH_USER: ${{ secrets.SSH_USER }}
|
||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||
DAGGER_NO_NAG: "1"
|
||||
run: task deploy-linux
|
||||
|
||||
- name: Cleanup TLS credentials
|
||||
if: always()
|
||||
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
||||
|
||||
publish-website:
|
||||
name: Publish Website Build History
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-linux, deploy-playstore]
|
||||
if: |
|
||||
always() &&
|
||||
(needs.build-linux.result == 'success' || needs.deploy-playstore.result == 'success')
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 50
|
||||
|
||||
- name: Check runner tools
|
||||
run: |
|
||||
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
|
||||
|
||||
- name: Setup Dagger Remote Engine (via stunnel)
|
||||
env:
|
||||
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
|
||||
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
|
||||
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
|
||||
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
|
||||
run: scripts/setup_dagger_remote.sh
|
||||
|
||||
- name: Generate build history and deploy website
|
||||
continue-on-error: true
|
||||
env:
|
||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
SSH_USER: ${{ secrets.SSH_USER }}
|
||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||
DAGGER_NO_NAG: "1"
|
||||
run: task publish-website
|
||||
|
||||
- name: Cleanup TLS credentials
|
||||
if: always()
|
||||
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
||||
|
||||
label-deploy-health:
|
||||
name: Update Deploy Health Label
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test-android-firebase, deploy-playstore, build-linux]
|
||||
if: always() && vars.DEPLOY_HEALTH_ISSUE != ''
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- name: Set CI/Full-Pass or CI/Full-Fail label on tracking issue
|
||||
env:
|
||||
FORGEJO_TOKEN: ${{ github.token }}
|
||||
FORGEJO_URL: ${{ github.server_url }}
|
||||
DEPLOY_HEALTH_ISSUE: ${{ vars.DEPLOY_HEALTH_ISSUE }}
|
||||
ALL_SUCCEEDED: ${{ needs.test-android-firebase.result == 'success' && needs.deploy-playstore.result == 'success' && needs.build-linux.result == 'success' }}
|
||||
run: |
|
||||
python3 - << 'PYEOF'
|
||||
import os, json, urllib.request, urllib.error
|
||||
|
||||
issue = os.environ.get("DEPLOY_HEALTH_ISSUE", "").strip()
|
||||
if not issue:
|
||||
print("DEPLOY_HEALTH_ISSUE not set; skipping")
|
||||
raise SystemExit(0)
|
||||
|
||||
token = os.environ["FORGEJO_TOKEN"]
|
||||
url_base = os.environ["FORGEJO_URL"].rstrip("/")
|
||||
succeeded = os.environ.get("ALL_SUCCEEDED", "false").lower() == "true"
|
||||
add_label = "CI/Full-Pass" if succeeded else "CI/Full-Fail"
|
||||
remove_label = "CI/Full-Fail" if succeeded else "CI/Full-Pass"
|
||||
|
||||
headers = {"Authorization": f"token {token}", "Content-Type": "application/json"}
|
||||
api = f"{url_base}/api/v1/repos/guettli/sharedinbox"
|
||||
|
||||
def api_get(path):
|
||||
req = urllib.request.Request(f"{api}{path}", headers=headers)
|
||||
with urllib.request.urlopen(req) as r:
|
||||
return json.loads(r.read())
|
||||
|
||||
def api_put(path, body):
|
||||
data = json.dumps(body).encode()
|
||||
req = urllib.request.Request(f"{api}{path}", data=data, headers=headers, method="PUT")
|
||||
try:
|
||||
with urllib.request.urlopen(req) as r:
|
||||
return json.loads(r.read())
|
||||
except urllib.error.HTTPError as e:
|
||||
print(f"PUT {path} failed: {e.read().decode()}")
|
||||
raise
|
||||
|
||||
repo_labels = api_get("/labels")
|
||||
label_map = {l["name"]: l["id"] for l in repo_labels}
|
||||
|
||||
if add_label not in label_map:
|
||||
print(f"Label '{add_label}' not found in repo — create it first")
|
||||
raise SystemExit(1)
|
||||
|
||||
current = api_get(f"/issues/{issue}/labels")
|
||||
keep_ids = [l["id"] for l in current if l["name"] not in ("CI/Full-Pass", "CI/Full-Fail")]
|
||||
keep_ids.append(label_map[add_label])
|
||||
|
||||
api_put(f"/issues/{issue}/labels", {"labels": keep_ids})
|
||||
print(f"Set '{add_label}' on issue #{issue}")
|
||||
PYEOF
|
||||
@@ -12,36 +12,21 @@ on:
|
||||
jobs:
|
||||
deploy:
|
||||
name: Build & Deploy Website
|
||||
runs-on: self-hosted
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Enable Nix flakes
|
||||
run: |
|
||||
mkdir -p ~/.config/nix
|
||||
echo "experimental-features = nix-command flakes" >> ~/.config/nix/nix.conf
|
||||
|
||||
- name: Setup SSH
|
||||
- name: Build & Deploy Website
|
||||
env:
|
||||
SSH_PRIVATE_KEY: ${{ secrets.WEBSITE_SSH_PRIVATE_KEY }}
|
||||
run: |
|
||||
if [ -n "$SSH_PRIVATE_KEY" ]; then
|
||||
mkdir -p ~/.ssh
|
||||
echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa
|
||||
chmod 600 ~/.ssh/id_rsa
|
||||
else
|
||||
echo "Error: WEBSITE_SSH_PRIVATE_KEY secret is not set."
|
||||
exit 1
|
||||
fi
|
||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
SSH_USER: ${{ secrets.SSH_USER }}
|
||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||
run: task website-deploy
|
||||
|
||||
- name: Deploy
|
||||
- name: Verify Website
|
||||
env:
|
||||
SSH_USER: ${{ secrets.WEBSITE_SSH_USER }}
|
||||
SSH_HOST: ${{ secrets.WEBSITE_SSH_HOST }}
|
||||
run: nix develop --command task website-deploy
|
||||
|
||||
- name: Verify
|
||||
run: nix develop --command task website-verify
|
||||
run: scripts/website-verify.sh
|
||||
|
||||
@@ -8,7 +8,7 @@ on:
|
||||
jobs:
|
||||
analyze-and-test:
|
||||
name: Analyze & unit test
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: sharedinbox-runner
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
|
||||
integration:
|
||||
name: Integration tests (Stalwart)
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: sharedinbox-runner
|
||||
# Run integration tests only on push to main, not on every PR.
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
|
||||
@@ -74,7 +74,7 @@ jobs:
|
||||
|
||||
integration-ui:
|
||||
name: UI Integration tests (Stalwart + Xvfb)
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: sharedinbox-runner
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
|
||||
steps:
|
||||
@@ -124,7 +124,7 @@ jobs:
|
||||
|
||||
build-linux:
|
||||
name: Build Linux desktop
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: sharedinbox-runner
|
||||
needs: analyze-and-test
|
||||
|
||||
steps:
|
||||
@@ -154,7 +154,7 @@ jobs:
|
||||
|
||||
deploy:
|
||||
name: Deploy Linux build & publish website
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: sharedinbox-runner
|
||||
needs: build-linux
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
env:
|
||||
|
||||
+5
-1
@@ -3,7 +3,6 @@ coverage/
|
||||
.dart_tool/
|
||||
.dart-tool/
|
||||
.packages
|
||||
pubspec.lock
|
||||
build/
|
||||
*.g.dart
|
||||
*.freezed.dart
|
||||
@@ -117,3 +116,8 @@ test/widget/failures/
|
||||
dagger-certs
|
||||
.Xauthority
|
||||
.sharedinbox-agent-state.json
|
||||
|
||||
.viminfo
|
||||
/go
|
||||
.last_deployed_sha
|
||||
.fail_count
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
[submodule "website/themes/PaperMod"]
|
||||
path = website/themes/PaperMod
|
||||
url = https://github.com/adityatelange/hugo-PaperMod.git
|
||||
@@ -30,3 +30,15 @@ repos:
|
||||
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && nix develop --command scripts/pre_commit_check.sh'
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
- id: ci-no-direct-dagger
|
||||
name: check for direct dagger calls in workflows (use Task instead)
|
||||
language: system
|
||||
entry: "bash -c 'git grep \"dagger call\" .forgejo/workflows/ && echo \"ERROR: Direct dagger calls found in workflows. Use Taskfile instead.\" && exit 1 || exit 0'"
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
- id: dagger-progress-plain
|
||||
name: ensure all dagger calls use --progress=plain
|
||||
language: system
|
||||
entry: "bash -c 'git grep \"dagger call\" -- \":!.pre-commit-config.yaml\" | grep -v \"\\-\\-progress=plain\" && echo \"ERROR: All dagger calls must include --progress=plain\" && exit 1 || exit 0'"
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
|
||||
@@ -23,7 +23,9 @@ fgj issue list --json --state open | jq '[.[] | select(.labels[].name == "State/
|
||||
Rules:
|
||||
|
||||
- Never start work on an issue without `State/Ready`
|
||||
- Switch `State/Ready` → `State/InProgress` as your **first action** when picking up an issue — before reading any code:
|
||||
- When working via the agent loop: `State/Ready` → `State/InProgress` is set automatically
|
||||
by `agent_loop.py` before the agent starts — do **not** set it yourself.
|
||||
- When working manually: switch to `State/InProgress` as your **first action**:
|
||||
```bash
|
||||
fgj issue edit <NUMBER> --remove-label "State/Ready" --add-label "State/InProgress"
|
||||
```
|
||||
|
||||
@@ -61,9 +61,29 @@ _DAGGER_RUNNER_HOST=tcp://127.0.0.1:8080
|
||||
Once the environment is set up, you can run the Dagger pipeline. For non-interactive environments (CI, LLMs), use `--progress=plain` for readable logs:
|
||||
|
||||
```bash
|
||||
nix develop --command dagger call --progress=plain -m ci check --source .
|
||||
nix develop --command dagger call --progress=plain -q -m ci --source=. check
|
||||
```
|
||||
|
||||
## Secrets
|
||||
|
||||
All sensitive credentials are passed as `dagger.Secret` (never as plain strings).
|
||||
This prevents values from appearing in Dagger logs or being cached in the engine.
|
||||
|
||||
| Parameter | Functions |
|
||||
|---|---|
|
||||
| `sshKey *dagger.Secret` | `Deployer`, `GenerateBuildHistory`, `BuildWebsite`, `PublishWebsite`, `DeployLinux`, `DeployApk` |
|
||||
| `keystoreBase64 *dagger.Secret` | `setupKeystore`, `BuildAndroidApk`, `DeployApk`, `SignAndroidBundle`, `PublishAndroid` |
|
||||
| `keystorePassword *dagger.Secret` | same as above |
|
||||
| `playStoreConfig *dagger.Secret` | `UploadToPlayStore`, `PublishAndroid` |
|
||||
| `serviceAccountKey *dagger.Secret` | `TestAndroidFirebase` |
|
||||
|
||||
Secrets are injected via `WithMountedSecret` (file-based, e.g. SSH key) or
|
||||
`WithSecretVariable` (env-var-based, e.g. keystore data, Play Store JSON).
|
||||
|
||||
The only credentials not typed as `dagger.Secret` are the test passwords
|
||||
(`STALWART_PASS_B`, `STALWART_PASS_C`) in `WithStalwart`. These are hardcoded
|
||||
development values defined in `stalwart-dev/` — not production secrets.
|
||||
|
||||
## CI Integration (Codeberg/Forgejo)
|
||||
|
||||
The CI workflow in `.forgejo/workflows/ci.yml` is configured to use the Dagger module located in the `ci/` directory.
|
||||
@@ -71,3 +91,93 @@ The CI workflow in `.forgejo/workflows/ci.yml` is configured to use the Dagger m
|
||||
- **Check Suite:** Runs analysis and tests in parallel.
|
||||
- **Builds:** Produces Linux and Android artifacts.
|
||||
- **Caching:** When using the shared engine, CI runners benefit from the persistent cache on the host.
|
||||
|
||||
## Credential Security — Keeping Production Secrets Off Codeberg
|
||||
|
||||
### Problem
|
||||
|
||||
The current setup stores two categories of secrets in Codeberg repository secrets:
|
||||
|
||||
1. **Dagger access credentials** — TLS certificates used to connect to the remote Dagger engine via stunnel (`DAGGER_CA_CERT`, `DAGGER_CLIENT_CERT`, `DAGGER_CLIENT_KEY`, `DAGGER_STUNNEL_URL`).
|
||||
2. **Production secrets** — actual credentials for external services: `ANDROID_KEYSTORE_BASE64`, `ANDROID_KEYSTORE_PASSWORD`, `PLAY_STORE_CONFIG_JSON`, `SSH_PRIVATE_KEY`, `FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY`.
|
||||
|
||||
If Codeberg is compromised, both categories are leaked. The Dagger TLS certificates enable access only to the Dagger engine and have limited blast radius. But the production secrets give direct access to the Play Store, the Android signing key, the deployment server, and Firebase — a much larger blast radius.
|
||||
|
||||
**Goal:** Keep only Dagger access credentials in Codeberg. Store all production secrets on the Dagger host machine so they never touch Codeberg.
|
||||
|
||||
### Option 1: Runner-level environment variables
|
||||
|
||||
Store production secrets as environment variables in the Forgejo runner's systemd service (e.g., via a `EnvironmentFile=` in the service override). The runner injects host env vars into job processes automatically. CI workflows drop the `${{ secrets.XYZ }}` references for production secrets entirely — the variables are already present in the job environment.
|
||||
|
||||
**Pro:**
|
||||
- No new infrastructure required.
|
||||
- Works with the existing `dagger call --progress=plain --secret env:VAR_NAME` argument style.
|
||||
- Secrets never enter Codeberg.
|
||||
- Straightforward to set up on a single self-hosted runner.
|
||||
|
||||
**Con:**
|
||||
- Env vars are visible to every process on the runner host (e.g., via `/proc/<pid>/environ`).
|
||||
- Rotating a secret requires host access (no API).
|
||||
- Does not scale cleanly to multiple runners without a shared secrets mechanism.
|
||||
|
||||
### Option 2: Secret files on the CI host with restricted permissions
|
||||
|
||||
Store production secrets as files owned by the runner user with mode `600` (e.g., `/home/forgejo-runner/secrets/play_store.json`). A small setup script reads the files and either exports them as env vars or passes them directly as file-type arguments to `dagger call --progress=plain`. CI workflows contain no secret references at all.
|
||||
|
||||
**Pro:**
|
||||
- OS-level file permissions limit access to the runner user.
|
||||
- Natural format for JSON payloads and key files.
|
||||
- Easy to audit (list files, check mtime).
|
||||
- No new infrastructure.
|
||||
|
||||
**Con:**
|
||||
- Plaintext files on disk; root or backup access exposes them.
|
||||
- Workflow must know file paths (either hardcoded or by convention).
|
||||
- Rotation still requires host filesystem access.
|
||||
|
||||
### Option 3: Dagger host as pipeline orchestrator
|
||||
|
||||
Instead of the CI runner invoking the Dagger CLI directly, the CI job sends a trigger to the Dagger host over SSH. The Dagger host runs the pipeline locally against its own environment, where secrets live as env vars or files. Codeberg only stores the SSH key to reach the Dagger host — not the production secrets.
|
||||
|
||||
```yaml
|
||||
# CI job only does this:
|
||||
- name: Trigger pipeline on Dagger host
|
||||
run: ssh dagger-host "cd sharedinbox && task publish-android"
|
||||
env:
|
||||
SSH_PRIVATE_KEY: ${{ secrets.DAGGER_TRIGGER_SSH_KEY }}
|
||||
```
|
||||
|
||||
**Pro:**
|
||||
- Production secrets never leave the Dagger host.
|
||||
- Codeberg stores exactly one secret: the trigger SSH key.
|
||||
- All deployment logic and secrets are fully contained on the host.
|
||||
|
||||
**Con:**
|
||||
- Harder to stream structured CI logs back to Codeberg Actions.
|
||||
- Dynamic context (commit SHA, PR branch) must be passed explicitly over SSH.
|
||||
- The trigger SSH key still grants shell access to the host, so its compromise has its own blast radius.
|
||||
- CI becomes a "fire-and-forget" call, making failure attribution harder.
|
||||
|
||||
### Option 4: External secret manager (e.g., HashiCorp Vault)
|
||||
|
||||
Run a secret manager co-located with the Dagger host. The CI job authenticates with a short-lived AppRole credential (stored in Codeberg) and retrieves secrets at runtime. Vault can also be configured with IP-allow-lists to further restrict who can authenticate.
|
||||
|
||||
**Pro:**
|
||||
- Full audit trail: every secret read is logged with a timestamp and caller identity.
|
||||
- Fine-grained access control per secret.
|
||||
- Built-in versioning and rotation support.
|
||||
- Industry-standard approach; scales to team or multi-runner setups.
|
||||
|
||||
**Con:**
|
||||
- Significant additional infrastructure to install, configure, and maintain.
|
||||
- Vault credentials (RoleID + SecretID) still need to be in Codeberg, though with a smaller blast radius than raw secrets.
|
||||
- Vault itself becomes a security-critical single point of failure.
|
||||
- Operational overhead likely disproportionate for a small single-developer project.
|
||||
|
||||
### 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 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.
|
||||
|
||||
+142
-16
@@ -1,6 +1,9 @@
|
||||
version: "3"
|
||||
silent: true
|
||||
|
||||
env:
|
||||
DAGGER_NO_NAG: "1"
|
||||
|
||||
tasks:
|
||||
default:
|
||||
desc: Run all checks (analyze + unit tests + widget tests + integration, in parallel)
|
||||
@@ -172,22 +175,145 @@ tasks:
|
||||
- fvm flutter test
|
||||
|
||||
test-backend:
|
||||
desc: Backend tests against a local Stalwart mail server
|
||||
deps: [_flutter-check]
|
||||
sources:
|
||||
- lib/**/*.dart
|
||||
- test/backend/**/*.dart
|
||||
desc: Backend tests against a local Stalwart mail server (via Dagger)
|
||||
cmds:
|
||||
- stalwart-dev/test.sh
|
||||
- dagger call --progress=plain -q -m ci --source=. test-backend
|
||||
|
||||
integration-ui:
|
||||
desc: UI E2E tests on Linux via Xvfb — headless, no emulator needed
|
||||
deps: [_preflight, _linux-deps-check, _pub-get]
|
||||
sources:
|
||||
- lib/**/*.dart
|
||||
- integration_test/app_e2e_test.dart
|
||||
desc: UI E2E tests on Linux via Xvfb — headless, no emulator needed (via Dagger)
|
||||
cmds:
|
||||
- stalwart-dev/integration_ui_test.sh
|
||||
- dagger call --progress=plain -q -m ci --source=. test-integration
|
||||
|
||||
sync-reliability:
|
||||
desc: Run sync reliability runner (via Dagger)
|
||||
cmds:
|
||||
- dagger call --progress=plain -q -m ci --source=. test-sync-reliability
|
||||
|
||||
test-android-firebase:
|
||||
desc: Build Android debug APKs and run instrumented tests on Firebase Test Lab (via Dagger)
|
||||
preconditions:
|
||||
- sh: test -n "$FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY"
|
||||
msg: "FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY is not set"
|
||||
- sh: test -n "$FIREBASE_PROJECT_ID"
|
||||
msg: "FIREBASE_PROJECT_ID is not set"
|
||||
cmds:
|
||||
- scripts/run_firebase_test.sh
|
||||
|
||||
ci-graph:
|
||||
desc: Print a Mermaid diagram of the CI pipeline — paste into mermaid.live or any Markdown renderer
|
||||
cmds:
|
||||
- dagger call --progress=plain -q -m ci --source=. graph
|
||||
|
||||
stalwart:
|
||||
desc: Start a Stalwart instance for local development (via Dagger)
|
||||
cmds:
|
||||
- echo "Starting Stalwart on default ports (JMAP=8080, IMAP=1430, SMTP=1025, SIEVE=4190)"
|
||||
- dagger call --progress=plain -q -m ci --source=. stalwart up --ports 8080:8080 --ports 1430:1430 --ports 1025:1025 --ports 4190:4190
|
||||
|
||||
deploy-linux:
|
||||
desc: Build and deploy Linux release via Dagger
|
||||
preconditions:
|
||||
- sh: test -n "$SSH_PRIVATE_KEY"
|
||||
msg: "SSH_PRIVATE_KEY is not set"
|
||||
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"
|
||||
|
||||
build-android-bundle:
|
||||
desc: Build AAB via Dagger (cached, versionCode=1 placeholder) and export locally
|
||||
cmds:
|
||||
- mkdir -p build/app/outputs/bundle/release
|
||||
- dagger call --progress=plain -q -m ci --source=. build-android-release -o build/app/outputs/bundle/release/app-release.aab
|
||||
|
||||
upload-android-bundle:
|
||||
desc: Upload AAB from build/ to Play Store via Dagger
|
||||
preconditions:
|
||||
- sh: test -n "$PLAY_STORE_CONFIG_JSON"
|
||||
msg: "PLAY_STORE_CONFIG_JSON is not set"
|
||||
- sh: test -f build/app/outputs/bundle/release/app-release.aab
|
||||
msg: "AAB not found — run build-android-bundle first"
|
||||
cmds:
|
||||
- dagger call --progress=plain -q -m ci --source=. upload-to-play-store --aab build/app/outputs/bundle/release/app-release.aab --play-store-config env:PLAY_STORE_CONFIG_JSON
|
||||
|
||||
publish-android:
|
||||
desc: Build cached AAB, stamp versionCode, sign, and publish to Play Store via Dagger
|
||||
preconditions:
|
||||
- sh: test -n "$PLAY_STORE_CONFIG_JSON"
|
||||
msg: "PLAY_STORE_CONFIG_JSON is not set"
|
||||
- sh: test -n "$ANDROID_KEYSTORE_BASE64"
|
||||
msg: "ANDROID_KEYSTORE_BASE64 is not set"
|
||||
- sh: test -n "$ANDROID_KEYSTORE_PASSWORD"
|
||||
msg: "ANDROID_KEYSTORE_PASSWORD is not set"
|
||||
cmds:
|
||||
- dagger call --progress=plain -q -m ci --source=. publish-android --play-store-config env:PLAY_STORE_CONFIG_JSON --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD
|
||||
|
||||
deploy-apk:
|
||||
desc: Build and deploy Android APK via Dagger
|
||||
preconditions:
|
||||
- sh: test -n "$SSH_PRIVATE_KEY"
|
||||
msg: "SSH_PRIVATE_KEY is not set"
|
||||
- sh: test -n "$ANDROID_KEYSTORE_BASE64"
|
||||
msg: "ANDROID_KEYSTORE_BASE64 is not set"
|
||||
- sh: test -n "$ANDROID_KEYSTORE_PASSWORD"
|
||||
msg: "ANDROID_KEYSTORE_PASSWORD is not set"
|
||||
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)"
|
||||
|
||||
publish-website:
|
||||
desc: Build and publish website via Dagger
|
||||
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"
|
||||
|
||||
check-dagger:
|
||||
desc: Run full check suite via Dagger (with OTEL timing report if python3 is available)
|
||||
cmds:
|
||||
- |
|
||||
DAGGER_OUT=$(mktemp)
|
||||
RC_FILE=$(mktemp)
|
||||
_ts() { date -u '+[%H:%M:%S]'; }
|
||||
run_dagger() {
|
||||
: > "$DAGGER_OUT"; : > "$RC_FILE"
|
||||
{ timeout --kill-after=10 600 "$@"; echo $? > "$RC_FILE"; } 2>&1 | tee "$DAGGER_OUT"
|
||||
RC=$(cat "$RC_FILE" 2>/dev/null || echo 1)
|
||||
if [ "$RC" -eq 124 ] && grep -q "All tests passed" "$DAGGER_OUT"; then
|
||||
echo "$(_ts) dagger: hung in teardown after success; treating as exit 0." >&2
|
||||
RC=0
|
||||
fi
|
||||
return "$RC"
|
||||
}
|
||||
retry_dagger() {
|
||||
for attempt in 1 2 3; do
|
||||
run_dagger "$@" && return 0
|
||||
RC=$?
|
||||
if [ "$attempt" -lt 3 ] && grep -qE "connection reset|context canceled|connection refused" "$DAGGER_OUT"; then
|
||||
echo "$(_ts) dagger: network error on attempt $attempt/3, retrying..." >&2
|
||||
else
|
||||
return "$RC"
|
||||
fi
|
||||
done
|
||||
}
|
||||
if ! command -v python3 >/dev/null 2>&1; then
|
||||
retry_dagger dagger call --progress=plain -q -m ci --source=. check
|
||||
RC=$?
|
||||
rm -f "$DAGGER_OUT" "$RC_FILE"
|
||||
exit $RC
|
||||
fi
|
||||
PORTFILE=$(mktemp)
|
||||
python3 ci/otel-receiver.py --port-file="$PORTFILE" &
|
||||
RECV_PID=$!
|
||||
cleanup() {
|
||||
rm -f "$PORTFILE" "$DAGGER_OUT" "$RC_FILE"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
until [ -s "$PORTFILE" ]; do sleep 0.05; done
|
||||
PORT=$(cat "$PORTFILE")
|
||||
retry_dagger env \
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT="http://127.0.0.1:$PORT" \
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf" \
|
||||
dagger call --progress=plain -q -m ci --source=. check
|
||||
RC=$?
|
||||
curl -sf "http://127.0.0.1:$PORT/shutdown" >/dev/null 2>&1 || true
|
||||
wait "$RECV_PID" 2>/dev/null || true
|
||||
exit $RC
|
||||
|
||||
integration-android:
|
||||
desc: UI integration tests on a connected Android emulator (Stalwart on host, emulator reaches it via 10.0.2.2)
|
||||
@@ -351,16 +477,16 @@ tasks:
|
||||
- ANDROID_HOME=${ANDROID_HOME:-$HOME/Android/Sdk} fvm flutter build apk --release --no-pub --dart-define=GIT_HASH=$(git rev-parse --short HEAD) | grep -Ev "was tree-shaken|Tree-shaking can be disabled"
|
||||
|
||||
deploy-android-bundle:
|
||||
desc: Build release AAB and upload to Play Store internal track
|
||||
deps: [build-android-bundle]
|
||||
desc: Build release AAB and upload to Play Store internal track (local/fvm)
|
||||
deps: [build-android-bundle-local]
|
||||
preconditions:
|
||||
- sh: test -n "$PLAY_STORE_CONFIG_JSON"
|
||||
msg: "PLAY_STORE_CONFIG_JSON is not set"
|
||||
cmds:
|
||||
- python3 scripts/deploy_playstore.py
|
||||
|
||||
build-android-bundle:
|
||||
desc: Build a release App Bundle (AAB)
|
||||
build-android-bundle-local:
|
||||
desc: Build a release App Bundle (AAB) locally via fvm (not Dagger)
|
||||
deps: [_preflight, _android-sdk-check, _codegen, generate-changelog]
|
||||
method: timestamp
|
||||
sources:
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
org.gradle.welcome=never
|
||||
|
||||
+649
-128
@@ -2,49 +2,308 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"dagger/ci/internal/dagger"
|
||||
"fmt"
|
||||
"time"
|
||||
"dagger/ci/internal/dagger"
|
||||
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
type Ci struct{}
|
||||
// patchAabScript patches android:versionCode in an AAB's compiled manifest proto.
|
||||
// It strips META-INF/ (old signature) and repacks the ZIP. No external dependencies.
|
||||
const patchAabScript = `#!/usr/bin/env python3
|
||||
import sys, zipfile
|
||||
|
||||
// Base container with all dependencies for Flutter and Linux builds
|
||||
func (m *Ci) Base(source *dagger.Directory) *dagger.Container {
|
||||
// Surgical inclusion: only take what is strictly needed for the build/test.
|
||||
// This improves caching by ignoring transient or irrelevant files.
|
||||
source = source.Filter(dagger.DirectoryFilterOpts{
|
||||
Include: []string{
|
||||
"lib/",
|
||||
"test/",
|
||||
"assets/",
|
||||
"scripts/",
|
||||
"pubspec.yaml",
|
||||
"analysis_options.yaml",
|
||||
"linux/",
|
||||
"android/",
|
||||
"integration_test/",
|
||||
"drift_schemas/",
|
||||
"stalwart-dev/",
|
||||
},
|
||||
})
|
||||
MANIFEST = "base/manifest/AndroidManifest.xml"
|
||||
VERSION_CODE_RID = 0x0101021b
|
||||
|
||||
def _vr(b, p):
|
||||
n = s = 0
|
||||
while True:
|
||||
c = b[p]; p += 1; n |= (c & 127) << s
|
||||
if not (c & 128): return n, p
|
||||
s += 7
|
||||
|
||||
def _ve(n):
|
||||
r = []
|
||||
while n > 127: r.append((n & 127) | 128); n >>= 7
|
||||
return bytes(r + [n])
|
||||
|
||||
def _parse(d):
|
||||
p = 0
|
||||
while p < len(d):
|
||||
tag, p = _vr(d, p); fn, wt = tag >> 3, tag & 7
|
||||
if wt == 0: v, p = _vr(d, p); yield fn, 0, v
|
||||
elif wt == 2: ln, p = _vr(d, p); yield fn, 2, d[p:p+ln]; p += ln
|
||||
elif wt == 5: yield fn, 5, d[p:p+4]; p += 4 # fixed32
|
||||
elif wt == 1: yield fn, 1, d[p:p+8]; p += 8 # fixed64
|
||||
else: raise ValueError(f"wire type {wt}")
|
||||
|
||||
def _enc(fn, wt, v):
|
||||
t = _ve((fn << 3) | wt)
|
||||
if wt == 0: return t + _ve(v)
|
||||
if wt in (1, 5): return t + v # fixed-width, pass bytes as-is
|
||||
return t + _ve(len(v)) + v
|
||||
|
||||
def _patch_prim(d, vc):
|
||||
# Patch int_decimal_value (field 6) or int_hexadecimal_value (field 7),
|
||||
# whichever is present — AAPT2 may use either.
|
||||
out = bytearray()
|
||||
for fn, wt, v in _parse(d):
|
||||
out += _enc(fn, 0, vc) if (fn in (6, 7) and wt == 0) else _enc(fn, wt, v)
|
||||
return bytes(out)
|
||||
|
||||
def _patch_item(d, vc):
|
||||
out = bytearray()
|
||||
for fn, wt, v in _parse(d):
|
||||
out += _enc(7, 2, _patch_prim(v, vc)) if fn == 7 else _enc(fn, wt, v)
|
||||
return bytes(out)
|
||||
|
||||
def _has_rid(d):
|
||||
return any(fn == 5 and wt == 0 and v == VERSION_CODE_RID for fn, wt, v in _parse(d))
|
||||
|
||||
def _patch_attr(d, vc):
|
||||
out = bytearray()
|
||||
for fn, wt, v in _parse(d):
|
||||
if fn == 3 and wt == 2: out += _enc(3, 2, str(vc).encode())
|
||||
elif fn == 6 and wt == 2: out += _enc(6, 2, _patch_item(v, vc))
|
||||
else: out += _enc(fn, wt, v)
|
||||
return bytes(out)
|
||||
|
||||
def _patch_elem(d, vc):
|
||||
out = bytearray()
|
||||
for fn, wt, v in _parse(d):
|
||||
out += _enc(4, 2, _patch_attr(v, vc)) if (fn == 4 and _has_rid(v)) else _enc(fn, wt, v)
|
||||
return bytes(out)
|
||||
|
||||
def _patch_node(d, vc):
|
||||
out = bytearray()
|
||||
for fn, wt, v in _parse(d):
|
||||
out += _enc(1, 2, _patch_elem(v, vc)) if fn == 1 else _enc(fn, wt, v)
|
||||
return bytes(out)
|
||||
|
||||
def _dump_proto(d, depth=0, limit=3):
|
||||
"""Print proto field structure for debugging."""
|
||||
pad = " " * depth
|
||||
for fn, wt, v in _parse(d):
|
||||
if wt == 0:
|
||||
print(f"{pad}[{fn}] varint={v} (0x{v:x})")
|
||||
elif wt == 2:
|
||||
print(f"{pad}[{fn}] bytes len={len(v)}")
|
||||
if depth < limit:
|
||||
_dump_proto(v, depth + 1, limit)
|
||||
elif wt == 5:
|
||||
print(f"{pad}[{fn}] fixed32={v.hex()}")
|
||||
elif wt == 1:
|
||||
print(f"{pad}[{fn}] fixed64={v.hex()}")
|
||||
|
||||
def _read_vc_from_node(d):
|
||||
"""Read versionCode from XmlNode proto bytes. Returns int or None."""
|
||||
for fn, wt, v in _parse(d):
|
||||
if fn == 1 and wt == 2: # XmlElement
|
||||
for efn, ewt, attr in _parse(v):
|
||||
if efn == 4 and ewt == 2 and _has_rid(attr): # XmlAttribute with versionCode RID
|
||||
for afn, awt, item in _parse(attr):
|
||||
if afn == 6 and awt == 2: # compiled_value (Item)
|
||||
for ifn, iwt, prim in _parse(item):
|
||||
if ifn == 7 and iwt == 2: # prim (Primitive)
|
||||
for pfn, pwt, pv in _parse(prim):
|
||||
if pfn in (6, 7) and pwt == 0:
|
||||
return pv
|
||||
return None
|
||||
|
||||
def patch(src, dst, vc):
|
||||
with zipfile.ZipFile(src) as z:
|
||||
mf = z.read(MANIFEST)
|
||||
|
||||
orig_vc = _read_vc_from_node(mf)
|
||||
if orig_vc is None:
|
||||
print("DEBUG: could not find versionCode — dumping manifest proto structure:")
|
||||
_dump_proto(mf, limit=4)
|
||||
sys.exit(f"ERROR: versionCode not found in {MANIFEST}")
|
||||
print(f"Original versionCode in manifest: {orig_vc}")
|
||||
|
||||
patched = _patch_node(mf, vc)
|
||||
with zipfile.ZipFile(src) as zin, zipfile.ZipFile(dst, 'w') as zout:
|
||||
for info in zin.infolist():
|
||||
if info.filename.startswith('META-INF/'):
|
||||
continue # strip old signature; jarsigner re-signs after
|
||||
d = patched if info.filename == MANIFEST else zin.read(info.filename)
|
||||
zi = zipfile.ZipInfo(info.filename, info.date_time)
|
||||
zi.compress_type = info.compress_type
|
||||
zi.external_attr = info.external_attr
|
||||
zout.writestr(zi, d)
|
||||
|
||||
# Verify the patch actually took effect
|
||||
with zipfile.ZipFile(dst) as z:
|
||||
actual = _read_vc_from_node(z.read(MANIFEST))
|
||||
if actual != vc:
|
||||
sys.exit(f"ERROR: versionCode patch failed — wrote {vc} but read back {actual} (original was {orig_vc})")
|
||||
print(f"versionCode={actual} -> {dst}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) != 4:
|
||||
sys.exit(f"usage: {sys.argv[0]} in.aab out.aab versionCode")
|
||||
patch(sys.argv[1], sys.argv[2], int(sys.argv[3]))
|
||||
`
|
||||
|
||||
type Ci struct {
|
||||
Source *dagger.Directory
|
||||
}
|
||||
|
||||
func New(
|
||||
// +defaultPath=".."
|
||||
source *dagger.Directory,
|
||||
) *Ci {
|
||||
return &Ci{
|
||||
Source: source.Filter(dagger.DirectoryFilterOpts{
|
||||
Include: []string{
|
||||
"lib/",
|
||||
"test/",
|
||||
"assets/",
|
||||
"scripts/",
|
||||
"pubspec.yaml",
|
||||
"pubspec.lock",
|
||||
"analysis_options.yaml",
|
||||
"linux/",
|
||||
"android/",
|
||||
"integration_test/",
|
||||
"drift_schemas/",
|
||||
"stalwart-dev/",
|
||||
"website/",
|
||||
},
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// toolchain returns the Flutter+Android toolchain without any mutable cache mounts.
|
||||
// Its execution cache key is stable until the image, apt packages, or SDK versions change.
|
||||
// Used as the base for pubGetLayer so flutter pub get is execution-cached between runs.
|
||||
func (m *Ci) toolchain() *dagger.Container {
|
||||
return dag.Container().
|
||||
From("ghcr.io/cirruslabs/flutter:3.41.6").
|
||||
WithExec([]string{"apt-get", "update"}).
|
||||
WithExec([]string{"apt-get", "install", "-y",
|
||||
"clang", "cmake", "ninja-build", "pkg-config",
|
||||
"libgtk-3-dev", "liblzma-dev", "libsecret-1-dev",
|
||||
"libgcrypt20-dev", "libjsoncpp-dev", "sqlite3", "curl", "python3", "iproute2"}).
|
||||
WithExec([]string{"curl", "-sL", "https://github.com/stalwartlabs/mail-server/releases/download/v0.14.1/stalwart-x86_64-unknown-linux-gnu.tar.gz", "-o", "/tmp/stalwart.tar.gz"}).
|
||||
WithExec([]string{"tar", "-xzf", "/tmp/stalwart.tar.gz", "-C", "/usr/local/bin", "stalwart"}).
|
||||
WithExec([]string{"chmod", "+x", "/usr/local/bin/stalwart"}).
|
||||
WithExec([]string{"rm", "/tmp/stalwart.tar.gz"}).
|
||||
WithMountedCache("/root/.pub-cache", dag.CacheVolume("flutter-pub-cache")).
|
||||
WithMountedCache("/root/.gradle", dag.CacheVolume("gradle-cache")).
|
||||
WithEnvVariable("PUB_CACHE", "/root/.pub-cache").
|
||||
WithDirectory("/src", source).
|
||||
WithWorkdir("/src")
|
||||
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{"useradd", "-m", "-s", "/bin/bash", "ci"}).
|
||||
WithExec([]string{"/bin/sh", "-c",
|
||||
`flutter_dir=$(dirname $(dirname $(which flutter))); ` +
|
||||
`chown -R ci:ci "$flutter_dir"; ` +
|
||||
`[ -n "$ANDROID_HOME" ] && chown -R ci:ci "$ANDROID_HOME" || true; ` +
|
||||
`mkdir -p /src && chown ci:ci /src`}).
|
||||
WithEnvVariable("PUB_CACHE", "/home/ci/.pub-cache").
|
||||
WithEnvVariable("HOME", "/home/ci").
|
||||
WithUser("ci").
|
||||
WithExec([]string{"/bin/sh", "-c",
|
||||
`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; }`})
|
||||
}
|
||||
|
||||
// Base is the Flutter toolchain container with mutable cache mounts attached.
|
||||
// Use for Android/Gradle builds that need the Gradle cache.
|
||||
func (m *Ci) Base() *dagger.Container {
|
||||
return m.toolchain().
|
||||
WithMountedCache("/home/ci/.gradle", dag.CacheVolume("gradle-cache"), dagger.ContainerWithMountedCacheOpts{Owner: "ci"})
|
||||
}
|
||||
|
||||
// pubGetLayer runs flutter pub get with only pubspec.yaml + pubspec.lock as
|
||||
// inputs, then removes non-deterministic fields from both package_config.json
|
||||
// and .flutter-plugins-dependencies so the snapshot is byte-for-byte stable
|
||||
// across runs. Re-executes only when pubspec.yaml or pubspec.lock changes.
|
||||
// Packages land in the execution-cache snapshot (not a named volume) so that
|
||||
// dagger prune can reclaim space from stale pubspec.lock snapshots.
|
||||
func (m *Ci) pubGetLayer() *dagger.Container {
|
||||
pubspecOnly := m.Source.Filter(dagger.DirectoryFilterOpts{
|
||||
Include: []string{"pubspec.yaml", "pubspec.lock"},
|
||||
})
|
||||
return m.toolchain().
|
||||
WithDirectory("/src", pubspecOnly, dagger.ContainerWithDirectoryOpts{Owner: "ci"}).
|
||||
WithWorkdir("/src").
|
||||
WithExec([]string{"/bin/bash", "-c",
|
||||
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
|
||||
`flutter pub get >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
|
||||
`grep -vE '^[+~><] ' "$tmp" || true`}).
|
||||
WithExec([]string{"python3", "-c",
|
||||
"import json, os\n" +
|
||||
"f='.dart_tool/package_config.json'; d=json.load(open(f)); [d.pop(k,None) for k in ('generated','generatorVersion')]; json.dump(d,open(f,'w'))\n" +
|
||||
"g='.flutter-plugins-dependencies'\n" +
|
||||
"if os.path.exists(g):\n" +
|
||||
" d=json.load(open(g)); d.pop('date_created',None); json.dump(d,open(g,'w'))\n"})
|
||||
}
|
||||
|
||||
// codegenBase runs build_runner on the source subset common to all build
|
||||
// variants (lib/, test/, assets/, pubspec.*), excluding committed generated
|
||||
// files so the cache key is stable. All setup() calls share this single
|
||||
// Dagger cache entry, so build_runner compiles only once per pipeline run.
|
||||
func (m *Ci) codegenBase() *dagger.Container {
|
||||
codegenSrc := m.Source.Filter(dagger.DirectoryFilterOpts{
|
||||
Include: []string{"lib/", "test/", "assets/", "pubspec.yaml", "pubspec.lock"},
|
||||
Exclude: []string{"**/*.g.dart", "**/*.mocks.dart"},
|
||||
})
|
||||
return m.pubGetLayer().
|
||||
WithDirectory("/src", codegenSrc, dagger.ContainerWithDirectoryOpts{Owner: "ci"}).
|
||||
WithWorkdir("/src").
|
||||
WithExec([]string{"/bin/bash", "-c",
|
||||
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
|
||||
`flutter pub run build_runner build --delete-conflicting-outputs >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
|
||||
`grep -vE '^\[' "$tmp" || true`})
|
||||
}
|
||||
|
||||
// setup overlays platform-specific source files onto the shared codegen base.
|
||||
// Generated files (*.g.dart, *.mocks.dart) are excluded from the overlay so
|
||||
// the freshly built output from codegenBase() is not overwritten by stale
|
||||
// committed copies.
|
||||
func (m *Ci) setup(src *dagger.Directory) *dagger.Container {
|
||||
return m.codegenBase().
|
||||
WithDirectory("/src", src.Filter(dagger.DirectoryFilterOpts{
|
||||
Exclude: []string{"**/*.g.dart", "**/*.mocks.dart"},
|
||||
}), dagger.ContainerWithDirectoryOpts{Owner: "ci"})
|
||||
}
|
||||
|
||||
// Setup is the exported variant (CLI / Taskfile). Uses the full check source.
|
||||
func (m *Ci) Setup() *dagger.Container {
|
||||
return m.setup(m.checkSrc())
|
||||
}
|
||||
|
||||
// checkSrc is the source subset for static checks and unit tests.
|
||||
func (m *Ci) checkSrc() *dagger.Directory {
|
||||
return m.Source.Filter(dagger.DirectoryFilterOpts{
|
||||
Include: []string{"lib/", "test/", "assets/", "pubspec.yaml", "pubspec.lock", "analysis_options.yaml", "scripts/"},
|
||||
})
|
||||
}
|
||||
|
||||
// androidSrc is the source subset for Android builds.
|
||||
func (m *Ci) androidSrc() *dagger.Directory {
|
||||
return m.Source.Filter(dagger.DirectoryFilterOpts{
|
||||
Include: []string{"lib/", "android/", "assets/", "pubspec.yaml", "pubspec.lock", "drift_schemas/"},
|
||||
})
|
||||
}
|
||||
|
||||
// firebaseSrc is the source subset for Firebase Test Lab builds (app + instrumented tests).
|
||||
func (m *Ci) firebaseSrc() *dagger.Directory {
|
||||
return m.Source.Filter(dagger.DirectoryFilterOpts{
|
||||
Include: []string{"lib/", "android/", "integration_test/", "assets/", "pubspec.yaml", "pubspec.lock", "drift_schemas/"},
|
||||
})
|
||||
}
|
||||
|
||||
// linuxSrc is the source subset for Linux builds and integration tests.
|
||||
func (m *Ci) linuxSrc() *dagger.Directory {
|
||||
return m.Source.Filter(dagger.DirectoryFilterOpts{
|
||||
Include: []string{"lib/", "linux/", "assets/", "pubspec.yaml", "pubspec.lock", "drift_schemas/"},
|
||||
})
|
||||
}
|
||||
|
||||
// backendSrc is the source subset for IMAP/JMAP backend tests.
|
||||
func (m *Ci) backendSrc() *dagger.Directory {
|
||||
return m.Source.Filter(dagger.DirectoryFilterOpts{
|
||||
Include: []string{"lib/", "test/", "assets/", "scripts/", "stalwart-dev/", "pubspec.yaml", "pubspec.lock"},
|
||||
})
|
||||
}
|
||||
|
||||
// integrationSrc is the source subset for UI integration tests (runs on Linux desktop).
|
||||
func (m *Ci) integrationSrc() *dagger.Directory {
|
||||
return m.Source.Filter(dagger.DirectoryFilterOpts{
|
||||
Include: []string{"lib/", "linux/", "integration_test/", "assets/", "pubspec.yaml", "pubspec.lock", "drift_schemas/"},
|
||||
})
|
||||
}
|
||||
|
||||
// Hugo container for website builds
|
||||
@@ -66,103 +325,198 @@ func (m *Ci) Deployer(sshKey *dagger.Secret) *dagger.Container {
|
||||
WithEnvVariable("RSYNC_RSH", "ssh -o StrictHostKeyChecking=no -i /root/.ssh/id_ed25519")
|
||||
}
|
||||
|
||||
// Setup environment: pub get and build_runner
|
||||
func (m *Ci) Setup(source *dagger.Directory) *dagger.Container {
|
||||
return m.Base(source).
|
||||
WithExec([]string{"flutter", "pub", "get"}).
|
||||
// Use --delete-conflicting-outputs to ensure generated files match the current source
|
||||
WithExec([]string{"flutter", "pub", "run", "build_runner", "build", "--delete-conflicting-outputs"})
|
||||
// Stalwart mail server service for backend and integration tests.
|
||||
func (m *Ci) Stalwart() *dagger.Service {
|
||||
stalwartSrc := m.Source.Filter(dagger.DirectoryFilterOpts{
|
||||
Include: []string{"stalwart-dev/"},
|
||||
})
|
||||
config := stalwartSrc.Directory("stalwart-dev").File("config.toml")
|
||||
|
||||
dataDir := dag.Container().
|
||||
From("alpine:3.21").
|
||||
WithExec([]string{"apk", "add", "--no-cache", "sqlite"}).
|
||||
WithExec([]string{"/bin/sh", "-c", "mkdir -p /tmp/stalwart && chmod 777 /tmp/stalwart"}).
|
||||
WithExec([]string{"sqlite3", "/tmp/stalwart/data.sqlite", "CREATE TABLE IF NOT EXISTS s (k BLOB PRIMARY KEY, v BLOB NOT NULL); INSERT OR REPLACE INTO s VALUES ('version.spam-filter', 'dev');"}).
|
||||
Directory("/tmp/stalwart")
|
||||
|
||||
return dag.Container().
|
||||
From("stalwartlabs/stalwart:v0.14.1").
|
||||
WithFile("/etc/stalwart/config.toml.orig", config).
|
||||
WithExec([]string{"/bin/sh", "-c", "sed -e 's/hostname = \"localhost\"/hostname = \"stalwart\"/' -e 's/bind = \\[\"0.0.0.0:\\([0-9]*\\)\"\\]/bind = [\"0.0.0.0:\\1\", \"[::]:\\1\"]/g' /etc/stalwart/config.toml.orig > /etc/stalwart/config.toml"}).
|
||||
WithDirectory("/tmp/stalwart", dataDir).
|
||||
WithExposedPort(8080). // JMAP
|
||||
WithExposedPort(1430). // IMAP
|
||||
WithExposedPort(1025). // SMTP
|
||||
WithExposedPort(4190). // ManageSieve
|
||||
WithEntrypoint([]string{"stalwart", "--config", "/etc/stalwart/config.toml"}).
|
||||
AsService()
|
||||
}
|
||||
|
||||
// Run hygiene check
|
||||
func (m *Ci) CheckHygiene(ctx context.Context, source *dagger.Directory) (string, error) {
|
||||
return m.Base(source).
|
||||
// WithStalwart binds the Stalwart service and sets test environment variables.
|
||||
func (m *Ci) WithStalwart(container *dagger.Container) *dagger.Container {
|
||||
stalwart := m.Stalwart()
|
||||
return container.
|
||||
WithServiceBinding("stalwart", stalwart).
|
||||
WithEnvVariable("STALWART_IMAP_HOST", "stalwart").
|
||||
WithEnvVariable("STALWART_SMTP_HOST", "stalwart").
|
||||
WithEnvVariable("STALWART_URL", "http://stalwart:8080").
|
||||
WithEnvVariable("STALWART_IMAP_PORT", "1430").
|
||||
WithEnvVariable("STALWART_SMTP_PORT", "1025").
|
||||
WithEnvVariable("STALWART_SIEVE_PORT", "4190").
|
||||
WithEnvVariable("STALWART_USER_B", "alice@example.com").
|
||||
WithEnvVariable("STALWART_PASS_B", "secret").
|
||||
WithEnvVariable("STALWART_USER_C", "bob@example.com").
|
||||
WithEnvVariable("STALWART_PASS_C", "secret")
|
||||
}
|
||||
|
||||
// CheckHygiene checks that no forbidden home-directory files are in the source.
|
||||
func (m *Ci) CheckHygiene(ctx context.Context) (string, error) {
|
||||
return m.Base().
|
||||
WithDirectory("/src", m.Source, dagger.ContainerWithDirectoryOpts{Owner: "ci"}).
|
||||
WithWorkdir("/src").
|
||||
WithExec([]string{"/bin/bash", "-c", "FORBIDDEN=\".ssh .bashrc .config .local .cache .gitconfig .android Android .gradle .pub-cache .dartServer .flutter .dart-cli-completion .atuin .bash_logout .profile .zcompdump .zshrc snap .emulator_console_auth_token .lesshst .metadata .tmux.conf\"; for f in $FORBIDDEN; do if [ -e \"$f\" ]; then echo \"ERROR: Forbidden file/dir found in source: $f\"; exit 1; fi; done; echo \"Hygiene check passed.\""}).
|
||||
Stdout(ctx)
|
||||
}
|
||||
|
||||
// Enforce architecture — ui/ must not import data/
|
||||
func (m *Ci) CheckLayers(ctx context.Context, source *dagger.Directory) (string, error) {
|
||||
return m.Base(source).
|
||||
// CheckLayers enforces that ui/ does not import data/.
|
||||
func (m *Ci) CheckLayers(ctx context.Context) (string, error) {
|
||||
return m.Base().
|
||||
WithDirectory("/src", m.Source.Filter(dagger.DirectoryFilterOpts{Include: []string{"lib/"}}), dagger.ContainerWithDirectoryOpts{Owner: "ci"}).
|
||||
WithWorkdir("/src").
|
||||
WithExec([]string{"/bin/bash", "-c", "VIOLATIONS=$(grep -rn \"package:sharedinbox/data/\" lib/ui/ 2>/dev/null || true); if [ -n \"$VIOLATIONS\" ]; then echo \"ERROR: UI layer imports data layer (only core/ interfaces are allowed from ui/):\"; echo \"$VIOLATIONS\"; exit 1; fi; echo \"Layer check passed.\""}).
|
||||
Stdout(ctx)
|
||||
}
|
||||
|
||||
// Run dart format check
|
||||
func (m *Ci) Format(ctx context.Context, source *dagger.Directory) (string, error) {
|
||||
return m.Base(source).
|
||||
// Format runs dart format check.
|
||||
func (m *Ci) Format(ctx context.Context) (string, error) {
|
||||
return m.setup(m.checkSrc()).
|
||||
WithExec([]string{"dart", "format", "--output=none", "--set-exit-if-changed", "lib", "test"}).
|
||||
Stdout(ctx)
|
||||
}
|
||||
|
||||
// Verify that mocks are up to date
|
||||
func (m *Ci) CheckMocks(ctx context.Context, source *dagger.Directory) (string, error) {
|
||||
return m.Setup(source).
|
||||
// CheckMocks verifies that generated mocks are up to date.
|
||||
// It snapshots the committed source (including any stale *.mocks.dart) before
|
||||
// running build_runner, so git diff detects real staleness instead of always
|
||||
// comparing two freshly-generated outputs.
|
||||
func (m *Ci) CheckMocks(ctx context.Context) (string, error) {
|
||||
return m.pubGetLayer().
|
||||
WithDirectory("/src", m.checkSrc(), dagger.ContainerWithDirectoryOpts{Owner: "ci"}).
|
||||
WithWorkdir("/src").
|
||||
WithExec([]string{"git", "init"}).
|
||||
WithExec([]string{"git", "config", "user.email", "ci@sharedinbox.de"}).
|
||||
WithExec([]string{"git", "config", "user.name", "CI"}).
|
||||
WithExec([]string{"git", "add", "."}).
|
||||
WithExec([]string{"git", "commit", "-m", "baseline"}).
|
||||
WithExec([]string{"flutter", "pub", "run", "build_runner", "build", "--delete-conflicting-outputs"}).
|
||||
WithExec([]string{"git", "commit", "-q", "-m", "baseline"}).
|
||||
WithExec([]string{"/bin/bash", "-c",
|
||||
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
|
||||
`flutter pub run build_runner build --delete-conflicting-outputs >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
|
||||
`grep -vE '^\[' "$tmp" || true`}).
|
||||
WithExec([]string{"/bin/bash", "-c", "CHANGED=$(find . -name '*.mocks.dart' | xargs -r git diff --exit-code); if [ $? -ne 0 ]; then echo \"ERROR: Mocks are out of date\"; exit 1; fi; echo \"Mocks are up to date.\""}).
|
||||
Stdout(ctx)
|
||||
}
|
||||
|
||||
// Run coverage check
|
||||
func (m *Ci) Coverage(ctx context.Context, source *dagger.Directory) (string, error) {
|
||||
return m.Setup(source).
|
||||
WithExec([]string{"flutter", "test", "test/unit", "--coverage"}).
|
||||
// Coverage runs unit tests with coverage gate.
|
||||
func (m *Ci) Coverage(ctx context.Context) (string, error) {
|
||||
return m.setup(m.checkSrc()).
|
||||
WithExec([]string{"/bin/bash", "-c",
|
||||
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
|
||||
`flutter test test/unit --coverage --reporter expanded --no-pub >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
|
||||
`grep -E '^All [0-9]+ tests passed' "$tmp" || tail -1 "$tmp"`}).
|
||||
WithExec([]string{"dart", "scripts/check_coverage.dart"}).
|
||||
Stdout(ctx)
|
||||
}
|
||||
|
||||
// Full check suite (equivalent to task check)
|
||||
func (m *Ci) Check(ctx context.Context, source *dagger.Directory) (string, error) {
|
||||
setup := m.Setup(source)
|
||||
// TestBackend runs IMAP/JMAP sync tests against a live Stalwart instance.
|
||||
func (m *Ci) TestBackend(ctx context.Context) (string, error) {
|
||||
return m.WithStalwart(m.setup(m.backendSrc())).
|
||||
WithExec([]string{"/bin/bash", "-c",
|
||||
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
|
||||
`flutter test --concurrency=1 --reporter expanded --no-pub test/backend >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
|
||||
`grep -E '^All [0-9]+ tests passed' "$tmp" || tail -1 "$tmp"`}).
|
||||
Stdout(ctx)
|
||||
}
|
||||
|
||||
// Hygiene & Layers
|
||||
if _, err := m.CheckHygiene(ctx, source); err != nil {
|
||||
// TestIntegration runs UI integration tests via Xvfb.
|
||||
func (m *Ci) TestIntegration(ctx context.Context) (string, error) {
|
||||
return m.WithStalwart(m.setup(m.integrationSrc())).
|
||||
WithEnvVariable("LIBGL_ALWAYS_SOFTWARE", "1").
|
||||
WithExec([]string{"/bin/bash", "-c",
|
||||
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
|
||||
`xvfb-run -s '-screen 0 1280x720x24' flutter test integration_test/ -d linux >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
|
||||
`grep -E '^All [0-9]+ tests passed' "$tmp" || tail -1 "$tmp"`}).
|
||||
Stdout(ctx)
|
||||
}
|
||||
|
||||
// TestSyncReliability runs the sync reliability runner.
|
||||
func (m *Ci) TestSyncReliability(ctx context.Context) (string, error) {
|
||||
return m.WithStalwart(m.setup(m.backendSrc())).
|
||||
WithExec([]string{"/bin/bash", "-c",
|
||||
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
|
||||
`flutter test test/backend/sync_reliability_test.dart --reporter expanded --concurrency=1 --no-pub >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
|
||||
`grep -E '^All [0-9]+ tests passed' "$tmp" || tail -1 "$tmp"`}).
|
||||
Stdout(ctx)
|
||||
}
|
||||
|
||||
// Check runs the full check suite.
|
||||
func (m *Ci) Check(ctx context.Context) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
if _, err := m.CheckHygiene(ctx); err != nil {
|
||||
return "Hygiene check failed", err
|
||||
}
|
||||
if _, err := m.CheckLayers(ctx, source); err != nil {
|
||||
if _, err := m.CheckLayers(ctx); err != nil {
|
||||
return "Layer check failed", err
|
||||
}
|
||||
|
||||
// Format (Running after Setup/pub get ensures package resolution context)
|
||||
if _, err := setup.WithExec([]string{"dart", "format", "--output=none", "--set-exit-if-changed", "lib", "test"}).Stdout(ctx); err != nil {
|
||||
checkSetup := m.setup(m.checkSrc())
|
||||
|
||||
if _, err := checkSetup.WithExec([]string{"dart", "format", "--output=none", "--set-exit-if-changed", "lib", "test"}).Stdout(ctx); err != nil {
|
||||
return "Format check failed", err
|
||||
}
|
||||
|
||||
// Run analyze
|
||||
analyze, err := setup.WithExec([]string{"flutter", "analyze"}).Stdout(ctx)
|
||||
analyze, err := checkSetup.WithExec([]string{"dart", "analyze", "--fatal-infos"}).Stdout(ctx)
|
||||
if err != nil {
|
||||
return analyze, err
|
||||
}
|
||||
|
||||
// Run coverage gate (includes unit tests)
|
||||
coverage, err := m.Coverage(ctx, source)
|
||||
mocks, err := m.CheckMocks(ctx)
|
||||
if err != nil {
|
||||
return mocks, err
|
||||
}
|
||||
|
||||
coverage, err := m.Coverage(ctx)
|
||||
if err != nil {
|
||||
return coverage, err
|
||||
}
|
||||
|
||||
// Run backend tests (requires Stalwart)
|
||||
testBackend, err := setup.WithExec([]string{"stalwart-dev/test.sh"}).Stdout(ctx)
|
||||
if err != nil {
|
||||
return testBackend, err
|
||||
var testBackend, testIntegration string
|
||||
eg, egCtx := errgroup.WithContext(ctx)
|
||||
eg.Go(func() error {
|
||||
var e error
|
||||
testBackend, e = m.TestBackend(egCtx)
|
||||
return e
|
||||
})
|
||||
eg.Go(func() error {
|
||||
var e error
|
||||
testIntegration, e = m.TestIntegration(egCtx)
|
||||
return e
|
||||
})
|
||||
if err := eg.Wait(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return fmt.Sprintf("All checks passed!\n\nAnalysis:\n%s\n\n%s\n\nBackend Tests:\n%s\n", analyze, coverage, testBackend), nil
|
||||
return fmt.Sprintf("All checks passed!\n\nAnalysis:\n%s\n\n%s\n\n%s\n\nBackend Tests:\n%s\n\nIntegration Tests:\n%s\n", analyze, mocks, coverage, testBackend, testIntegration), nil
|
||||
}
|
||||
|
||||
// Generate build history Hugo content by scanning the remote server
|
||||
// GenerateBuildHistory scans the remote server and produces Hugo content.
|
||||
func (m *Ci) GenerateBuildHistory(
|
||||
ctx context.Context,
|
||||
source *dagger.Directory,
|
||||
sshKey *dagger.Secret,
|
||||
sshUser string,
|
||||
sshHost string,
|
||||
) *dagger.Directory {
|
||||
scriptSource := source.Filter(dagger.DirectoryFilterOpts{
|
||||
scriptSource := m.Source.Filter(dagger.DirectoryFilterOpts{
|
||||
Include: []string{"scripts/generate_build_history.py", "website/"},
|
||||
})
|
||||
|
||||
@@ -170,6 +524,7 @@ func (m *Ci) GenerateBuildHistory(
|
||||
From("python:3.12-alpine").
|
||||
WithExec([]string{"apk", "add", "--no-cache", "openssh-client"}).
|
||||
WithMountedSecret("/root/.ssh/id_ed25519", sshKey, dagger.ContainerWithMountedSecretOpts{Mode: 0600}).
|
||||
WithExec([]string{"chmod", "700", "/root/.ssh"}).
|
||||
WithEnvVariable("SSH_USER", sshUser).
|
||||
WithEnvVariable("SSH_HOST", sshHost).
|
||||
WithDirectory("/src", scriptSource).
|
||||
@@ -178,23 +533,19 @@ func (m *Ci) GenerateBuildHistory(
|
||||
Directory("website/content/builds")
|
||||
}
|
||||
|
||||
// Build and return the Hugo-based website bundle
|
||||
// BuildWebsite builds the Hugo-based website.
|
||||
func (m *Ci) BuildWebsite(
|
||||
ctx context.Context,
|
||||
source *dagger.Directory,
|
||||
sshKey *dagger.Secret,
|
||||
sshUser string,
|
||||
sshHost string,
|
||||
) *dagger.Directory {
|
||||
// 1. Generate build history content
|
||||
buildHistory := m.GenerateBuildHistory(ctx, source, sshKey, sshUser, sshHost)
|
||||
buildHistory := m.GenerateBuildHistory(ctx, sshKey, sshUser, sshHost)
|
||||
|
||||
// 2. Prepare website source (base files + generated history)
|
||||
websiteSource := source.Filter(dagger.DirectoryFilterOpts{
|
||||
websiteSource := m.Source.Filter(dagger.DirectoryFilterOpts{
|
||||
Include: []string{"website/"},
|
||||
}).WithDirectory("website/content/builds", buildHistory)
|
||||
|
||||
// 3. Build with Hugo
|
||||
return m.Hugo().
|
||||
WithDirectory("/src", websiteSource).
|
||||
WithWorkdir("/src/website").
|
||||
@@ -202,18 +553,15 @@ func (m *Ci) BuildWebsite(
|
||||
Directory("public")
|
||||
}
|
||||
|
||||
// Build and deploy the website to the remote server
|
||||
// PublishWebsite builds and deploys the website to the remote server.
|
||||
func (m *Ci) PublishWebsite(
|
||||
ctx context.Context,
|
||||
source *dagger.Directory,
|
||||
sshKey *dagger.Secret,
|
||||
sshUser string,
|
||||
sshHost string,
|
||||
) (string, error) {
|
||||
// 1. Build the website
|
||||
public := m.BuildWebsite(ctx, source, sshKey, sshUser, sshHost)
|
||||
public := m.BuildWebsite(ctx, sshKey, sshUser, sshHost)
|
||||
|
||||
// 2. Deploy using rsync
|
||||
return m.Deployer(sshKey).
|
||||
WithDirectory("/public", public).
|
||||
WithExec([]string{"rsync", "-avz", "--delete",
|
||||
@@ -222,33 +570,30 @@ func (m *Ci) PublishWebsite(
|
||||
Stdout(ctx)
|
||||
}
|
||||
|
||||
// Build and return the Linux bundle
|
||||
func (m *Ci) BuildLinux(source *dagger.Directory) *dagger.Directory {
|
||||
return m.Setup(source).
|
||||
WithExec([]string{"flutter", "build", "linux", "--debug"}).
|
||||
Directory("build/linux/x64/debug/bundle")
|
||||
}
|
||||
|
||||
// Build and return the Linux bundle (release)
|
||||
func (m *Ci) BuildLinuxRelease(source *dagger.Directory) *dagger.Directory {
|
||||
return m.Setup(source).
|
||||
// BuildLinux builds the Linux release bundle.
|
||||
func (m *Ci) BuildLinux() *dagger.Directory {
|
||||
return m.setup(m.linuxSrc()).
|
||||
WithExec([]string{"flutter", "build", "linux", "--release"}).
|
||||
Directory("build/linux/x64/release/bundle")
|
||||
}
|
||||
|
||||
// Package and deploy the Linux release to the server
|
||||
// BuildLinuxRelease builds the Linux release bundle.
|
||||
func (m *Ci) BuildLinuxRelease() *dagger.Directory {
|
||||
return m.setup(m.linuxSrc()).
|
||||
WithExec([]string{"flutter", "build", "linux", "--release"}).
|
||||
Directory("build/linux/x64/release/bundle")
|
||||
}
|
||||
|
||||
// DeployLinux packages and deploys the Linux release to the server.
|
||||
func (m *Ci) DeployLinux(
|
||||
ctx context.Context,
|
||||
source *dagger.Directory,
|
||||
sshKey *dagger.Secret,
|
||||
sshUser string,
|
||||
sshHost string,
|
||||
commitHash string,
|
||||
) (string, error) {
|
||||
// 1. Build the release bundle
|
||||
bundle := m.BuildLinuxRelease(source)
|
||||
bundle := m.BuildLinuxRelease()
|
||||
|
||||
// 2. Package and deploy
|
||||
datePath := time.Now().Format("2006/01/02")
|
||||
remoteDir := fmt.Sprintf("public_html/builds/%s", datePath)
|
||||
tarball := fmt.Sprintf("sharedinbox-linux-amd64-%s.tar.gz", commitHash)
|
||||
@@ -261,26 +606,34 @@ func (m *Ci) DeployLinux(
|
||||
Stdout(ctx)
|
||||
}
|
||||
|
||||
// Build and return the Android APK
|
||||
func (m *Ci) BuildAndroidApk(source *dagger.Directory) *dagger.File {
|
||||
return m.Setup(source).
|
||||
WithExec([]string{"flutter", "build", "apk", "--release"}).
|
||||
// setupKeystore decodes the base64 keystore into the android build container.
|
||||
func (m *Ci) setupKeystore(keystoreBase64 *dagger.Secret, keystorePassword *dagger.Secret) *dagger.Container {
|
||||
return m.setup(m.androidSrc()).
|
||||
WithSecretVariable("ANDROID_KEYSTORE_BASE64", keystoreBase64).
|
||||
WithSecretVariable("ANDROID_KEYSTORE_PASSWORD", keystorePassword).
|
||||
WithExec([]string{"/bin/sh", "-c", `echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > android/app/upload-keystore.jks`})
|
||||
}
|
||||
|
||||
// BuildAndroidApk builds a release APK signed with the upload key.
|
||||
func (m *Ci) BuildAndroidApk(keystoreBase64 *dagger.Secret, keystorePassword *dagger.Secret, buildNumber string) *dagger.File {
|
||||
return m.setupKeystore(keystoreBase64, keystorePassword).
|
||||
WithExec([]string{"flutter", "build", "apk", "--release", "--no-pub", "--build-number", buildNumber}).
|
||||
File("build/app/outputs/flutter-apk/app-release.apk")
|
||||
}
|
||||
|
||||
// Deploy the Android APK to the server
|
||||
// DeployApk builds and deploys the APK to the server.
|
||||
func (m *Ci) DeployApk(
|
||||
ctx context.Context,
|
||||
source *dagger.Directory,
|
||||
sshKey *dagger.Secret,
|
||||
sshUser string,
|
||||
sshHost string,
|
||||
commitHash string,
|
||||
keystoreBase64 *dagger.Secret,
|
||||
keystorePassword *dagger.Secret,
|
||||
buildNumber string,
|
||||
) (string, error) {
|
||||
// 1. Build the APK
|
||||
apk := m.BuildAndroidApk(source)
|
||||
apk := m.BuildAndroidApk(keystoreBase64, keystorePassword, buildNumber)
|
||||
|
||||
// 2. Deploy
|
||||
datePath := time.Now().Format("2006/01/02")
|
||||
remoteDir := fmt.Sprintf("public_html/builds/%s", datePath)
|
||||
apkName := fmt.Sprintf("sharedinbox-mua-%s.apk", commitHash)
|
||||
@@ -292,31 +645,96 @@ func (m *Ci) DeployApk(
|
||||
Stdout(ctx)
|
||||
}
|
||||
|
||||
// Build and return the Android App Bundle (AAB)
|
||||
func (m *Ci) BuildAndroidRelease(source *dagger.Directory) *dagger.File {
|
||||
return m.Setup(source).
|
||||
WithExec([]string{"flutter", "build", "appbundle", "--release"}).
|
||||
// BuildAndroidDebugApks builds the debug app APK and the androidTest APK needed for Firebase Test Lab.
|
||||
// Returns a flat directory with app-debug.apk and app-debug-androidTest.apk.
|
||||
func (m *Ci) BuildAndroidDebugApks() *dagger.Directory {
|
||||
built := m.setup(m.firebaseSrc()).
|
||||
WithExec([]string{"flutter", "build", "apk", "--debug", "--no-pub"}).
|
||||
WithWorkdir("/src/android").
|
||||
WithExec([]string{"./gradlew", "app:assembleAndroidTest"}).
|
||||
WithWorkdir("/src").
|
||||
WithExec([]string{"/bin/bash", "-c",
|
||||
`apk=$(find /src -path "*androidTest*" -name "*.apk" -type f 2>/dev/null | head -1) && \
|
||||
[ -n "$apk" ] || { echo "ERROR: no androidTest APK found; APKs present:"; find /src -name "*.apk" -type f 2>/dev/null; exit 1; } && \
|
||||
echo "Found test APK: $apk" && \
|
||||
cp "$apk" /src/app-debug-androidTest.apk`})
|
||||
|
||||
return dag.Directory().
|
||||
WithFile("app-debug.apk",
|
||||
built.File("build/app/outputs/flutter-apk/app-debug.apk")).
|
||||
WithFile("app-debug-androidTest.apk",
|
||||
built.File("app-debug-androidTest.apk"))
|
||||
}
|
||||
|
||||
// TestAndroidFirebase builds Android APKs and runs instrumented tests on Firebase Test Lab.
|
||||
func (m *Ci) TestAndroidFirebase(
|
||||
ctx context.Context,
|
||||
serviceAccountKey *dagger.Secret,
|
||||
projectID string,
|
||||
) (string, error) {
|
||||
apks := m.BuildAndroidDebugApks()
|
||||
|
||||
return dag.Container().
|
||||
From("google/cloud-sdk:slim").
|
||||
WithDirectory("/apks", apks).
|
||||
WithSecretVariable("FIREBASE_SA_KEY", serviceAccountKey).
|
||||
WithEnvVariable("FIREBASE_PROJECT_ID", projectID).
|
||||
WithExec([]string{"/bin/bash", "-c",
|
||||
`auth_err=$(mktemp); trap 'rm -f "$auth_err"' EXIT; \
|
||||
gcloud auth activate-service-account --key-file=<(echo "$FIREBASE_SA_KEY") 2>"$auth_err" \
|
||||
|| { cat "$auth_err"; exit 1; }; \
|
||||
gcloud config set project "$FIREBASE_PROJECT_ID" 2>>"$auth_err" \
|
||||
|| { cat "$auth_err"; exit 1; }; \
|
||||
unknown=$(grep -vF "Activated service account credentials for:" "$auth_err" \
|
||||
| grep -vF "Updated property [core/project]." | grep -v "^$" || true); \
|
||||
[ -z "$unknown" ] || { echo "ERROR: unexpected gcloud auth output: $unknown"; exit 1; }; \
|
||||
out=$(gcloud firebase test android run \
|
||||
--type instrumentation \
|
||||
--app /apks/app-debug.apk \
|
||||
--test /apks/app-debug-androidTest.apk \
|
||||
--device model=oriole,version=33,locale=en,orientation=portrait \
|
||||
--results-bucket=gs://sharedinbox-ftl-results 2>&1); rc=$?; echo "$out"; \
|
||||
[ "$rc" -eq 0 ] || { echo "ERROR: gcloud firebase test exited with code $rc"; exit "$rc"; }; \
|
||||
expected_devices=1; \
|
||||
actual_devices=$(echo "$out" | grep "│" | grep -cE "(Passed|Failed|Inconclusive|Skipped)") || actual_devices=0; \
|
||||
[ "$actual_devices" -eq "$expected_devices" ] || \
|
||||
{ echo "ERROR: expected $expected_devices test result(s) but found $actual_devices"; exit 1; }; \
|
||||
echo "$out" | grep -q "Passed" || { echo "ERROR: no passing test results — tests failed or did not run"; exit 1; }`}).
|
||||
Stdout(ctx)
|
||||
}
|
||||
|
||||
// BuildAndroidRelease builds the AAB with a fixed build-number so Dagger can cache it.
|
||||
// versionCode and signing are applied separately via StampAndroidVersionCode + SignAndroidBundle.
|
||||
func (m *Ci) BuildAndroidRelease() *dagger.File {
|
||||
return m.setup(m.androidSrc()).
|
||||
WithExec([]string{"flutter", "build", "appbundle", "--release", "--no-pub", "--build-number", "1"}).
|
||||
File("build/app/outputs/bundle/release/app-release.aab")
|
||||
}
|
||||
|
||||
// Publish the Android App Bundle to Google Play Store
|
||||
func (m *Ci) PublishAndroid(
|
||||
// withGoCache mounts Dagger cache volumes for GOCACHE and GOMODCACHE so Go
|
||||
// builds inside the container reuse cached packages between pipeline runs.
|
||||
func withGoCache(c *dagger.Container) *dagger.Container {
|
||||
return c.
|
||||
WithMountedCache("/home/ci/.cache/go-build", dag.CacheVolume("go-build-cache")).
|
||||
WithMountedCache("/home/ci/go/pkg/mod", dag.CacheVolume("go-mod-cache")).
|
||||
WithEnvVariable("GOCACHE", "/home/ci/.cache/go-build").
|
||||
WithEnvVariable("GOMODCACHE", "/home/ci/go/pkg/mod")
|
||||
}
|
||||
|
||||
// UploadToPlayStore uploads a pre-built AAB to the Play Store internal track.
|
||||
func (m *Ci) UploadToPlayStore(
|
||||
ctx context.Context,
|
||||
source *dagger.Directory,
|
||||
aab *dagger.File,
|
||||
playStoreConfig *dagger.Secret,
|
||||
) (string, error) {
|
||||
// 1. Build the AAB
|
||||
aab := m.BuildAndroidRelease(source)
|
||||
|
||||
// 2. Prepare script source
|
||||
scriptSource := source.Filter(dagger.DirectoryFilterOpts{
|
||||
scriptSource := m.Source.Filter(dagger.DirectoryFilterOpts{
|
||||
Include: []string{"scripts/deploy_playstore.py"},
|
||||
})
|
||||
|
||||
// 3. Deploy
|
||||
return dag.Container().
|
||||
From("python:3.12-alpine").
|
||||
WithExec([]string{"apk", "add", "--no-cache", "curl"}).
|
||||
WithMountedCache("/root/.cache/pip", dag.CacheVolume("pip-cache")).
|
||||
WithExec([]string{"pip", "install", "requests", "google-auth"}).
|
||||
WithFile("/src/build/app/outputs/bundle/release/app-release.aab", aab).
|
||||
WithFile("/src/scripts/deploy_playstore.py", scriptSource.File("scripts/deploy_playstore.py")).
|
||||
@@ -325,3 +743,106 @@ func (m *Ci) PublishAndroid(
|
||||
WithExec([]string{"python3", "scripts/deploy_playstore.py"}).
|
||||
Stdout(ctx)
|
||||
}
|
||||
|
||||
// StampAndroidVersionCode patches the versionCode in a built AAB without rebuilding.
|
||||
func (m *Ci) StampAndroidVersionCode(aab *dagger.File, versionCode int) *dagger.File {
|
||||
return dag.Container().
|
||||
From("python:3.12-alpine").
|
||||
WithNewFile("/patch.py", patchAabScript).
|
||||
WithFile("/in.aab", aab).
|
||||
WithExec([]string{"python3", "/patch.py", "/in.aab", "/out.aab", fmt.Sprintf("%d", versionCode)}).
|
||||
File("/out.aab")
|
||||
}
|
||||
|
||||
// SignAndroidBundle signs an AAB with the release upload key via jarsigner.
|
||||
func (m *Ci) SignAndroidBundle(aab *dagger.File, keystoreBase64 *dagger.Secret, keystorePassword *dagger.Secret) *dagger.File {
|
||||
return dag.Container().
|
||||
From("eclipse-temurin:17-jdk-alpine").
|
||||
WithFile("/app.aab", aab).
|
||||
WithSecretVariable("KS_BASE64", keystoreBase64).
|
||||
WithSecretVariable("KS_PASS", keystorePassword).
|
||||
WithExec([]string{"sh", "-c",
|
||||
`[ -n "$KS_BASE64" ] || { echo "ERROR: KS_BASE64 secret is empty — ANDROID_KEYSTORE_BASE64 not set"; exit 1; }
|
||||
[ -n "$KS_PASS" ] || { echo "ERROR: KS_PASS secret is empty — ANDROID_KEYSTORE_PASSWORD not set"; exit 1; }
|
||||
echo "$KS_BASE64" | base64 -d > /keystore.jks && \
|
||||
jarsigner -sigalg SHA256withRSA -digestalg SHA-256 \
|
||||
-signedjar /signed.aab \
|
||||
-keystore /keystore.jks \
|
||||
-storepass "$KS_PASS" -keypass "$KS_PASS" \
|
||||
/app.aab upload`}).
|
||||
File("/signed.aab")
|
||||
}
|
||||
|
||||
// PublishAndroid builds a cached AAB, stamps the versionCode, re-signs, and uploads to Play Store.
|
||||
func (m *Ci) PublishAndroid(
|
||||
ctx context.Context,
|
||||
playStoreConfig *dagger.Secret,
|
||||
keystoreBase64 *dagger.Secret,
|
||||
keystorePassword *dagger.Secret,
|
||||
) (string, error) {
|
||||
versionCode := int(time.Now().Unix())
|
||||
aab := m.BuildAndroidRelease()
|
||||
stamped := m.StampAndroidVersionCode(aab, versionCode)
|
||||
signed := m.SignAndroidBundle(stamped, keystoreBase64, keystorePassword)
|
||||
return m.UploadToPlayStore(ctx, signed, playStoreConfig)
|
||||
}
|
||||
|
||||
// Graph returns a Mermaid diagram of the CI pipeline structure.
|
||||
// Paste the output into any Mermaid renderer (codeberg, github, mermaid.live)
|
||||
// or save it as a .md file to get a rendered diagram.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// dagger call --progress=plain -q -m ci --source=. graph
|
||||
func (m *Ci) Graph() string {
|
||||
return `# CI Pipeline Graph
|
||||
|
||||
` + "```" + `mermaid
|
||||
flowchart TD
|
||||
subgraph dagger ["Dagger · Check pipeline"]
|
||||
toolchain["toolchain\nflutter:3.41.6 + NDK + apt"]
|
||||
pubGet["pubGetLayer\nflutter pub get"]
|
||||
codegen["codegenBase\nbuild_runner build\n(shared cache)"]
|
||||
stalwart(["Stalwart service\nIMAP · JMAP · SMTP · Sieve"])
|
||||
|
||||
toolchain --> pubGet
|
||||
pubGet --> codegen
|
||||
|
||||
pubGet --> hygiene["CheckHygiene"]
|
||||
pubGet --> layers["CheckLayers"]
|
||||
pubGet --> mocks["CheckMocks\n(own build_runner run)"]
|
||||
|
||||
codegen --> fmt["Format"]
|
||||
codegen --> analyze["Analyze"]
|
||||
codegen --> coverage["Coverage\nunit tests + gate"]
|
||||
codegen --> backend["TestBackend\nIMAP / JMAP"]
|
||||
codegen --> integration["TestIntegration\nXvfb · Linux desktop"]
|
||||
|
||||
stalwart --> backend
|
||||
stalwart --> integration
|
||||
|
||||
hygiene --> check{{"✓ Check"}}
|
||||
layers --> check
|
||||
fmt --> check
|
||||
analyze --> check
|
||||
mocks --> check
|
||||
coverage --> check
|
||||
backend --> check
|
||||
integration --> check
|
||||
end
|
||||
|
||||
subgraph forgejo ["Codeberg CI · .forgejo/workflows/ci.yml"]
|
||||
ciCheck["check"]
|
||||
buildLinux["build-linux\n(main only)"]
|
||||
deployPS["deploy-playstore\n(main only)"]
|
||||
pubWeb["publish-website\n(main only)"]
|
||||
|
||||
ciCheck --> buildLinux
|
||||
ciCheck --> deployPS
|
||||
buildLinux --> pubWeb
|
||||
deployPS --> pubWeb
|
||||
end
|
||||
|
||||
check -- "task check-dagger" --> ciCheck
|
||||
` + "```"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Minimal OTLP HTTP/protobuf trace receiver for Dagger CI timing.
|
||||
|
||||
Usage:
|
||||
python3 ci/otel-receiver.py --port-file=/tmp/otel.port
|
||||
|
||||
Caller sets:
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:<port>
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import signal
|
||||
import struct
|
||||
import sys
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
|
||||
|
||||
# ── Minimal protobuf binary decoder ─────────────────────────────────────────
|
||||
# Only decodes the fields we need; skips everything else safely.
|
||||
|
||||
def _varint(buf, pos):
|
||||
n, shift = 0, 0
|
||||
while pos < len(buf):
|
||||
b = buf[pos]; pos += 1
|
||||
n |= (b & 0x7F) << shift
|
||||
shift += 7
|
||||
if not (b & 0x80):
|
||||
return n, pos
|
||||
raise ValueError("truncated varint")
|
||||
|
||||
|
||||
def _fields(buf):
|
||||
"""Yield (field_num, wire_type, raw_value) for each field in a message."""
|
||||
pos = 0
|
||||
while pos < len(buf):
|
||||
tag, pos = _varint(buf, pos)
|
||||
wt, fn = tag & 7, tag >> 3
|
||||
if wt == 0: # varint
|
||||
v, pos = _varint(buf, pos)
|
||||
elif wt == 1: # fixed64
|
||||
v = struct.unpack_from("<Q", buf, pos)[0]; pos += 8
|
||||
elif wt == 2: # length-delimited
|
||||
n, pos = _varint(buf, pos)
|
||||
v = buf[pos:pos + n]; pos += n
|
||||
elif wt == 5: # fixed32
|
||||
v = struct.unpack_from("<I", buf, pos)[0]; pos += 4
|
||||
else:
|
||||
break # unknown: stop
|
||||
yield fn, wt, v
|
||||
|
||||
|
||||
def _any_value(buf):
|
||||
"""Parse AnyValue, return (type_tag, python_value)."""
|
||||
for fn, wt, v in _fields(buf):
|
||||
if fn == 1 and wt == 2: # string_value
|
||||
return "str", v.decode("utf-8", errors="replace")
|
||||
if fn == 2 and wt == 0: # bool_value
|
||||
return "bool", bool(v)
|
||||
if fn == 3 and wt == 0: # int_value (sint64)
|
||||
return "int", v
|
||||
if fn == 4 and wt == 1: # double_value
|
||||
return "float", struct.unpack("<d", struct.pack("<Q", v))[0]
|
||||
return None, None
|
||||
|
||||
|
||||
def _keyvalue(buf):
|
||||
key, tag, val = None, None, None
|
||||
for fn, wt, v in _fields(buf):
|
||||
if fn == 1 and wt == 2:
|
||||
key = v.decode("utf-8", errors="replace")
|
||||
elif fn == 2 and wt == 2:
|
||||
tag, val = _any_value(v)
|
||||
return key, tag, val
|
||||
|
||||
|
||||
def _span(buf):
|
||||
name = ""
|
||||
start_ns = end_ns = 0
|
||||
cached = False
|
||||
for fn, wt, v in _fields(buf):
|
||||
if fn == 5 and wt == 2: # name
|
||||
name = v.decode("utf-8", errors="replace")
|
||||
elif fn == 7 and wt == 1: # start_time_unix_nano
|
||||
start_ns = v
|
||||
elif fn == 8 and wt == 1: # end_time_unix_nano
|
||||
end_ns = v
|
||||
elif fn == 9 and wt == 2: # attributes (repeated)
|
||||
k, tag, val = _keyvalue(v)
|
||||
if tag == "bool" and k and "cached" in k.lower():
|
||||
cached = val
|
||||
return {"name": name, "dur": max(0.0, (end_ns - start_ns) / 1e9), "cached": cached}
|
||||
|
||||
|
||||
def _decode(body):
|
||||
spans = []
|
||||
for fn1, wt1, rs in _fields(body): # resource_spans = 1
|
||||
if fn1 != 1 or wt1 != 2:
|
||||
continue
|
||||
for fn2, wt2, ss in _fields(rs): # scope_spans = 2
|
||||
if fn2 != 2 or wt2 != 2:
|
||||
continue
|
||||
for fn3, wt3, sp in _fields(ss): # spans = 2
|
||||
if fn3 == 2 and wt3 == 2:
|
||||
spans.append(_span(sp))
|
||||
return spans
|
||||
|
||||
|
||||
# ── HTTP receiver ────────────────────────────────────────────────────────────
|
||||
|
||||
_spans = []
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
class _Handler(BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
def _respond(self, code, body=b""):
|
||||
self.close_connection = True # actually close after response, matching the header
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", "application/x-protobuf")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Connection", "close")
|
||||
self.end_headers()
|
||||
if body:
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self):
|
||||
if self.path != "/shutdown":
|
||||
self._respond(404); return
|
||||
self._respond(200, b"shutting down")
|
||||
threading.Thread(target=self.server.shutdown, daemon=True).start()
|
||||
|
||||
def do_POST(self):
|
||||
if self.path != "/v1/traces":
|
||||
self._respond(404); return
|
||||
n = int(self.headers.get("Content-Length", 0))
|
||||
body = self.rfile.read(n)
|
||||
try:
|
||||
decoded = _decode(body)
|
||||
except Exception as exc:
|
||||
print(f"[otel-receiver] decode error: {exc}", file=sys.stderr, flush=True)
|
||||
self._respond(400, str(exc).encode()); return
|
||||
with _lock:
|
||||
_spans.extend(decoded)
|
||||
self._respond(200)
|
||||
|
||||
def log_message(self, *_):
|
||||
pass
|
||||
|
||||
|
||||
# ── Timing report ────────────────────────────────────────────────────────────
|
||||
|
||||
def _report():
|
||||
with _lock:
|
||||
if not _spans:
|
||||
print("otel-receiver: no spans received", file=sys.stderr)
|
||||
return
|
||||
rows = sorted(_spans, key=lambda r: r["dur"], reverse=True)
|
||||
NAME_W = 38
|
||||
print(f'\n{"STATUS":<6} {"DURATION":>8} SPAN')
|
||||
print("─" * (6 + 2 + 8 + 2 + NAME_W + 20))
|
||||
for r in rows:
|
||||
status = "CACHED" if r["cached"] else "LIVE"
|
||||
name = r["name"]
|
||||
if len(name) > NAME_W:
|
||||
name = name[: NAME_W - 1] + "…"
|
||||
print(f'{status:<6} {r["dur"]:7.2f}s {name}')
|
||||
print(f"\n{len(rows)} spans total")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--port-file", default="")
|
||||
args = ap.parse_args()
|
||||
|
||||
server = HTTPServer(("127.0.0.1", 0), _Handler)
|
||||
if args.port_file:
|
||||
with open(args.port_file, "w") as f:
|
||||
f.write(str(server.server_address[1]))
|
||||
|
||||
def _shutdown(sig, frame):
|
||||
threading.Thread(target=server.shutdown, daemon=True).start()
|
||||
|
||||
signal.signal(signal.SIGTERM, _shutdown)
|
||||
signal.signal(signal.SIGINT, _shutdown)
|
||||
|
||||
server.serve_forever()
|
||||
_report()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
# Load .env into environment
|
||||
set -a
|
||||
# shellcheck source=.env
|
||||
source "$REPO_DIR/.env"
|
||||
set +a
|
||||
|
||||
# SSH_PRIVATE_KEY must not live in .env (dagger parses .env and chokes on multiline values)
|
||||
export SSH_PRIVATE_KEY=$(cat "$HOME/.ssh/id_ed25519")
|
||||
|
||||
# Add nix profile and nix store tools (task, dagger) to PATH
|
||||
export PATH="$HOME/.nix-profile/bin:$PATH"
|
||||
for pkg in "*go-task-*/bin/task" "*dagger-*/bin/dagger"; do
|
||||
bin=$(ls -d /nix/store/$pkg 2>/dev/null | sort -V | tail -1)
|
||||
[ -n "$bin" ] && export PATH="$(dirname "$bin"):$PATH"
|
||||
done
|
||||
|
||||
exec python3 "$REPO_DIR/deploy_cron.py"
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Cron deploy script for sharedinbox website.
|
||||
Runs every 5 minutes; skips if origin/main has not changed since last successful deploy.
|
||||
Gives up and creates a Codeberg issue after 5 consecutive failures on the same commit.
|
||||
"""
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
REPO_DIR = Path(__file__).parent.resolve()
|
||||
SHA_FILE = REPO_DIR / '.last_deployed_sha'
|
||||
FAILED_SHA_FILE = REPO_DIR / '.last_failed_sha'
|
||||
FAIL_COUNT_FILE = REPO_DIR / '.fail_count'
|
||||
ERROR_FILE = REPO_DIR / '.last_deploy_error'
|
||||
ISSUE_SHA_FILE = REPO_DIR / '.last_issue_sha'
|
||||
|
||||
MAX_FAILURES = 5
|
||||
REPO = 'guettli/sharedinbox'
|
||||
CODEBERG = 'https://codeberg.org'
|
||||
|
||||
|
||||
def git(*args):
|
||||
return subprocess.run(
|
||||
['git', *args], cwd=REPO_DIR, check=True,
|
||||
capture_output=True, text=True,
|
||||
).stdout.strip()
|
||||
|
||||
|
||||
def read(path: Path) -> str:
|
||||
return path.read_text().strip() if path.exists() else ''
|
||||
|
||||
|
||||
def read_int(path: Path) -> int:
|
||||
try:
|
||||
return int(read(path))
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
|
||||
def issue_exists_for(sha: str) -> bool:
|
||||
"""Check Codeberg for an open issue referencing this commit SHA."""
|
||||
result = subprocess.run(
|
||||
['tea', 'issue', 'list', '--repo', REPO, '--state', 'open',
|
||||
'--limit', '50', '--output', 'simple'],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
return sha[:8] in result.stdout
|
||||
|
||||
|
||||
def create_issue(failed_sha: str, fail_count: int) -> None:
|
||||
error_output = read(ERROR_FILE)
|
||||
tail = '\n'.join(error_output.splitlines()[-40:]) if error_output else '(no output captured)'
|
||||
commit_url = f'{CODEBERG}/{REPO}/commit/{failed_sha}'
|
||||
script_url = f'{CODEBERG}/{REPO}/src/branch/main/deploy_cron.py'
|
||||
timestamp = datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')
|
||||
|
||||
title = f'Deploy failed {fail_count}x on {failed_sha[:8]} — needs fix'
|
||||
body = f"""\
|
||||
## Deploy failure — action needed
|
||||
|
||||
The automated deploy cron failed **{fail_count} times** on commit \
|
||||
[{failed_sha[:8]}]({commit_url}) and has stopped retrying.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Detected** | {timestamp} |
|
||||
| **Failing commit** | [{failed_sha}]({commit_url}) |
|
||||
| **Failures** | {fail_count} / {MAX_FAILURES} |
|
||||
| **Deploy script** | [deploy_cron.py]({script_url}) |
|
||||
| **Log file** | `~/si-deploy-cron/deploy.log` |
|
||||
|
||||
### Last deploy output
|
||||
|
||||
```
|
||||
{tail}
|
||||
```
|
||||
|
||||
### Next steps
|
||||
|
||||
Push a fix to `main` — the cron (every 5 min) will retry automatically on the next commit.
|
||||
"""
|
||||
|
||||
result = subprocess.run(
|
||||
['tea', 'issue', 'create',
|
||||
'--repo', REPO,
|
||||
'--title', title,
|
||||
'--description', body,
|
||||
'--labels', 'State/Ready,Prio/High'],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f'Failed to create issue: {result.stderr}', file=sys.stderr)
|
||||
else:
|
||||
print(f'Issue created: {result.stdout.strip()}')
|
||||
|
||||
|
||||
def main():
|
||||
git('fetch', 'origin', 'main')
|
||||
remote_sha = git('rev-parse', 'origin/main')
|
||||
|
||||
last_sha = read(SHA_FILE)
|
||||
last_failed = read(FAILED_SHA_FILE)
|
||||
fail_count = read_int(FAIL_COUNT_FILE) if remote_sha == last_failed else 0
|
||||
last_issue = read(ISSUE_SHA_FILE)
|
||||
|
||||
if remote_sha == last_sha:
|
||||
print(f'No changes since {remote_sha[:8]}, skipping.')
|
||||
return
|
||||
|
||||
if fail_count >= MAX_FAILURES:
|
||||
if remote_sha != last_issue and not issue_exists_for(remote_sha):
|
||||
print(f'{remote_sha[:8]} failed {fail_count}x — creating issue.')
|
||||
create_issue(remote_sha, fail_count)
|
||||
ISSUE_SHA_FILE.write_text(remote_sha + '\n')
|
||||
else:
|
||||
print(f'{remote_sha[:8]} failed {fail_count}x, issue already exists, skipping.')
|
||||
return
|
||||
|
||||
attempt = fail_count + 1
|
||||
print(f'Deploying {remote_sha[:8]} (attempt {attempt}/{MAX_FAILURES}, was {last_sha[:8] or "none"})...')
|
||||
git('pull', '--ff-only', 'origin', 'main')
|
||||
|
||||
result = subprocess.run(
|
||||
['task', 'publish-website'],
|
||||
cwd=REPO_DIR,
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
combined = result.stdout + result.stderr
|
||||
print(combined, end='')
|
||||
|
||||
if result.returncode != 0:
|
||||
print(f'Deploy failed (exit {result.returncode}), attempt {attempt}/{MAX_FAILURES}', file=sys.stderr)
|
||||
FAILED_SHA_FILE.write_text(remote_sha + '\n')
|
||||
FAIL_COUNT_FILE.write_text(str(attempt) + '\n')
|
||||
ERROR_FILE.write_text(combined)
|
||||
sys.exit(1)
|
||||
|
||||
SHA_FILE.write_text(remote_sha + '\n')
|
||||
for f in (FAILED_SHA_FILE, FAIL_COUNT_FILE, ERROR_FILE, ISSUE_SHA_FILE):
|
||||
f.unlink(missing_ok=True)
|
||||
print('Deploy complete.')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -29,7 +29,11 @@
|
||||
cairo
|
||||
gdk-pixbuf
|
||||
harfbuzz
|
||||
# Dagger remote setup dependencies
|
||||
stunnel
|
||||
netcat
|
||||
];
|
||||
|
||||
fgj = pkgs.stdenv.mkDerivation {
|
||||
pname = "fgj";
|
||||
version = "0.4.0";
|
||||
|
||||
@@ -112,12 +112,28 @@ void main() {
|
||||
late String userPass;
|
||||
|
||||
setUpAll(() {
|
||||
imapHost = Platform.environment['STALWART_IMAP_HOST'] ?? '127.0.0.1';
|
||||
imapPort = int.parse(Platform.environment['STALWART_IMAP_PORT'] ?? '1430');
|
||||
smtpHost = Platform.environment['STALWART_SMTP_HOST'] ?? '127.0.0.1';
|
||||
smtpPort = int.parse(Platform.environment['STALWART_SMTP_PORT'] ?? '1025');
|
||||
userEmail = Platform.environment['STALWART_USER_B'] ?? 'alice@example.com';
|
||||
userPass = Platform.environment['STALWART_PASS_B'] ?? 'secret';
|
||||
const required = [
|
||||
'STALWART_IMAP_HOST',
|
||||
'STALWART_IMAP_PORT',
|
||||
'STALWART_SMTP_HOST',
|
||||
'STALWART_SMTP_PORT',
|
||||
'STALWART_USER_B',
|
||||
'STALWART_PASS_B',
|
||||
];
|
||||
final missing = required.where((k) => Platform.environment[k] == null).toList();
|
||||
if (missing.isNotEmpty) {
|
||||
fail(
|
||||
'Missing required environment variables: ${missing.join(', ')}. '
|
||||
'This test requires a running Stalwart instance — '
|
||||
'run via stalwart-dev/integration_ui_test.sh.',
|
||||
);
|
||||
}
|
||||
imapHost = Platform.environment['STALWART_IMAP_HOST']!;
|
||||
imapPort = int.parse(Platform.environment['STALWART_IMAP_PORT']!);
|
||||
smtpHost = Platform.environment['STALWART_SMTP_HOST']!;
|
||||
smtpPort = int.parse(Platform.environment['STALWART_SMTP_PORT']!);
|
||||
userEmail = Platform.environment['STALWART_USER_B']!;
|
||||
userPass = Platform.environment['STALWART_PASS_B']!;
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
|
||||
@@ -1,26 +1,35 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
|
||||
const _kChannelId = 'new_mail';
|
||||
const _kChannelName = 'New mail';
|
||||
|
||||
final _plugin = FlutterLocalNotificationsPlugin();
|
||||
bool _initialized = false;
|
||||
|
||||
Future<void> initNotifications() async {
|
||||
const android = AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||
await _plugin.initialize(
|
||||
const InitializationSettings(android: android),
|
||||
onDidReceiveNotificationResponse: (_) {},
|
||||
);
|
||||
await _plugin
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin>()
|
||||
?.requestNotificationsPermission();
|
||||
try {
|
||||
const android = AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||
await _plugin.initialize(
|
||||
const InitializationSettings(android: android),
|
||||
onDidReceiveNotificationResponse: (_) {},
|
||||
);
|
||||
await _plugin
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin>()
|
||||
?.requestNotificationsPermission();
|
||||
_initialized = true;
|
||||
} on MissingPluginException {
|
||||
// Plugin not registered on this device; notifications silently disabled.
|
||||
} catch (_) {
|
||||
// Unexpected initialization failure; notifications silently disabled.
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> showNewMailNotification(String accountEmail) async {
|
||||
if (!Platform.isAndroid) return;
|
||||
if (!Platform.isAndroid || !_initialized) return;
|
||||
await _plugin.show(
|
||||
accountEmail.hashCode & 0x7FFFFFFF,
|
||||
'New mail',
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'dart:io';
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:enough_mail/enough_mail.dart' as imap;
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
@@ -32,14 +33,22 @@ void callbackDispatcher() {
|
||||
}
|
||||
|
||||
Future<void> registerBackgroundSync() async {
|
||||
await Workmanager().initialize(callbackDispatcher);
|
||||
await Workmanager().registerPeriodicTask(
|
||||
_kTaskName,
|
||||
_kTaskName,
|
||||
frequency: const Duration(minutes: 15),
|
||||
constraints: Constraints(networkType: NetworkType.connected),
|
||||
existingWorkPolicy: ExistingPeriodicWorkPolicy.keep,
|
||||
);
|
||||
try {
|
||||
await Workmanager().initialize(callbackDispatcher);
|
||||
await Workmanager().registerPeriodicTask(
|
||||
_kTaskName,
|
||||
_kTaskName,
|
||||
frequency: const Duration(minutes: 15),
|
||||
constraints: Constraints(networkType: NetworkType.connected),
|
||||
existingWorkPolicy: ExistingPeriodicWorkPolicy.keep,
|
||||
);
|
||||
} on PlatformException {
|
||||
// WorkManager channel unavailable on this device; background sync disabled.
|
||||
} on MissingPluginException {
|
||||
// Plugin not registered on this device; background sync disabled.
|
||||
} catch (_) {
|
||||
// Unexpected initialization failure; background sync disabled.
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _doBackgroundSync() async {
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:io';
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
@@ -578,20 +579,44 @@ String? _dbPath;
|
||||
|
||||
/// Call after WidgetsFlutterBinding.ensureInitialized() so that the
|
||||
/// path_provider plugin channel is registered before the first DB access.
|
||||
/// On some Android versions the Pigeon channel is not ready at the very
|
||||
/// start of main(); if it fails, _openConnection() retries lazily.
|
||||
Future<void> initDatabasePath() async {
|
||||
final dir = await getApplicationSupportDirectory();
|
||||
_dbPath = p.join(dir.path, 'sharedinbox.db');
|
||||
try {
|
||||
final dir = await getApplicationSupportDirectory();
|
||||
_dbPath = p.join(dir.path, 'sharedinbox.db');
|
||||
} on PlatformException {
|
||||
// Channel not yet established; LazyDatabase will resolve the path
|
||||
// on first access, after runApp() completes initialization.
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the application support path, retrying on PlatformException to
|
||||
/// survive a race where the path_provider Pigeon channel isn't ready yet.
|
||||
Future<String> _resolveDatabasePath() async {
|
||||
if (_dbPath != null) return _dbPath!;
|
||||
// initDatabasePath() failed (channel not ready before runApp). Retry now
|
||||
// that the engine is fully initialised, with brief back-off.
|
||||
const delays = [100, 300, 600];
|
||||
for (final ms in delays) {
|
||||
try {
|
||||
final dir = await getApplicationSupportDirectory();
|
||||
_dbPath = p.join(dir.path, 'sharedinbox.db');
|
||||
return _dbPath!;
|
||||
} on PlatformException {
|
||||
await Future<void>.delayed(Duration(milliseconds: ms));
|
||||
}
|
||||
}
|
||||
throw PlatformException(
|
||||
code: 'channel-error',
|
||||
message: 'path_provider unavailable after ${delays.length + 1} attempts — '
|
||||
'cannot open database.',
|
||||
);
|
||||
}
|
||||
|
||||
LazyDatabase _openConnection() {
|
||||
return LazyDatabase(() async {
|
||||
final file = File(
|
||||
_dbPath ??
|
||||
p.join(
|
||||
(await getApplicationSupportDirectory()).path,
|
||||
'sharedinbox.db',
|
||||
),
|
||||
);
|
||||
final file = File(await _resolveDatabasePath());
|
||||
return NativeDatabase.createInBackground(
|
||||
file,
|
||||
setup: (db) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:sharedinbox/core/models/account.dart';
|
||||
|
||||
@@ -2,7 +2,7 @@ import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart' show rootBundle;
|
||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class ChangeLogScreen extends StatelessWidget {
|
||||
|
||||
@@ -37,39 +37,22 @@ class CrashScreen extends StatelessWidget {
|
||||
title: const Text('Something went wrong'),
|
||||
backgroundColor: Theme.of(context).colorScheme.errorContainer,
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, color: Colors.red, size: 64),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'sharedinbox.de encountered an unexpected error and needs to be restarted.',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Text(
|
||||
'Error Details:',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[200],
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
exception.toString(),
|
||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
|
||||
),
|
||||
),
|
||||
if (stackTrace != null) ...[
|
||||
body: Builder(
|
||||
builder: (ctx) => SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, color: Colors.red, size: 64),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'sharedinbox.de encountered an unexpected error and needs to be restarted.',
|
||||
style: Theme.of(ctx).textTheme.titleMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Text(
|
||||
'Stack Trace:',
|
||||
'Error Details:',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
@@ -80,70 +63,94 @@ class CrashScreen extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
stackTrace.toString(),
|
||||
exception.toString(),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 10,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
FilledButton.icon(
|
||||
onPressed: () async {
|
||||
final data = await _buildReport();
|
||||
await Clipboard.setData(ClipboardData(text: data));
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
duration: Duration(seconds: 5),
|
||||
content: Text('Copied to clipboard'),
|
||||
if (stackTrace != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Stack Trace:',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[200],
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
stackTrace.toString(),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 10,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.copy),
|
||||
label: const Text('Copy to Clipboard'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () async {
|
||||
final report = await _buildReport();
|
||||
final title = Uri.encodeComponent(
|
||||
'Crash: ${exception.toString().split('\n').first}',
|
||||
);
|
||||
final body = Uri.encodeComponent(report);
|
||||
final url = Uri.parse(
|
||||
'https://codeberg.org/guettli/sharedinbox/issues/new?title=$title&body=$body',
|
||||
);
|
||||
try {
|
||||
final launched = await launchUrl(
|
||||
url,
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
if (!launched && context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
FilledButton.icon(
|
||||
onPressed: () async {
|
||||
final data = await _buildReport();
|
||||
await Clipboard.setData(ClipboardData(text: data));
|
||||
if (ctx.mounted) {
|
||||
ScaffoldMessenger.of(ctx).showSnackBar(
|
||||
const SnackBar(
|
||||
duration: Duration(seconds: 5),
|
||||
content: Text('Could not open browser.'),
|
||||
content: Text('Copied to clipboard'),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
duration: const Duration(seconds: 5),
|
||||
content: Text('Error: $e'),
|
||||
),
|
||||
},
|
||||
icon: const Icon(Icons.copy),
|
||||
label: const Text('Copy to Clipboard'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () async {
|
||||
// URL carries only the title to avoid exceeding browser
|
||||
// URL-length limits — long stack traces caused "create
|
||||
// issue failed" (#146). Use "Copy to Clipboard" first to
|
||||
// get the full report, then paste it in the issue body.
|
||||
final title = Uri.encodeComponent(
|
||||
'Crash: ${exception.toString().split('\n').first}',
|
||||
);
|
||||
final url = Uri.parse(
|
||||
'https://codeberg.org/guettli/sharedinbox/issues/new?title=$title',
|
||||
);
|
||||
try {
|
||||
final launched = await launchUrl(
|
||||
url,
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
if (!launched && ctx.mounted) {
|
||||
ScaffoldMessenger.of(ctx).showSnackBar(
|
||||
const SnackBar(
|
||||
duration: Duration(seconds: 5),
|
||||
content: Text('Could not open browser.'),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (ctx.mounted) {
|
||||
ScaffoldMessenger.of(ctx).showSnackBar(
|
||||
SnackBar(
|
||||
duration: const Duration(seconds: 5),
|
||||
content: Text('Error: $e'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.bug_report),
|
||||
label: const Text('Report Issue on Codeberg'),
|
||||
),
|
||||
],
|
||||
},
|
||||
icon: const Icon(Icons.bug_report),
|
||||
label: const Text('Report Issue on Codeberg'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
+1329
File diff suppressed because it is too large
Load Diff
+7
-6
@@ -52,7 +52,7 @@ dependencies:
|
||||
# HTML rendering for email bodies
|
||||
webview_flutter: ^4.0.0
|
||||
url_launcher: ^6.3.2
|
||||
flutter_markdown: ^0.7.7+1
|
||||
flutter_markdown_plus: ^1.0.7
|
||||
|
||||
# Background sync and local notifications
|
||||
flutter_local_notifications: ^18.0.1
|
||||
@@ -74,7 +74,7 @@ dev_dependencies:
|
||||
mockito: ^5.4.4
|
||||
fake_async: ^1.3.1
|
||||
path_provider_platform_interface: ^2.1.2
|
||||
sqlite3: any # used directly in test/unit/db_test_helper.dart
|
||||
sqlite3: ^3.1.5 # used directly in test/unit/db_test_helper.dart; 3.x required for Database.close()
|
||||
url_launcher_platform_interface: ^2.3.2
|
||||
plugin_platform_interface: ^2.1.8
|
||||
|
||||
@@ -84,7 +84,8 @@ flutter:
|
||||
- assets/
|
||||
|
||||
dependency_overrides:
|
||||
# path_provider_android 2.3+ uses package:jni which crashes on startup
|
||||
# (SIGSEGV in libdartjni.so FindClassUnchecked — JNI env not ready when
|
||||
# the Dart VM first calls into it). Pin to 2.2.x which uses Pigeon instead.
|
||||
path_provider_android: ">=2.2.0 <2.3.0"
|
||||
# path_provider_android 2.2.21 updated to Pigeon 26, which causes a
|
||||
# channel-error on startup on some Android devices. 2.3+ uses package:jni
|
||||
# (SIGSEGV in libdartjni.so FindClassUnchecked). Pin to 2.2.20 which uses
|
||||
# stable Pigeon and is known to work reliably.
|
||||
path_provider_android: ">=2.2.0 <2.2.21"
|
||||
|
||||
+392
-88
@@ -7,23 +7,28 @@ Flow
|
||||
1. Agent already running?
|
||||
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)
|
||||
2. No agent running → check Codeberg CI
|
||||
a. CI is running → print "CI running, waiting", exit 0
|
||||
b. Latest CI failed → start fix-CI agent, save state, exit 0
|
||||
c. CI ok (or no run yet) → find oldest Ready issue, start issue agent,
|
||||
2. No agent running → extract pending_issue from state (if any), then check CI
|
||||
a. CI is running → save pending-ci state, exit 0
|
||||
b. Latest CI failed → start fix-CI agent (preserving pending_issue), exit 0
|
||||
c. CI ok + pending_issue → close the issue (CI passed), exit 0
|
||||
d. CI ok (or no run yet) → find oldest Ready issue, start issue agent,
|
||||
save state, exit 0
|
||||
d. No Ready issues → print "nothing to do", exit 0
|
||||
e. No Ready issues → print "nothing to do", exit 0
|
||||
|
||||
Issue agents must NOT close the issue themselves; the loop closes it after CI passes.
|
||||
|
||||
State file: ~/.sharedinbox-agent-state.json
|
||||
{ "pid": 12345, "issue": 91,
|
||||
"started_at": "2026-05-15T12:00:00+00:00", "type": "issue" }
|
||||
|
||||
Output is written to ~/.sharedinbox-agent-logs/<session>-<timestamp>.log.
|
||||
Resume the Claude conversation afterward with:
|
||||
To resume the Claude conversation, look up the session UUID first:
|
||||
|
||||
claude --resume issue-91
|
||||
scripts/agent_loop.py list # shows NAME and UUID columns
|
||||
claude --resume <uuid> # use the UUID, NOT the session name
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
@@ -32,47 +37,57 @@ import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
# Cron runs with a minimal PATH; ensure Nix profile binaries (tea, claude) are found.
|
||||
os.environ["PATH"] = f"/home/si/.nix-profile/bin:{os.environ.get('PATH', '/usr/bin:/bin')}"
|
||||
# Cron runs with a minimal PATH; ensure Nix profile binaries (tea, claude) and ~/go/bin (fgj) are found.
|
||||
os.environ["PATH"] = (
|
||||
f"{Path.home()}/.nix-profile/bin"
|
||||
f":{Path.home()}/go/bin"
|
||||
f":{os.environ.get('PATH', '/usr/bin:/bin')}"
|
||||
)
|
||||
|
||||
# ── configuration ─────────────────────────────────────────────────────────────
|
||||
|
||||
REPO = "guettli/sharedinbox"
|
||||
REPO_URL = f"https://codeberg.org/{REPO}"
|
||||
STATE_FILE = Path.home() / ".sharedinbox-agent-state.json"
|
||||
MAX_AGENT_AGE_SECONDS = 3600 # 1 hour
|
||||
CLAUDE_PROJECTS_DIR = Path.home() / ".claude" / "projects" / (
|
||||
"-" + str(Path.home())[1:].replace("/", "-")
|
||||
)
|
||||
|
||||
# Labels used by the workflow.
|
||||
LABEL_READY = "State/Ready"
|
||||
LABEL_IN_PROGRESS = "State/InProgress"
|
||||
LABEL_QUESTION = "State/Question"
|
||||
LABEL_PRIO_HIGH = "Prio/High"
|
||||
|
||||
# Only pick up issues filed by these accounts.
|
||||
ALLOWED_ISSUE_AUTHORS = {"guettli", "guettlibot", "guettlibot2"}
|
||||
|
||||
# ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _tea(*args: str) -> dict | list | None:
|
||||
"""Run a `tea api` command and return parsed JSON, or None on 204."""
|
||||
method = "GET"
|
||||
path = args[0]
|
||||
extra: list[str] = []
|
||||
body_str = None
|
||||
def _issue_url(number: int) -> str:
|
||||
return f"{REPO_URL}/issues/{number}"
|
||||
|
||||
i = 1
|
||||
while i < len(args):
|
||||
if args[i] in ("--method", "-X") and i + 1 < len(args):
|
||||
method = args[i + 1]
|
||||
i += 2
|
||||
elif args[i] in ("--data", "-d") and i + 1 < len(args):
|
||||
body_str = args[i + 1]
|
||||
i += 2
|
||||
else:
|
||||
extra.append(args[i])
|
||||
i += 1
|
||||
|
||||
cmd = ["tea", "api", "--repo", REPO, "-X", method]
|
||||
if body_str:
|
||||
cmd += ["-d", body_str]
|
||||
cmd.append(path)
|
||||
def _ci_run_url(run_id: int) -> str:
|
||||
return f"{REPO_URL}/actions/runs/{run_id}"
|
||||
|
||||
|
||||
def _fgj(*args: str) -> None:
|
||||
"""Run a fgj command, raising on failure."""
|
||||
cmd = ["fgj", "--hostname", "codeberg.org", *args]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"fgj {' '.join(args)} failed:\n{result.stderr or result.stdout}"
|
||||
)
|
||||
|
||||
|
||||
def _tea_get(path: str) -> dict | list | None:
|
||||
"""Run a tea api GET and return parsed JSON. Only use for reads — tea PATCH/PUT
|
||||
silently fails (exits 0) when unauthenticated, so writes must go via fgj."""
|
||||
cmd = ["tea", "api", path]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(
|
||||
@@ -81,50 +96,103 @@ def _tea(*args: str) -> dict | list | None:
|
||||
out = result.stdout.strip()
|
||||
if not out:
|
||||
return None
|
||||
return json.loads(out)
|
||||
data = json.loads(out)
|
||||
if isinstance(data, dict) and "message" in data and "url" in data:
|
||||
raise RuntimeError(f"tea api {path} returned error: {data['message']}")
|
||||
return data
|
||||
|
||||
|
||||
def _set_labels(issue: int, add: list[str], remove: list[str]) -> None:
|
||||
"""Replace labels on an issue via the tea CLI."""
|
||||
current = _tea(f"repos/{REPO}/issues/{issue}/labels") or []
|
||||
current_names = {lbl["name"] for lbl in current}
|
||||
all_labels = _tea(f"repos/{REPO}/labels") or []
|
||||
name_to_id = {lbl["name"]: lbl["id"] for lbl in all_labels}
|
||||
|
||||
desired = (current_names - set(remove)) | set(add)
|
||||
ids = [name_to_id[n] for n in desired if n in name_to_id]
|
||||
_tea(
|
||||
f"repos/{REPO}/issues/{issue}/labels",
|
||||
"-X", "PUT",
|
||||
"-d", json.dumps({"labels": ids}),
|
||||
)
|
||||
"""Add/remove labels on an issue via fgj."""
|
||||
cmd = ["issue", "edit", str(issue), "--repo", REPO]
|
||||
for label in add:
|
||||
cmd += ["--add-label", label]
|
||||
for label in remove:
|
||||
cmd += ["--remove-label", label]
|
||||
_fgj(*cmd)
|
||||
|
||||
|
||||
def _close_issue(issue: int) -> None:
|
||||
_tea(
|
||||
f"repos/{REPO}/issues/{issue}",
|
||||
"-X", "PATCH",
|
||||
"-d", json.dumps({"state": "closed"}),
|
||||
)
|
||||
_fgj("issue", "close", str(issue), "--repo", REPO)
|
||||
_set_labels(issue, add=[], remove=[LABEL_IN_PROGRESS])
|
||||
|
||||
|
||||
def _comment_issue(issue: int, body: str) -> None:
|
||||
_fgj("issue", "comment", str(issue), "--repo", REPO, "--body", body)
|
||||
|
||||
|
||||
def _ready_issues() -> list[dict]:
|
||||
"""Return open issues with State/Ready, oldest first."""
|
||||
data = _tea(f"repos/{REPO}/issues?state=open&type=issues&limit=50") or []
|
||||
"""Return open issues with State/Ready, Prio/High first, then oldest."""
|
||||
result = subprocess.run(
|
||||
["fgj", "--hostname", "codeberg.org", "issue", "list",
|
||||
"--repo", REPO, "--state", "open", "--json"],
|
||||
capture_output=True, text=True, check=True,
|
||||
)
|
||||
data = json.loads(result.stdout) if result.stdout.strip() else []
|
||||
ready = [
|
||||
i for i in data
|
||||
if any(lbl["name"] == LABEL_READY for lbl in i.get("labels", []))
|
||||
and i.get("user", {}).get("login", "") in ALLOWED_ISSUE_AUTHORS
|
||||
]
|
||||
ready.sort(key=lambda i: i["number"])
|
||||
ready.sort(key=lambda i: (
|
||||
0 if any(lbl["name"] == LABEL_PRIO_HIGH for lbl in i.get("labels", [])) else 1,
|
||||
i["number"],
|
||||
))
|
||||
return ready
|
||||
|
||||
|
||||
def _latest_ci_run() -> dict | None:
|
||||
data = _tea(f"repos/{REPO}/actions/runs?limit=1")
|
||||
data = _tea_get(f"repos/{REPO}/actions/runs?limit=1")
|
||||
runs = (data or {}).get("workflow_runs", [])
|
||||
return runs[0] if runs else None
|
||||
|
||||
|
||||
def _latest_ci_run_for_branch(branch: str) -> dict | None:
|
||||
"""Return the latest CI run for a specific branch, or None.
|
||||
|
||||
Forgejo's workflow_runs API has no top-level head_branch field.
|
||||
For push events the branch is in ``prettyref``; for pull_request
|
||||
events it lives inside ``event_payload["pull_request"]["head"]["ref"]``.
|
||||
"""
|
||||
data = _tea_get(f"repos/{REPO}/actions/runs?limit=20")
|
||||
runs = (data or {}).get("workflow_runs", [])
|
||||
for run in runs:
|
||||
if run.get("event") == "pull_request":
|
||||
try:
|
||||
payload = json.loads(run.get("event_payload", "{}"))
|
||||
if payload.get("pull_request", {}).get("head", {}).get("ref") == branch:
|
||||
return run
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
pass
|
||||
else:
|
||||
if run.get("prettyref") == branch:
|
||||
return run
|
||||
return None
|
||||
|
||||
|
||||
def _find_pr_for_branch(branch: str) -> dict | None:
|
||||
"""Return the first open PR whose head branch matches, or None."""
|
||||
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 None
|
||||
prs = json.loads(result.stdout)
|
||||
for pr in prs:
|
||||
head = pr.get("head", {})
|
||||
ref = head.get("ref") or head.get("label", "").split(":")[-1]
|
||||
if ref == branch:
|
||||
return pr
|
||||
return None
|
||||
|
||||
|
||||
def _merge_pr(pr_number: int) -> None:
|
||||
"""Squash-merge a PR via fgj."""
|
||||
_fgj("pr", "merge", str(pr_number), "--repo", REPO, "--merge-method", "squash")
|
||||
|
||||
|
||||
# ── state file ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -137,35 +205,63 @@ def _read_state() -> dict | None:
|
||||
return None
|
||||
|
||||
|
||||
def _write_state(pid: int, issue: int | None, kind: str) -> None:
|
||||
STATE_FILE.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"pid": pid,
|
||||
"issue": issue,
|
||||
"started_at": datetime.now(timezone.utc).isoformat(),
|
||||
"type": kind,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
def _write_state(pid: int | None, issue: int | None, kind: str, issue_title: str | None = None, session_name: str | None = None, ci_run_id: int | None = None) -> None:
|
||||
data: dict = {
|
||||
"pid": pid,
|
||||
"issue": issue,
|
||||
"started_at": datetime.now(timezone.utc).isoformat(),
|
||||
"type": kind,
|
||||
}
|
||||
if issue_title is not None:
|
||||
data["issue_title"] = issue_title
|
||||
if session_name is not None:
|
||||
data["session_name"] = session_name
|
||||
if ci_run_id is not None:
|
||||
data["ci_run_id_at_start"] = ci_run_id
|
||||
STATE_FILE.write_text(json.dumps(data, indent=2))
|
||||
STATE_FILE.chmod(0o600)
|
||||
|
||||
|
||||
def _clear_state() -> None:
|
||||
STATE_FILE.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _find_session_uuid(session_name: str) -> str | None:
|
||||
"""Return the Claude session UUID for *session_name*, or None if not found.
|
||||
|
||||
Claude stores session metadata in JSONL files; the first entry with
|
||||
type=="agent-name" contains both the human-readable name and the UUID
|
||||
needed for ``claude --resume <uuid>``.
|
||||
"""
|
||||
if not CLAUDE_PROJECTS_DIR.exists():
|
||||
return None
|
||||
for jsonl in CLAUDE_PROJECTS_DIR.glob("*.jsonl"):
|
||||
try:
|
||||
with jsonl.open() as fh:
|
||||
for line in fh:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
d = json.loads(line)
|
||||
if d.get("type") == "agent-name" and d.get("agentName") == session_name:
|
||||
return d.get("sessionId")
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
# ── agent launcher ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _start_agent(prompt: str, session_name: str) -> int:
|
||||
"""Start Claude Code as a detached background process and return its PID."""
|
||||
log_dir = Path.home() / ".sharedinbox-agent-logs"
|
||||
log_dir.mkdir(exist_ok=True)
|
||||
log_dir.mkdir(mode=0o700, exist_ok=True)
|
||||
log_dir.chmod(0o700) # fix permissions if dir already existed with wrong mode
|
||||
ts = datetime.now().strftime("%Y%m%dT%H%M%S")
|
||||
log_file = log_dir / f"{session_name}-{ts}.log"
|
||||
|
||||
log_fh = open(log_file, "w")
|
||||
log_fh = open(log_file, "w", opener=lambda p, f: os.open(p, f, 0o600))
|
||||
proc = subprocess.Popen(
|
||||
[
|
||||
"claude",
|
||||
@@ -183,8 +279,8 @@ def _start_agent(prompt: str, session_name: str) -> int:
|
||||
proc.stdin.write(b"\n")
|
||||
proc.stdin.close()
|
||||
|
||||
print(f"[agent_loop] Started agent pid={proc.pid}, log={log_file}")
|
||||
print(f"[agent_loop] Resume: claude --resume {shlex.quote(session_name)}")
|
||||
print(f"Started agent pid={proc.pid}, log={log_file}")
|
||||
print(f" Resume: run 'scripts/agent_loop.py list' to get the UUID-based resume command")
|
||||
return proc.pid
|
||||
|
||||
|
||||
@@ -211,6 +307,28 @@ def _agent_age_seconds(state: dict) -> float:
|
||||
return 0.0
|
||||
|
||||
|
||||
def _git_summary() -> str:
|
||||
"""Return a one-line summary of the latest commit and whether it's been pushed."""
|
||||
try:
|
||||
commit = subprocess.run(
|
||||
["git", "log", "--oneline", "-1"],
|
||||
capture_output=True, text=True, check=True,
|
||||
).stdout.strip()
|
||||
ahead = subprocess.run(
|
||||
["git", "rev-list", "--count", "HEAD@{u}..HEAD"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if ahead.returncode == 0 and ahead.stdout.strip() != "0":
|
||||
push_status = f"not pushed ({ahead.stdout.strip()} ahead)"
|
||||
elif ahead.returncode == 0:
|
||||
push_status = "pushed"
|
||||
else:
|
||||
push_status = "no upstream"
|
||||
return f"{commit} [{push_status}]"
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _kill_agent(state: dict) -> None:
|
||||
"""Forcefully stop the running agent."""
|
||||
pid = state.get("pid")
|
||||
@@ -221,10 +339,58 @@ def _kill_agent(state: dict) -> None:
|
||||
pass
|
||||
|
||||
|
||||
# ── subcommands ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def cmd_list() -> int:
|
||||
"""List recent agent-loop sessions, newest first."""
|
||||
if not CLAUDE_PROJECTS_DIR.exists():
|
||||
print(f"No sessions found (directory missing: {CLAUDE_PROJECTS_DIR})")
|
||||
return 0
|
||||
|
||||
sessions = []
|
||||
for jsonl in CLAUDE_PROJECTS_DIR.glob("*.jsonl"):
|
||||
agent_name = None
|
||||
session_id = None
|
||||
try:
|
||||
with jsonl.open() as fh:
|
||||
for line in fh:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
d = json.loads(line)
|
||||
if d.get("type") == "agent-name":
|
||||
agent_name = d.get("agentName")
|
||||
session_id = d.get("sessionId")
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if agent_name:
|
||||
sessions.append((jsonl.stat().st_mtime, agent_name, session_id))
|
||||
|
||||
if not sessions:
|
||||
print("No agent sessions found.")
|
||||
return 0
|
||||
|
||||
sessions.sort(reverse=True)
|
||||
total = len(sessions)
|
||||
print(f" {'DATE':<16} {'NAME':<20} UUID (use with: claude --resume <uuid>)")
|
||||
print(f" {'-'*16} {'-'*20} {'-'*36}")
|
||||
for mtime, name, sid in sessions[:20]:
|
||||
ts = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M")
|
||||
print(f" {ts:<16} {name:<20} {sid}")
|
||||
if total > 20:
|
||||
print(f" ... ({total - 20} more)")
|
||||
return 0
|
||||
|
||||
|
||||
# ── main flow ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def main() -> int:
|
||||
def _run_loop() -> int:
|
||||
now = datetime.now(timezone.utc)
|
||||
print(f"---------------------- Starting {now.strftime('%Y-%m-%d %H:%MZ')}")
|
||||
|
||||
state = _read_state()
|
||||
|
||||
# ── 1. Agent already running? ─────────────────────────────────────────────
|
||||
@@ -234,37 +400,134 @@ def main() -> int:
|
||||
kind = state.get("type", "issue")
|
||||
pid = state.get("pid", "?")
|
||||
|
||||
issue_title = state.get("issue_title", "")
|
||||
issue_ref = (
|
||||
f"{_issue_url(issue)} {issue_title}".strip() if issue else str(issue)
|
||||
)
|
||||
|
||||
if age > MAX_AGENT_AGE_SECONDS:
|
||||
print(
|
||||
f"[agent_loop] Agent pid={pid!r} (issue #{issue}) "
|
||||
f"Agent pid={pid!r} ({issue_ref}) "
|
||||
f"has been running for {age/60:.0f} min — aborting."
|
||||
)
|
||||
_kill_agent(state)
|
||||
_clear_state()
|
||||
if issue:
|
||||
_set_labels(issue, add=[LABEL_QUESTION], remove=[LABEL_IN_PROGRESS])
|
||||
print(f"[agent_loop] Set issue #{issue} to State/Question.")
|
||||
_comment_issue(
|
||||
issue,
|
||||
f"Agent (pid {pid}) was killed after running for {age/60:.0f} min "
|
||||
f"(limit: {MAX_AGENT_AGE_SECONDS//60} min). "
|
||||
"Please investigate and resume manually.",
|
||||
)
|
||||
print(f"Set {_issue_url(issue)} to State/Question.")
|
||||
return 1
|
||||
|
||||
print(
|
||||
f"[agent_loop] Agent pid={pid!r} ({kind}, issue #{issue}) "
|
||||
f"still running ({age/60:.0f} min). Waiting."
|
||||
)
|
||||
session_name = state.get("session_name")
|
||||
uuid = _find_session_uuid(session_name) if session_name else None
|
||||
if uuid:
|
||||
resume_cmd = f"claude --resume {shlex.quote(uuid)}"
|
||||
elif session_name:
|
||||
resume_cmd = f"claude --resume <uuid> # run: scripts/agent_loop.py list"
|
||||
else:
|
||||
resume_cmd = ""
|
||||
git_info = _git_summary()
|
||||
parts = [
|
||||
f"Agent pid={pid!r} ({kind}, {issue_ref}) still running ({age/60:.0f} min). Waiting.",
|
||||
]
|
||||
if resume_cmd:
|
||||
parts.append(f" Resume: {resume_cmd}")
|
||||
if git_info:
|
||||
parts.append(f" Commit: {git_info}")
|
||||
print("\n".join(parts))
|
||||
return 0
|
||||
|
||||
# Agent not running (or no state) — clean up stale state.
|
||||
# Agent not running (or no state) — extract any pending issue, then clean up.
|
||||
pending_issue: int | None = None
|
||||
ci_run_id_at_start: int | None = None
|
||||
if state:
|
||||
pending_issue = state.get("issue")
|
||||
ci_run_id_at_start = state.get("ci_run_id_at_start")
|
||||
_clear_state()
|
||||
|
||||
# ── 2. Check CI ───────────────────────────────────────────────────────────
|
||||
# ── 2. Check for a PR opened by the agent ────────────────────────────────
|
||||
if pending_issue:
|
||||
branch = f"issue-{pending_issue}-fix"
|
||||
pr = _find_pr_for_branch(branch)
|
||||
if pr:
|
||||
pr_number = pr["number"]
|
||||
pr_url = f"{REPO_URL}/pulls/{pr_number}"
|
||||
print(f"Found PR #{pr_number} ({pr_url}) for issue #{pending_issue}.")
|
||||
pr_run = _latest_ci_run_for_branch(branch)
|
||||
|
||||
if pr_run and pr_run.get("status") == "running":
|
||||
print(f"CI run {_ci_run_url(pr_run['id'])} on branch {branch!r} is running. Waiting.")
|
||||
_write_state(None, pending_issue, "pending-ci")
|
||||
return 0
|
||||
|
||||
if pr_run and pr_run.get("status") in ("failure", "error"):
|
||||
print(f"CI run {_ci_run_url(pr_run['id'])} on branch {branch!r} failed — starting fix agent.")
|
||||
prompt = (
|
||||
f"The Codeberg CI for guettli/sharedinbox just failed on branch {branch!r} "
|
||||
f"(PR #{pr_number}). "
|
||||
f"CI run: {_ci_run_url(pr_run['id'])}. "
|
||||
"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. "
|
||||
"Do NOT push to main, do NOT close the issue, do NOT merge the PR. "
|
||||
"Verify locally with 'task check' before pushing. "
|
||||
"When done, stop."
|
||||
)
|
||||
session_name = f"ci-fix-pr-{pr_number}"
|
||||
pid = _start_agent(prompt, session_name)
|
||||
_write_state(pid, pending_issue, "ci-fix", session_name=session_name)
|
||||
return 0
|
||||
|
||||
if not pr_run:
|
||||
# No CI run yet — might be that CI hasn't triggered yet.
|
||||
# Wait up to 15 min before giving up.
|
||||
pr_created_at = pr.get("created_at", "")
|
||||
try:
|
||||
created = datetime.fromisoformat(pr_created_at.replace("Z", "+00:00"))
|
||||
age_s = (datetime.now(timezone.utc) - created).total_seconds()
|
||||
except Exception:
|
||||
age_s = 999999
|
||||
if age_s < 900:
|
||||
print(
|
||||
f"PR #{pr_number} has no CI run yet (created {age_s/60:.0f} min ago). Waiting."
|
||||
)
|
||||
_write_state(None, pending_issue, "pending-ci")
|
||||
return 0
|
||||
print(
|
||||
f"No CI run for branch {branch!r} after {age_s/60:.0f} min — "
|
||||
"agent may not have pushed. Setting to State/Question."
|
||||
)
|
||||
_set_labels(pending_issue, add=[LABEL_QUESTION], remove=[LABEL_IN_PROGRESS])
|
||||
_comment_issue(
|
||||
pending_issue,
|
||||
f"Agent opened PR #{pr_number} but no CI run appeared on branch `{branch}` "
|
||||
f"after {age_s/60:.0f} min. The agent may not have pushed any commits. "
|
||||
"Please investigate and resume manually.",
|
||||
)
|
||||
return 0
|
||||
|
||||
# CI passed on the PR branch — squash-merge and close.
|
||||
print(f"CI passed on branch {branch!r} — merging PR #{pr_number}.")
|
||||
_merge_pr(pr_number)
|
||||
_close_issue(pending_issue)
|
||||
print(f"Merged PR #{pr_number} and closed {_issue_url(pending_issue)}.")
|
||||
return 0
|
||||
|
||||
# ── 3. Global CI check (agent pushed to main, or no pending issue) ────────
|
||||
run = _latest_ci_run()
|
||||
|
||||
if run and run.get("status") == "running":
|
||||
print(f"[agent_loop] CI run {run['id']} is still running. Waiting.")
|
||||
print(f"CI run {_ci_run_url(run['id'])} is still running. Waiting.")
|
||||
if pending_issue:
|
||||
_write_state(None, pending_issue, "pending-ci")
|
||||
return 0
|
||||
|
||||
if run and run.get("status") in ("failure", "error"):
|
||||
print(f"[agent_loop] CI run {run['id']} failed — starting fix agent.")
|
||||
print(f"CI run {_ci_run_url(run['id'])} failed — starting fix agent.")
|
||||
prompt = (
|
||||
"The Codeberg CI for guettli/sharedinbox just failed. "
|
||||
f"The CI run ID is {run['id']}. "
|
||||
@@ -274,13 +537,36 @@ def main() -> int:
|
||||
"When done, stop."
|
||||
)
|
||||
pid = _start_agent(prompt, "ci-fix")
|
||||
_write_state(pid, None, "ci-fix")
|
||||
_write_state(pid, pending_issue, "ci-fix", session_name="ci-fix")
|
||||
return 0
|
||||
|
||||
# CI is ok (or no run) — find a Ready issue.
|
||||
# CI is ok (or no run).
|
||||
if pending_issue:
|
||||
latest_run_id = run["id"] if run else None
|
||||
if ci_run_id_at_start is not None and latest_run_id == ci_run_id_at_start:
|
||||
# CI run hasn't changed since the agent was launched → agent pushed nothing
|
||||
# (likely crashed or hit a rate limit).
|
||||
print(
|
||||
f"No new CI run since agent started for {_issue_url(pending_issue)} "
|
||||
f"(run id {latest_run_id}) — agent did nothing. Setting to State/Question."
|
||||
)
|
||||
_set_labels(pending_issue, add=[LABEL_QUESTION], remove=[LABEL_IN_PROGRESS])
|
||||
_comment_issue(
|
||||
pending_issue,
|
||||
"The agent exited without pushing any changes (no new CI run was triggered). "
|
||||
"This usually means the agent hit a rate limit or crashed at startup. "
|
||||
"The issue has been set to State/Question — please review the agent log and retry.",
|
||||
)
|
||||
return 0
|
||||
_close_issue(pending_issue)
|
||||
ci_run_part = f" {_ci_run_url(run['id'])}" if run else ""
|
||||
print(f"CI passed{ci_run_part} — closed {_issue_url(pending_issue)}.")
|
||||
return 0
|
||||
|
||||
# Find a Ready issue.
|
||||
issues = _ready_issues()
|
||||
if not issues:
|
||||
print("[agent_loop] No issues with State/Ready. Nothing to do.")
|
||||
print("No issues with State/Ready. Nothing to do.")
|
||||
return 0
|
||||
|
||||
issue = issues[0]
|
||||
@@ -288,7 +574,7 @@ def main() -> int:
|
||||
issue_title = issue["title"]
|
||||
issue_body = issue.get("body", "")
|
||||
|
||||
print(f"[agent_loop] Starting agent for issue #{issue_number}: {issue_title}")
|
||||
print(f"Starting agent for {_issue_url(issue_number)} {issue_title}")
|
||||
|
||||
# Mark InProgress before starting so the next cron tick sees it even if
|
||||
# the agent hasn't had time to do so yet.
|
||||
@@ -311,16 +597,34 @@ Instructions:
|
||||
- Write or update tests as appropriate.
|
||||
- Run 'task check' locally and fix any failures before committing.
|
||||
- Commit with a descriptive message referencing the issue number (e.g. "feat: ... (#{issue_number})").
|
||||
- Push to origin/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 push -u origin issue-{issue_number}-fix
|
||||
fgj pr create --title "fix: <short description> (#{issue_number})" \\
|
||||
--head issue-{issue_number}-fix --base main --repo {REPO}
|
||||
- Do NOT push to main, do NOT close the issue, and do NOT merge the PR — the loop handles that after CI passes.
|
||||
- If you hit a blocker you cannot resolve, set the issue label to State/Question
|
||||
and stop (do NOT close the issue).
|
||||
- When the work is done and pushed, close the issue and stop.
|
||||
- When the work is pushed and the PR is opened, stop. The loop will merge the PR and close the issue after CI passes.
|
||||
"""
|
||||
|
||||
pid = _start_agent(prompt, f"issue-{issue_number}")
|
||||
_write_state(pid, issue_number, "issue")
|
||||
session_name = f"issue-{issue_number}"
|
||||
pid = _start_agent(prompt, session_name)
|
||||
current_run_id = run["id"] if run else None
|
||||
_write_state(pid, issue_number, "issue", issue_title, session_name=session_name, ci_run_id=current_run_id)
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(prog="agent_loop")
|
||||
sub = parser.add_subparsers(dest="cmd")
|
||||
sub.add_parser("list", help="List recent agent sessions")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.cmd == "list":
|
||||
return cmd_list()
|
||||
return _run_loop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
||||
@@ -5,7 +5,14 @@ set -euo pipefail
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
|
||||
echo "check-mocks: regenerating..."
|
||||
fvm flutter pub run build_runner build --delete-conflicting-outputs 2>&1
|
||||
tmp=$(mktemp)
|
||||
trap 'rm -f "$tmp"' EXIT
|
||||
if fvm flutter pub run build_runner build --delete-conflicting-outputs >"$tmp" 2>&1; then
|
||||
grep -vE '^\[' "$tmp" || true
|
||||
else
|
||||
cat "$tmp"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CHANGED=$(git diff --name-only -- '*.mocks.dart')
|
||||
if [ -n "$CHANGED" ]; then
|
||||
|
||||
+21
-16
@@ -31,25 +31,28 @@ def _upload_aab(session: AuthorizedSession, edit_id: str) -> int:
|
||||
"""Resumable upload of the AAB. Returns the version code."""
|
||||
file_size = os.path.getsize(AAB_PATH)
|
||||
|
||||
init_resp = session.post(
|
||||
f"{_UPLOAD_BASE}/{PACKAGE_NAME}/edits/{edit_id}/bundles",
|
||||
params={"uploadType": "resumable"},
|
||||
headers={
|
||||
"X-Upload-Content-Type": "application/octet-stream",
|
||||
"X-Upload-Content-Length": str(file_size),
|
||||
},
|
||||
json={},
|
||||
timeout=30,
|
||||
)
|
||||
init_resp.raise_for_status()
|
||||
upload_url = init_resp.headers["Location"]
|
||||
|
||||
with open(AAB_PATH, "rb") as f:
|
||||
data = f.read()
|
||||
|
||||
last_exc = None
|
||||
for attempt in range(_MAX_UPLOAD_ATTEMPTS):
|
||||
try:
|
||||
# Each attempt needs a fresh resumable upload URL — the previous URL expires on failure.
|
||||
init_resp = session.post(
|
||||
f"{_UPLOAD_BASE}/{PACKAGE_NAME}/edits/{edit_id}/bundles",
|
||||
params={"uploadType": "resumable"},
|
||||
headers={
|
||||
"X-Upload-Content-Type": "application/octet-stream",
|
||||
"X-Upload-Content-Length": str(file_size),
|
||||
},
|
||||
json={},
|
||||
timeout=30,
|
||||
)
|
||||
if not init_resp.ok:
|
||||
print(f"Init attempt {attempt + 1} failed: HTTP {init_resp.status_code}: {init_resp.text[:500]}")
|
||||
init_resp.raise_for_status()
|
||||
upload_url = init_resp.headers["Location"]
|
||||
|
||||
upload_resp = session.put(
|
||||
upload_url,
|
||||
data=data,
|
||||
@@ -59,13 +62,15 @@ def _upload_aab(session: AuthorizedSession, edit_id: str) -> int:
|
||||
},
|
||||
timeout=_TIMEOUT,
|
||||
)
|
||||
upload_resp.raise_for_status()
|
||||
if not upload_resp.ok:
|
||||
print(f"Upload attempt {attempt + 1} failed: HTTP {upload_resp.status_code}: {upload_resp.text[:500]}")
|
||||
upload_resp.raise_for_status()
|
||||
return upload_resp.json()["versionCode"]
|
||||
except requests.HTTPError as exc:
|
||||
except requests.RequestException as exc:
|
||||
last_exc = exc
|
||||
if attempt < _MAX_UPLOAD_ATTEMPTS - 1:
|
||||
delay = 10 * (2 ** attempt)
|
||||
print(f"Upload attempt {attempt + 1} failed ({exc}), retrying in {delay}s…")
|
||||
print(f"Attempt {attempt + 1} failed ({exc}), retrying in {delay}s…")
|
||||
time.sleep(delay)
|
||||
|
||||
raise RuntimeError(
|
||||
|
||||
@@ -33,15 +33,23 @@ def list_remote_files(ssh_user: str, ssh_host: str, pattern: str) -> list[str]:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"ssh",
|
||||
"-o",
|
||||
"StrictHostKeyChecking=no",
|
||||
"-v",
|
||||
"-o", "StrictHostKeyChecking=no",
|
||||
"-i", "/root/.ssh/id_ed25519",
|
||||
f"{ssh_user}@{ssh_host}",
|
||||
f"find {REMOTE_BUILDS_DIR} -name '{pattern}' -type f | sort",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(
|
||||
f"WARNING: ssh exit {result.returncode} listing {pattern} on {ssh_user}@{ssh_host}"
|
||||
" — build history will be empty for this pattern",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(result.stderr, file=sys.stderr)
|
||||
return []
|
||||
return [line.strip() for line in result.stdout.splitlines() if line.strip()]
|
||||
|
||||
|
||||
|
||||
Executable
+54
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env bash
|
||||
# Runs the Firebase Test Lab Dagger pipeline with Gradle/Dagger noise filtered out.
|
||||
# Retries up to 3 times on transient Dagger engine connectivity errors.
|
||||
set -uo pipefail
|
||||
|
||||
OUT=$(mktemp)
|
||||
RC_FILE=$(mktemp)
|
||||
trap 'rm -f "$OUT" "$RC_FILE"' EXIT
|
||||
|
||||
_strip_ansi() {
|
||||
sed 's/\x1b\[[0-9;]*[mGKHFJ]//g'
|
||||
}
|
||||
|
||||
_filter_noise() {
|
||||
grep -vE \
|
||||
'> Task :.+(UP-TO-DATE|NO-SOURCE|SKIPPED)'\
|
||||
'|[0-9]+ files found for path '\''lib/'\
|
||||
'|^Inputs:'\
|
||||
'|^[[:space:]]+-[[:space:]]/'\
|
||||
'|\[Incubating\]'\
|
||||
'|Deprecated Gradle features'\
|
||||
'|warning-mode all'\
|
||||
'|please refer to https://docs\.gradle'\
|
||||
'|[0-9]+ actionable tasks'\
|
||||
'|^warning: \[options\]'\
|
||||
'|^Note: Some input files'\
|
||||
'|Starting a Gradle Daemon'\
|
||||
'|Have questions, feedback, or issues'\
|
||||
'|https://firebase\.google\.com/support'\
|
||||
'|^\s*[┆│]\s*$' \
|
||||
|| true
|
||||
}
|
||||
|
||||
_run() {
|
||||
: > "$OUT" ; : > "$RC_FILE"
|
||||
{
|
||||
dagger call --progress=plain -q -m ci --source=. test-android-firebase \
|
||||
--service-account-key env:FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY \
|
||||
--project-id "$FIREBASE_PROJECT_ID"
|
||||
echo $? > "$RC_FILE"
|
||||
} 2>&1 | tee "$OUT" | _strip_ansi | _filter_noise
|
||||
}
|
||||
|
||||
for attempt in 1 2 3; do
|
||||
_run && break
|
||||
RC=$(cat "$RC_FILE" 2>/dev/null || echo 1)
|
||||
if [ "$attempt" -lt 3 ] && grep -qE "connection reset|context canceled|connection refused|No Dagger server responded" "$OUT"; then
|
||||
echo "[firebase] dagger connectivity error on attempt $attempt/3, retrying..." >&2
|
||||
else
|
||||
exit "$RC"
|
||||
fi
|
||||
done
|
||||
|
||||
exit "$(cat "$RC_FILE" 2>/dev/null || echo 0)"
|
||||
Executable
+74
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env bash
|
||||
# Establishes a secure tunnel to a remote Dagger Engine via stunnel.
|
||||
set -euo pipefail
|
||||
|
||||
if [ -z "${DAGGER_STUNNEL_URL:-}" ]; then
|
||||
echo "Error: DAGGER_STUNNEL_URL must be set."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Parse host and port (e.g., example.com:8774 or just example.com)
|
||||
host=$(echo "$DAGGER_STUNNEL_URL" | cut -d: -f1)
|
||||
port=$(echo "$DAGGER_STUNNEL_URL" | cut -d: -f2)
|
||||
if [ "$host" == "$port" ]; then
|
||||
port="8774"
|
||||
fi
|
||||
|
||||
echo "Probing $host:$port..."
|
||||
if ! nc -zw 3 "$host" "$port" 2>/dev/null; then
|
||||
echo "Error: No Dagger server responded on $host:$port"
|
||||
exit 1
|
||||
fi
|
||||
echo "Found active Dagger server on $host:$port"
|
||||
|
||||
# 2. Setup TLS credentials (passed as env vars from secrets)
|
||||
mkdir -p /tmp/dagger-tls
|
||||
echo "$DAGGER_CA_CERT" > /tmp/dagger-tls/ca.crt
|
||||
echo "$DAGGER_CLIENT_CERT" > /tmp/dagger-tls/client.crt
|
||||
echo "$DAGGER_CLIENT_KEY" > /tmp/dagger-tls/client.key
|
||||
chmod 600 /tmp/dagger-tls/client.key
|
||||
|
||||
# 3. Configure and start stunnel
|
||||
STUNNEL_CONF="/tmp/stunnel-dagger.conf"
|
||||
cat << EOF > "$STUNNEL_CONF"
|
||||
client = yes
|
||||
foreground = yes
|
||||
pid = /tmp/stunnel.pid
|
||||
debug = warning
|
||||
; TCP keepalive on the remote side to prevent NAT/firewall from resetting the connection
|
||||
socket = r:SO_KEEPALIVE=1
|
||||
socket = r:TCP_KEEPIDLE=10
|
||||
socket = r:TCP_KEEPINTVL=5
|
||||
socket = r:TCP_KEEPCNT=3
|
||||
|
||||
[dagger]
|
||||
accept = 127.0.0.1:1774
|
||||
connect = $host:$port
|
||||
CAfile = /tmp/dagger-tls/ca.crt
|
||||
cert = /tmp/dagger-tls/client.crt
|
||||
key = /tmp/dagger-tls/client.key
|
||||
verifyChain = yes
|
||||
EOF
|
||||
|
||||
# Start stunnel in the background
|
||||
stunnel "$STUNNEL_CONF" &
|
||||
TUNNEL_PID=$!
|
||||
|
||||
# Give it a moment to establish
|
||||
sleep 2
|
||||
|
||||
if ! kill -0 "$TUNNEL_PID" 2>/dev/null; then
|
||||
echo "Error: stunnel failed to start"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 4. Export environment for subsequent CI steps
|
||||
if [ -n "${GITHUB_ENV:-}" ]; then
|
||||
echo "_EXPERIMENTAL_DAGGER_RUNNER_HOST=tcp://127.0.0.1:1774" >> "$GITHUB_ENV"
|
||||
echo "_DAGGER_RUNNER_HOST=tcp://127.0.0.1:1774" >> "$GITHUB_ENV"
|
||||
echo "Tunnel established. Dagger is configured to use the remote engine."
|
||||
else
|
||||
export _EXPERIMENTAL_DAGGER_RUNNER_HOST=tcp://127.0.0.1:1774
|
||||
export _DAGGER_RUNNER_HOST=tcp://127.0.0.1:1774
|
||||
echo "Tunnel established. Run: export _DAGGER_RUNNER_HOST=tcp://127.0.0.1:1774"
|
||||
fi
|
||||
+422
-3
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for agent_loop.py."""
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
@@ -14,6 +15,16 @@ sys.path.insert(0, str(Path(__file__).parent))
|
||||
import agent_loop
|
||||
|
||||
|
||||
class TestUrlHelpers(unittest.TestCase):
|
||||
def test_issue_url(self):
|
||||
url = agent_loop._issue_url(128)
|
||||
self.assertEqual(url, "https://codeberg.org/guettli/sharedinbox/issues/128")
|
||||
|
||||
def test_ci_run_url(self):
|
||||
url = agent_loop._ci_run_url(4145144)
|
||||
self.assertEqual(url, "https://codeberg.org/guettli/sharedinbox/actions/runs/4145144")
|
||||
|
||||
|
||||
class TestStateFile(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".json")
|
||||
@@ -54,6 +65,16 @@ class TestStateFile(unittest.TestCase):
|
||||
agent_loop._clear_state()
|
||||
self.assertIsNone(agent_loop._read_state())
|
||||
|
||||
def test_write_state_stores_issue_title(self):
|
||||
agent_loop._write_state(42, 10, "issue", "My Test Issue")
|
||||
data = json.loads(Path(self._tmp.name).read_text())
|
||||
self.assertEqual(data["issue_title"], "My Test Issue")
|
||||
|
||||
def test_write_state_omits_issue_title_when_none(self):
|
||||
agent_loop._write_state(42, None, "ci-fix")
|
||||
data = json.loads(Path(self._tmp.name).read_text())
|
||||
self.assertNotIn("issue_title", data)
|
||||
|
||||
|
||||
class TestAgentAlive(unittest.TestCase):
|
||||
def test_own_pid_is_alive(self):
|
||||
@@ -158,7 +179,7 @@ class TestMain(unittest.TestCase):
|
||||
patch("agent_loop._set_labels", side_effect=fake_set_labels), \
|
||||
patch("agent_loop._start_agent", side_effect=fake_start_agent), \
|
||||
patch("agent_loop._write_state"):
|
||||
result = agent_loop.main()
|
||||
result = agent_loop._run_loop()
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
labels_idx = next(
|
||||
@@ -184,7 +205,7 @@ class TestMain(unittest.TestCase):
|
||||
patch("agent_loop._set_labels", side_effect=fake_set_labels), \
|
||||
patch("agent_loop._start_agent", return_value=99), \
|
||||
patch("agent_loop._write_state"):
|
||||
agent_loop.main()
|
||||
agent_loop._run_loop()
|
||||
|
||||
self.assertIn(agent_loop.LABEL_IN_PROGRESS, captured.get("add", []))
|
||||
self.assertIn(agent_loop.LABEL_READY, captured.get("remove", []))
|
||||
@@ -196,12 +217,410 @@ class TestMain(unittest.TestCase):
|
||||
patch("agent_loop._ready_issues", return_value=[]), \
|
||||
patch("agent_loop._set_labels") as mock_labels, \
|
||||
patch("agent_loop._start_agent") as mock_start:
|
||||
result = agent_loop.main()
|
||||
result = agent_loop._run_loop()
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
mock_labels.assert_not_called()
|
||||
mock_start.assert_not_called()
|
||||
|
||||
def test_prompt_does_not_tell_agent_to_close_issue(self):
|
||||
"""Agents must not close issues; the loop handles closing after CI passes."""
|
||||
captured_prompt = {}
|
||||
|
||||
def fake_start_agent(prompt, session_name):
|
||||
captured_prompt["prompt"] = prompt
|
||||
return 77
|
||||
|
||||
with patch("agent_loop._read_state", return_value=None), \
|
||||
patch("agent_loop._latest_ci_run", return_value=None), \
|
||||
patch("agent_loop._ready_issues", return_value=[self._make_issue(42)]), \
|
||||
patch("agent_loop._set_labels"), \
|
||||
patch("agent_loop._start_agent", side_effect=fake_start_agent), \
|
||||
patch("agent_loop._write_state"):
|
||||
agent_loop._run_loop()
|
||||
|
||||
prompt = captured_prompt.get("prompt", "")
|
||||
# "do NOT close the issue" (blocker instruction) is fine; what must be
|
||||
# absent is any affirmative instruction to close on completion.
|
||||
self.assertNotIn("close the issue and stop", prompt.lower())
|
||||
|
||||
|
||||
class TestPendingCi(unittest.TestCase):
|
||||
"""Tests for the pending-CI state: issue closed only after CI passes."""
|
||||
|
||||
def _dead_state(self, issue: int, kind: str = "issue") -> dict:
|
||||
return {
|
||||
"pid": 999999999, # non-existent PID
|
||||
"issue": issue,
|
||||
"started_at": "2026-01-01T00:00:00+00:00",
|
||||
"type": kind,
|
||||
}
|
||||
|
||||
def test_closes_issue_when_ci_passes_after_agent_finishes(self):
|
||||
"""After issue agent finishes, loop closes the issue once CI is green."""
|
||||
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
|
||||
patch("agent_loop._latest_ci_run", return_value={"id": 1, "status": "success"}), \
|
||||
patch("agent_loop._close_issue") as mock_close, \
|
||||
patch("agent_loop._clear_state"):
|
||||
result = agent_loop._run_loop()
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
mock_close.assert_called_once_with(10)
|
||||
|
||||
def test_ci_passed_output_includes_ci_run_url(self):
|
||||
"""'CI passed' line includes the CI run URL when a run is available."""
|
||||
buf = io.StringIO()
|
||||
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
|
||||
patch("agent_loop._latest_ci_run", return_value={"id": 4145144, "status": "success"}), \
|
||||
patch("agent_loop._close_issue"), \
|
||||
patch("agent_loop._clear_state"), \
|
||||
contextlib.redirect_stdout(buf):
|
||||
agent_loop._run_loop()
|
||||
output = buf.getvalue()
|
||||
self.assertIn("https://codeberg.org/guettli/sharedinbox/actions/runs/4145144", output)
|
||||
self.assertIn("https://codeberg.org/guettli/sharedinbox/issues/10", output)
|
||||
|
||||
def test_ci_passed_output_without_run_omits_ci_url(self):
|
||||
"""'CI passed' line still works when no CI run is available."""
|
||||
buf = io.StringIO()
|
||||
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
|
||||
patch("agent_loop._latest_ci_run", return_value=None), \
|
||||
patch("agent_loop._close_issue"), \
|
||||
patch("agent_loop._clear_state"), \
|
||||
contextlib.redirect_stdout(buf):
|
||||
agent_loop._run_loop()
|
||||
output = buf.getvalue()
|
||||
self.assertIn("CI passed", output)
|
||||
self.assertIn("https://codeberg.org/guettli/sharedinbox/issues/10", output)
|
||||
self.assertNotIn("/actions/runs/", output)
|
||||
|
||||
def test_does_not_close_issue_when_ci_fails(self):
|
||||
"""After issue agent finishes, loop must NOT close the issue if CI failed."""
|
||||
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
|
||||
patch("agent_loop._latest_ci_run", return_value={"id": 1, "status": "failure"}), \
|
||||
patch("agent_loop._close_issue") as mock_close, \
|
||||
patch("agent_loop._start_agent", return_value=55), \
|
||||
patch("agent_loop._write_state"), \
|
||||
patch("agent_loop._clear_state"):
|
||||
result = agent_loop._run_loop()
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
mock_close.assert_not_called()
|
||||
|
||||
def test_saves_pending_ci_state_while_ci_running(self):
|
||||
"""When CI is still running after agent finishes, pending issue is preserved."""
|
||||
written = {}
|
||||
|
||||
def fake_write_state(pid, issue, kind, issue_title=None, session_name=None, ci_run_id=None):
|
||||
written["pid"] = pid
|
||||
written["issue"] = issue
|
||||
written["kind"] = kind
|
||||
|
||||
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
|
||||
patch("agent_loop._latest_ci_run", return_value={"id": 1, "status": "running"}), \
|
||||
patch("agent_loop._write_state", side_effect=fake_write_state), \
|
||||
patch("agent_loop._clear_state"):
|
||||
result = agent_loop._run_loop()
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(written.get("issue"), 10)
|
||||
self.assertEqual(written.get("kind"), "pending-ci")
|
||||
self.assertIsNone(written.get("pid"))
|
||||
|
||||
def test_ci_fix_preserves_pending_issue_in_state(self):
|
||||
"""When CI fails after agent finishes, ci-fix state includes the pending issue."""
|
||||
written = {}
|
||||
|
||||
def fake_write_state(pid, issue, kind, issue_title=None, session_name=None, ci_run_id=None):
|
||||
written["pid"] = pid
|
||||
written["issue"] = issue
|
||||
written["kind"] = kind
|
||||
|
||||
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
|
||||
patch("agent_loop._latest_ci_run", return_value={"id": 1, "status": "failure"}), \
|
||||
patch("agent_loop._start_agent", return_value=55), \
|
||||
patch("agent_loop._write_state", side_effect=fake_write_state), \
|
||||
patch("agent_loop._clear_state"):
|
||||
result = agent_loop._run_loop()
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(written.get("issue"), 10)
|
||||
self.assertEqual(written.get("kind"), "ci-fix")
|
||||
|
||||
def test_closes_issue_after_ci_fix_and_ci_passes(self):
|
||||
"""After ci-fix agent finishes and CI passes, the pending issue is closed."""
|
||||
with patch("agent_loop._read_state", return_value=self._dead_state(10, "ci-fix")), \
|
||||
patch("agent_loop._latest_ci_run", return_value={"id": 1, "status": "success"}), \
|
||||
patch("agent_loop._close_issue") as mock_close, \
|
||||
patch("agent_loop._clear_state"):
|
||||
result = agent_loop._run_loop()
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
mock_close.assert_called_once_with(10)
|
||||
|
||||
def test_no_pending_issue_ci_fix_without_issue(self):
|
||||
"""ci-fix for a manual push (no pending issue) does not try to close anything."""
|
||||
with patch("agent_loop._read_state", return_value={
|
||||
"pid": 999999999, "issue": None, "started_at": "2026-01-01T00:00:00+00:00",
|
||||
"type": "ci-fix",
|
||||
}), \
|
||||
patch("agent_loop._latest_ci_run", return_value={"id": 1, "status": "success"}), \
|
||||
patch("agent_loop._close_issue") as mock_close, \
|
||||
patch("agent_loop._ready_issues", return_value=[]), \
|
||||
patch("agent_loop._clear_state"):
|
||||
result = agent_loop._run_loop()
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
mock_close.assert_not_called()
|
||||
|
||||
|
||||
class TestOutputFormat(unittest.TestCase):
|
||||
"""Verify output format: no [agent_loop] prefix, URLs in output."""
|
||||
|
||||
def test_output_starts_with_header(self):
|
||||
buf = io.StringIO()
|
||||
with patch("agent_loop._read_state", return_value=None), \
|
||||
patch("agent_loop._latest_ci_run", return_value=None), \
|
||||
patch("agent_loop._ready_issues", return_value=[]), \
|
||||
contextlib.redirect_stdout(buf):
|
||||
agent_loop._run_loop()
|
||||
first_line = buf.getvalue().splitlines()[0]
|
||||
self.assertTrue(first_line.startswith("---------------------- Starting "),
|
||||
f"Unexpected first line: {first_line!r}")
|
||||
|
||||
def test_no_agent_loop_prefix_in_output(self):
|
||||
buf = io.StringIO()
|
||||
with patch("agent_loop._read_state", return_value=None), \
|
||||
patch("agent_loop._latest_ci_run", return_value=None), \
|
||||
patch("agent_loop._ready_issues", return_value=[]), \
|
||||
contextlib.redirect_stdout(buf):
|
||||
agent_loop._run_loop()
|
||||
self.assertNotIn("[agent_loop]", buf.getvalue())
|
||||
|
||||
def test_ci_run_output_contains_url(self):
|
||||
run = {"id": 4145144, "status": "running"}
|
||||
buf = io.StringIO()
|
||||
with patch("agent_loop._read_state", return_value=None), \
|
||||
patch("agent_loop._latest_ci_run", return_value=run), \
|
||||
contextlib.redirect_stdout(buf):
|
||||
agent_loop._run_loop()
|
||||
self.assertIn("https://codeberg.org/guettli/sharedinbox/actions/runs/4145144",
|
||||
buf.getvalue())
|
||||
|
||||
def test_issue_output_contains_url_and_title(self):
|
||||
issue = {"number": 128, "title": "Fix something", "body": "", "labels": []}
|
||||
buf = io.StringIO()
|
||||
with patch("agent_loop._read_state", return_value=None), \
|
||||
patch("agent_loop._latest_ci_run", return_value=None), \
|
||||
patch("agent_loop._ready_issues", return_value=[issue]), \
|
||||
patch("agent_loop._set_labels"), \
|
||||
patch("agent_loop._start_agent", return_value=99), \
|
||||
patch("agent_loop._write_state"), \
|
||||
contextlib.redirect_stdout(buf):
|
||||
agent_loop._run_loop()
|
||||
output = buf.getvalue()
|
||||
self.assertIn("https://codeberg.org/guettli/sharedinbox/issues/128", output)
|
||||
self.assertIn("Fix something", output)
|
||||
|
||||
|
||||
class TestLatestCiRunForBranch(unittest.TestCase):
|
||||
"""Tests for _latest_ci_run_for_branch — Forgejo API field mapping."""
|
||||
|
||||
def _make_pr_run(self, branch: str, status: str = "success") -> dict:
|
||||
payload = json.dumps({"pull_request": {"head": {"ref": branch}}})
|
||||
return {"event": "pull_request", "event_payload": payload, "status": status, "id": 1}
|
||||
|
||||
def _make_push_run(self, prettyref: str, status: str = "success") -> dict:
|
||||
return {"event": "push", "prettyref": prettyref, "status": status, "id": 2}
|
||||
|
||||
def _mock_tea_runs(self, runs):
|
||||
with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}) as m:
|
||||
yield m
|
||||
|
||||
def test_pr_event_matches_via_event_payload(self):
|
||||
run = self._make_pr_run("issue-166-fix")
|
||||
with patch("agent_loop._tea_get", return_value={"workflow_runs": [run]}):
|
||||
result = agent_loop._latest_ci_run_for_branch("issue-166-fix")
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["id"], 1)
|
||||
|
||||
def test_pr_event_does_not_match_wrong_branch(self):
|
||||
run = self._make_pr_run("issue-99-fix")
|
||||
with patch("agent_loop._tea_get", return_value={"workflow_runs": [run]}):
|
||||
result = agent_loop._latest_ci_run_for_branch("issue-166-fix")
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_push_event_matches_via_prettyref(self):
|
||||
run = self._make_push_run("issue-166-fix")
|
||||
with patch("agent_loop._tea_get", return_value={"workflow_runs": [run]}):
|
||||
result = agent_loop._latest_ci_run_for_branch("issue-166-fix")
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["id"], 2)
|
||||
|
||||
def test_push_event_prettyref_pr_number_does_not_match_branch(self):
|
||||
# Forgejo sets prettyref="#169" for PR runs — must not match branch name.
|
||||
run = {"event": "push", "prettyref": "#169", "status": "success", "id": 3}
|
||||
with patch("agent_loop._tea_get", return_value={"workflow_runs": [run]}):
|
||||
result = agent_loop._latest_ci_run_for_branch("issue-166-fix")
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_head_branch_field_absent_still_works(self):
|
||||
# Regression: the old code used run.get("head_branch") which is absent in Forgejo.
|
||||
run = self._make_pr_run("issue-166-fix")
|
||||
self.assertNotIn("head_branch", run)
|
||||
with patch("agent_loop._tea_get", return_value={"workflow_runs": [run]}):
|
||||
result = agent_loop._latest_ci_run_for_branch("issue-166-fix")
|
||||
self.assertIsNotNone(result)
|
||||
|
||||
def test_returns_none_when_no_runs(self):
|
||||
with patch("agent_loop._tea_get", return_value={"workflow_runs": []}):
|
||||
result = agent_loop._latest_ci_run_for_branch("issue-166-fix")
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_returns_first_matching_run(self):
|
||||
runs = [
|
||||
self._make_pr_run("issue-166-fix", status="success"),
|
||||
self._make_pr_run("issue-166-fix", status="failure"),
|
||||
]
|
||||
runs[0]["id"] = 10
|
||||
runs[1]["id"] = 11
|
||||
with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}):
|
||||
result = agent_loop._latest_ci_run_for_branch("issue-166-fix")
|
||||
self.assertEqual(result["id"], 10)
|
||||
|
||||
|
||||
class TestFindSessionUuid(unittest.TestCase):
|
||||
"""Tests for _find_session_uuid()."""
|
||||
|
||||
def _write_jsonl(self, directory: Path, filename: str, entries: list) -> Path:
|
||||
path = directory / filename
|
||||
with path.open("w") as fh:
|
||||
for entry in entries:
|
||||
fh.write(json.dumps(entry) + "\n")
|
||||
return path
|
||||
|
||||
def test_returns_uuid_for_matching_session_name(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
projects_dir = Path(tmpdir)
|
||||
self._write_jsonl(projects_dir, "abc123.jsonl", [
|
||||
{"type": "agent-name", "agentName": "issue-91", "sessionId": "uuid-abc-123"},
|
||||
])
|
||||
orig = agent_loop.CLAUDE_PROJECTS_DIR
|
||||
agent_loop.CLAUDE_PROJECTS_DIR = projects_dir
|
||||
try:
|
||||
result = agent_loop._find_session_uuid("issue-91")
|
||||
finally:
|
||||
agent_loop.CLAUDE_PROJECTS_DIR = orig
|
||||
self.assertEqual(result, "uuid-abc-123")
|
||||
|
||||
def test_returns_none_when_name_does_not_match(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
projects_dir = Path(tmpdir)
|
||||
self._write_jsonl(projects_dir, "abc123.jsonl", [
|
||||
{"type": "agent-name", "agentName": "issue-99", "sessionId": "uuid-abc-123"},
|
||||
])
|
||||
orig = agent_loop.CLAUDE_PROJECTS_DIR
|
||||
agent_loop.CLAUDE_PROJECTS_DIR = projects_dir
|
||||
try:
|
||||
result = agent_loop._find_session_uuid("issue-91")
|
||||
finally:
|
||||
agent_loop.CLAUDE_PROJECTS_DIR = orig
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_returns_none_when_directory_missing(self):
|
||||
orig = agent_loop.CLAUDE_PROJECTS_DIR
|
||||
agent_loop.CLAUDE_PROJECTS_DIR = Path("/nonexistent/path/that/does/not/exist")
|
||||
try:
|
||||
result = agent_loop._find_session_uuid("issue-91")
|
||||
finally:
|
||||
agent_loop.CLAUDE_PROJECTS_DIR = orig
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_returns_none_when_no_agent_name_entry(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
projects_dir = Path(tmpdir)
|
||||
self._write_jsonl(projects_dir, "abc123.jsonl", [
|
||||
{"type": "message", "content": "hello"},
|
||||
])
|
||||
orig = agent_loop.CLAUDE_PROJECTS_DIR
|
||||
agent_loop.CLAUDE_PROJECTS_DIR = projects_dir
|
||||
try:
|
||||
result = agent_loop._find_session_uuid("issue-91")
|
||||
finally:
|
||||
agent_loop.CLAUDE_PROJECTS_DIR = orig
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_scans_multiple_files_to_find_match(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
projects_dir = Path(tmpdir)
|
||||
self._write_jsonl(projects_dir, "aaa.jsonl", [
|
||||
{"type": "agent-name", "agentName": "issue-10", "sessionId": "uuid-10"},
|
||||
])
|
||||
self._write_jsonl(projects_dir, "bbb.jsonl", [
|
||||
{"type": "agent-name", "agentName": "issue-91", "sessionId": "uuid-91"},
|
||||
])
|
||||
orig = agent_loop.CLAUDE_PROJECTS_DIR
|
||||
agent_loop.CLAUDE_PROJECTS_DIR = projects_dir
|
||||
try:
|
||||
result = agent_loop._find_session_uuid("issue-91")
|
||||
finally:
|
||||
agent_loop.CLAUDE_PROJECTS_DIR = orig
|
||||
self.assertEqual(result, "uuid-91")
|
||||
|
||||
|
||||
class TestRunLoopResumeCommand(unittest.TestCase):
|
||||
"""Tests that _run_loop() shows a UUID-based resume command when agent is running."""
|
||||
|
||||
def _alive_state(self, session_name="issue-91"):
|
||||
return {
|
||||
"pid": os.getpid(), # own PID is always alive
|
||||
"issue": 91,
|
||||
"started_at": "2026-05-23T12:00:00+00:00",
|
||||
"type": "issue",
|
||||
"session_name": session_name,
|
||||
}
|
||||
|
||||
def test_resume_shows_uuid_when_found(self):
|
||||
buf = io.StringIO()
|
||||
fake_uuid = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
|
||||
with patch("agent_loop._read_state", return_value=self._alive_state()), \
|
||||
patch("agent_loop._agent_alive", return_value=True), \
|
||||
patch("agent_loop._agent_age_seconds", return_value=600), \
|
||||
patch("agent_loop._find_session_uuid", return_value=fake_uuid), \
|
||||
patch("agent_loop._git_summary", return_value=""), \
|
||||
contextlib.redirect_stdout(buf):
|
||||
agent_loop._run_loop()
|
||||
output = buf.getvalue()
|
||||
self.assertIn(f"claude --resume {fake_uuid}", output)
|
||||
|
||||
def test_resume_shows_list_hint_when_uuid_not_found(self):
|
||||
buf = io.StringIO()
|
||||
with patch("agent_loop._read_state", return_value=self._alive_state()), \
|
||||
patch("agent_loop._agent_alive", return_value=True), \
|
||||
patch("agent_loop._agent_age_seconds", return_value=600), \
|
||||
patch("agent_loop._find_session_uuid", return_value=None), \
|
||||
patch("agent_loop._git_summary", return_value=""), \
|
||||
contextlib.redirect_stdout(buf):
|
||||
agent_loop._run_loop()
|
||||
output = buf.getvalue()
|
||||
self.assertIn("scripts/agent_loop.py list", output)
|
||||
# Must NOT show the session name as a valid resume argument.
|
||||
self.assertNotIn("claude --resume issue-91", output)
|
||||
|
||||
def test_resume_not_shown_when_no_session_name(self):
|
||||
state = self._alive_state()
|
||||
del state["session_name"]
|
||||
buf = io.StringIO()
|
||||
with patch("agent_loop._read_state", return_value=state), \
|
||||
patch("agent_loop._agent_alive", return_value=True), \
|
||||
patch("agent_loop._agent_age_seconds", return_value=600), \
|
||||
patch("agent_loop._find_session_uuid", return_value=None), \
|
||||
patch("agent_loop._git_summary", return_value=""), \
|
||||
contextlib.redirect_stdout(buf):
|
||||
agent_loop._run_loop()
|
||||
output = buf.getvalue()
|
||||
self.assertNotIn("Resume:", output)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Executable
+89
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env bash
|
||||
# Tests for Firebase CI check patterns used in ci/main.go.
|
||||
# Run directly: bash scripts/test_firebase_check.sh
|
||||
|
||||
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: '$expected'"
|
||||
echo " actual: '$actual'"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
# --- auth stderr filter ---
|
||||
# Lines ignored: "Activated service account credentials for: [...]"
|
||||
# "Updated property [core/project]."
|
||||
_filter_auth() {
|
||||
grep -vF "Activated service account credentials for:" \
|
||||
| grep -vF "Updated property [core/project]." \
|
||||
| grep -v "^$" \
|
||||
|| true
|
||||
}
|
||||
|
||||
_assert "auth: both known messages produce empty output" "" \
|
||||
"$(printf 'Activated service account credentials for: [ci@sa.iam.gserviceaccount.com]\nUpdated property [core/project].\n' | _filter_auth)"
|
||||
|
||||
_assert "auth: only credentials line produces empty output" "" \
|
||||
"$(printf 'Activated service account credentials for: [ci@sa.iam.gserviceaccount.com]\n' | _filter_auth)"
|
||||
|
||||
_assert "auth: only property line produces empty output" "" \
|
||||
"$(printf 'Updated property [core/project].\n' | _filter_auth)"
|
||||
|
||||
_assert "auth: empty input produces empty output" "" \
|
||||
"$(printf '' | _filter_auth)"
|
||||
|
||||
_assert "auth: unexpected line passes through" "some unexpected error" \
|
||||
"$(printf 'some unexpected error\n' | _filter_auth)"
|
||||
|
||||
_assert "auth: unknown line kept alongside known messages" "unexpected line" \
|
||||
"$(printf 'Activated service account credentials for: [x]\nunexpected line\nUpdated property [core/project].\n' | _filter_auth)"
|
||||
|
||||
# --- "error" word detection: grep -qwi 'error' ---
|
||||
# Matches "error" as a whole word (case-insensitive).
|
||||
# Must NOT match "error" as part of another word (e.g. "stderr", "AssertionError").
|
||||
_has_err() { printf '%s\n' "$1" | grep -qwi 'error' && echo yes || echo no; }
|
||||
|
||||
_assert "error: non-retryable error line matched" yes "$(_has_err 'A non-retryable error occurred.')"
|
||||
_assert "error: uppercase ERROR matched" yes "$(_has_err 'ERROR: infrastructure_failure')"
|
||||
_assert "error: mixed-case Error matched" yes "$(_has_err 'Error: something went wrong')"
|
||||
_assert "error: normal pending line not matched" no "$(_has_err 'Test is Pending')"
|
||||
_assert "error: timing line not matched" no "$(_has_err 'Done. Test time = 183 (secs)')"
|
||||
_assert "error: completion line not matched" no "$(_has_err 'Instrumentation testing complete.')"
|
||||
_assert "error: 'stderr' word not matched" no "$(_has_err 'some stderr: gcloud output')"
|
||||
_assert "error: 'AssertionError' not matched" no "$(_has_err 'java.lang.AssertionError: expected true')"
|
||||
|
||||
# --- device count from result table ---
|
||||
# Counts data rows by looking for lines with "│" that contain an outcome word.
|
||||
TABLE_PASS="┌─────────┬───────────────────────┬──────────────┐
|
||||
│ OUTCOME │ TEST_AXIS_VALUE │ TEST_DETAILS │
|
||||
├─────────┼───────────────────────┼──────────────┤
|
||||
│ Passed │ oriole-33-en-portrait │ -- │
|
||||
└─────────┴───────────────────────┴──────────────┘"
|
||||
|
||||
TABLE_FAIL="┌─────────┬───────────────────────┬──────────────┐
|
||||
│ OUTCOME │ TEST_AXIS_VALUE │ TEST_DETAILS │
|
||||
├─────────┼───────────────────────┼──────────────┤
|
||||
│ Failed │ oriole-33-en-portrait │ -- │
|
||||
└─────────┴───────────────────────┴──────────────┘"
|
||||
|
||||
_count() {
|
||||
local n
|
||||
n=$(printf '%s' "$1" | grep "│" | grep -cE "(Passed|Failed|Inconclusive|Skipped)") || n=0
|
||||
printf '%s' "$n"
|
||||
}
|
||||
|
||||
_assert "count: one passing device gives 1" 1 "$(_count "$TABLE_PASS")"
|
||||
_assert "count: one failing device gives 1" 1 "$(_count "$TABLE_FAIL")"
|
||||
_assert "count: no table gives 0" 0 "$(_count 'Test is Pending\nDone.')"
|
||||
_assert "count: plain output gives 0" 0 "$(_count 'Instrumentation testing complete.')"
|
||||
|
||||
echo ""
|
||||
echo "Results: $PASS passed, $FAIL failed"
|
||||
[ "$FAIL" -eq 0 ] || exit 1
|
||||
@@ -1,10 +1,5 @@
|
||||
# Minimal Stalwart Mail configuration for local development and integration tests.
|
||||
#
|
||||
# Do not start directly — use stalwart-dev/start, which substitutes $STALWART_PORT
|
||||
# and writes a per-clone config into /tmp/stalwart-dev-PORT/ before starting.
|
||||
#
|
||||
# Check: curl http://localhost:$STALWART_PORT/.well-known/jmap
|
||||
#
|
||||
# HTTP only — localhost testing, no TLS.
|
||||
# Two test accounts (alice, bob) for multi-account sync tests.
|
||||
|
||||
@@ -13,27 +8,27 @@ hostname = "localhost"
|
||||
|
||||
[[server.listener]]
|
||||
id = "jmap"
|
||||
bind = ["127.0.0.1:8080"]
|
||||
bind = ["0.0.0.0:8080"]
|
||||
protocol = "http"
|
||||
|
||||
[[server.listener]]
|
||||
id = "imap"
|
||||
bind = ["127.0.0.1:1430"]
|
||||
bind = ["0.0.0.0:1430"]
|
||||
protocol = "imap"
|
||||
|
||||
[[server.listener]]
|
||||
id = "smtp"
|
||||
bind = ["127.0.0.1:1025"]
|
||||
bind = ["0.0.0.0:1025"]
|
||||
protocol = "smtp"
|
||||
|
||||
[[server.listener]]
|
||||
id = "managesieve"
|
||||
bind = ["127.0.0.1:4190"]
|
||||
bind = ["0.0.0.0:4190"]
|
||||
protocol = "managesieve"
|
||||
|
||||
[store."db"]
|
||||
type = "sqlite"
|
||||
path = "/tmp/stalwart-dev/data.sqlite"
|
||||
path = "/tmp/stalwart/data.sqlite"
|
||||
|
||||
[storage]
|
||||
data = "db"
|
||||
|
||||
+22
-19
@@ -6,16 +6,6 @@
|
||||
# STALWART_TMPDIR/ports.env for other scripts to source.
|
||||
set -euo pipefail
|
||||
|
||||
command -v stalwart >/dev/null || {
|
||||
echo "stalwart not in PATH — run inside nix develop"
|
||||
exit 1
|
||||
}
|
||||
|
||||
command -v ss >/dev/null || {
|
||||
echo "ss not in PATH — cannot verify Stalwart ports"
|
||||
exit 1
|
||||
}
|
||||
|
||||
if [ "${STALWART_RANDOM_PORTS:-0}" = "1" ] || [ "${STALWART_PORT:-0}" = "0" ]; then
|
||||
command -v python3 >/dev/null || {
|
||||
echo "python3 not in PATH — cannot choose random Stalwart ports"
|
||||
@@ -61,17 +51,30 @@ export STALWART_SIEVE_PORT=${STALWART_SIEVE_PORT}
|
||||
export STALWART_URL=${STALWART_URL}
|
||||
EOF
|
||||
|
||||
# Find a container runtime
|
||||
if command -v podman >/dev/null 2>&1; then
|
||||
RUNTIME="podman"
|
||||
elif command -v docker >/dev/null 2>&1; then
|
||||
RUNTIME="docker"
|
||||
else
|
||||
echo "No container runtime (podman or docker) found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Stalwart ports: JMAP=${STALWART_PORT} IMAP=${STALWART_IMAP_PORT} SMTP=${STALWART_SMTP_PORT} SIEVE=${STALWART_SIEVE_PORT}" >&2
|
||||
echo "Stalwart is running in the foreground. Press Ctrl+C to stop." >&2
|
||||
echo "Stalwart is running in a container (${RUNTIME}). Press Ctrl+C to stop." >&2
|
||||
echo "Connection info written to ${TMPDIR}/ports.env" >&2
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
|
||||
sed -e "s|127.0.0.1:8080|127.0.0.1:${STALWART_PORT}|" \
|
||||
-e "s|127.0.0.1:1430|127.0.0.1:${STALWART_IMAP_PORT}|" \
|
||||
-e "s|127.0.0.1:1025|127.0.0.1:${STALWART_SMTP_PORT}|" \
|
||||
-e "s|127.0.0.1:4190|127.0.0.1:${STALWART_SIEVE_PORT}|" \
|
||||
-e "s|/tmp/stalwart-dev|${TMPDIR}|" \
|
||||
"${REPO_ROOT}/stalwart-dev/config.toml" >"${TMPDIR}/config.toml"
|
||||
|
||||
exec stalwart --config "${TMPDIR}/config.toml"
|
||||
# Run Stalwart in container, mapping the random host ports to the fixed container ports.
|
||||
# We mount the config.toml and use /tmp/stalwart for data (mapped to our local TMPDIR).
|
||||
exec "${RUNTIME}" run --rm -i \
|
||||
-p "${STALWART_PORT}:8080" \
|
||||
-p "${STALWART_IMAP_PORT}:1430" \
|
||||
-p "${STALWART_SMTP_PORT}:1025" \
|
||||
-p "${STALWART_SIEVE_PORT}:4190" \
|
||||
-v "${REPO_ROOT}/stalwart-dev/config.toml:/etc/stalwart/config.toml:ro" \
|
||||
-v "${TMPDIR}:/tmp/stalwart:rw" \
|
||||
docker.io/stalwartlabs/stalwart:v0.14.1 \
|
||||
stalwart --config /etc/stalwart/config.toml
|
||||
|
||||
@@ -12,13 +12,7 @@ export STALWART_RANDOM_PORTS=1
|
||||
STALWART_TMPDIR="$(mktemp -d /tmp/stalwart-dev-XXXXXX)"
|
||||
export STALWART_TMPDIR
|
||||
|
||||
command -v stalwart >/dev/null || {
|
||||
echo "stalwart not in PATH — run inside nix develop"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Kill any stalwart left over from a previous run (the CI self-hosted runner
|
||||
# keeps processes alive across jobs when a run is killed externally).
|
||||
# Kill any stalwart left over from a previous run.
|
||||
pkill -x stalwart 2>/dev/null && sleep 0.5 || true
|
||||
|
||||
# Pre-seed spam-filter version so Stalwart does not fetch it on first boot.
|
||||
@@ -35,8 +29,8 @@ tmp=$(mktemp)
|
||||
STALWART_PID=$!
|
||||
trap 'kill "$STALWART_PID" 2>/dev/null || true; wait "$STALWART_PID" 2>/dev/null || true; rm -f "$tmp"' EXIT
|
||||
|
||||
# Wait until Stalwart is accepting connections (up to 10 s).
|
||||
for _i in $(seq 1 20); do
|
||||
# Wait until Stalwart is accepting connections (up to 60 s).
|
||||
for _i in $(seq 1 120); do
|
||||
# shellcheck source=/dev/null
|
||||
[ -f "${STALWART_TMPDIR}/ports.env" ] && . "${STALWART_TMPDIR}/ports.env"
|
||||
grep -E "already in use" "$LOGFILE" >/dev/null 2>&1 && {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sharedinbox/core/sync/background_sync.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Regression test for https://codeberg.org/guettli/sharedinbox/issues/149:
|
||||
// On some Android devices the WorkManager platform channel is absent at
|
||||
// startup, throwing PlatformException(channel-error, ...).
|
||||
// registerBackgroundSync() must absorb the failure and let the app continue.
|
||||
test(
|
||||
'registerBackgroundSync completes without throwing when plugin is unavailable',
|
||||
() async {
|
||||
// In the unit-test environment the native WorkManager plugin is not
|
||||
// registered, so Workmanager().initialize() throws a PlatformException or
|
||||
// MissingPluginException. The fix catches it. This test fails before the
|
||||
// fix (exception propagates) and passes after it (exception is swallowed).
|
||||
await expectLater(registerBackgroundSync(), completes);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sharedinbox/core/services/notification_service.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Regression test for https://codeberg.org/guettli/sharedinbox/issues/146:
|
||||
// On some Android devices the flutter_local_notifications plugin channel is
|
||||
// absent at startup, throwing MissingPluginException (or a similar error).
|
||||
// initNotifications() must absorb the failure and let the app continue.
|
||||
test(
|
||||
'initNotifications completes without throwing when plugin is unavailable',
|
||||
() async {
|
||||
// In the unit-test environment the native plugin is not registered, so
|
||||
// _plugin.initialize() throws. The fix catches it and keeps _initialized
|
||||
// false. This test fails before the fix (exception propagates) and passes
|
||||
// after it (exception is swallowed).
|
||||
await expectLater(initNotifications(), completes);
|
||||
});
|
||||
|
||||
test('showNewMailNotification completes without throwing', () async {
|
||||
// Platform.isAndroid is false in tests, so this returns early without
|
||||
// touching the plugin. Ensures the guard path is exercised.
|
||||
await expectLater(showNewMailNotification('test@example.com'), completes);
|
||||
});
|
||||
}
|
||||
@@ -59,6 +59,10 @@ void main() {
|
||||
await tester.tap(find.text('Report Issue on Codeberg'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Regression for #146: URL must contain only the title, NOT the full
|
||||
// report body. Long stack traces caused "create issue failed" by
|
||||
// exceeding browser URL-length limits. The report is copied to clipboard
|
||||
// so the user can paste it into the issue body.
|
||||
expect(
|
||||
mock.launchedUrl,
|
||||
contains('https://codeberg.org/guettli/sharedinbox/issues/new'),
|
||||
@@ -67,7 +71,43 @@ void main() {
|
||||
mock.launchedUrl,
|
||||
contains('title=Crash%3A%20TestException%3A%20something%20broke'),
|
||||
);
|
||||
expect(mock.launchedUrl, contains('App%20Version%3A%201.0.0%2B42'));
|
||||
expect(mock.launchedUrl, contains('TestException%3A%20something%20broke'));
|
||||
expect(mock.launchedUrl, isNot(contains('&body=')));
|
||||
expect(mock.launchedUrl, isNot(contains('App%20Version')));
|
||||
expect(mock.launchedUrl, isNot(contains('Stack%20Trace')));
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'CrashScreen used as root widget — buttons work without ScaffoldMessenger crash',
|
||||
(tester) async {
|
||||
// Regression test for: ScaffoldMessenger.of(context) null-crash when
|
||||
// CrashScreen is the root widget (runApp path after startup crash).
|
||||
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: startup crash';
|
||||
final stackTrace = StackTrace.current;
|
||||
|
||||
// Pump CrashScreen directly as the root — no parent MaterialApp.
|
||||
await tester.pumpWidget(
|
||||
CrashScreen(exception: exception, stackTrace: stackTrace),
|
||||
);
|
||||
|
||||
expect(find.textContaining('TestException'), findsOneWidget);
|
||||
|
||||
// Tapping 'Report Issue on Codeberg' must not crash. Previously
|
||||
// ScaffoldMessenger.of(context) threw because context was above the
|
||||
// MaterialApp that CrashScreen itself creates.
|
||||
await tester.tap(find.text('Report Issue on Codeberg'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
mock.launchedUrl,
|
||||
contains('https://codeberg.org/guettli/sharedinbox/issues/new'),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Submodule website/themes/PaperMod deleted from 154d006e01
@@ -0,0 +1,22 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020 nanxiaobei and adityatelange
|
||||
Copyright (c) 2021-2026 adityatelange
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,99 @@
|
||||
# Hugo PaperMod
|
||||
|
||||
**A fast, clean, and responsive theme for [Hugo](https://gohugo.io/).**
|
||||
|
||||
[](https://themes.gohugo.io/themes/hugo-papermod/)
|
||||
[](https://github.com/gohugoio/hugo/releases/tag/v0.146.0)
|
||||
[](https://discord.gg/ahpmTvhVmp)
|
||||
|
||||
> Based on [hugo-paper](https://github.com/nanxiaobei/hugo-paper/tree/4330c8b12aa48bfdecbcad6ad66145f679a430b3), with additional features and customization options.
|
||||
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Live Demo</td>
|
||||
<td><a href="https://adityatelange.github.io/hugo-PaperMod/">adityatelange.github.io/hugo-PaperMod</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Documentation 📚</td>
|
||||
<td><a href="https://github.com/adityatelange/hugo-PaperMod/wiki">Github Wiki</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Example Site Source</td>
|
||||
<td><a href="https://github.com/adityatelange/hugo-PaperMod/tree/exampleSite">exampleSite branch</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://www.star-history.com/adityatelange/hugo-papermod"><img src="https://api.star-history.com/badge?repo=adityatelange/hugo-PaperMod&theme=dark" alt="Star History Rank" /></a></td>
|
||||
<td><a href="https://ko-fi.com/H2H229ZWH"><img src="https://ko-fi.com/img/githubbutton_sm.svg" alt="ko-fi" /></a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
<p align="center">
|
||||
<img src="https://user-images.githubusercontent.com/21258296/114303440-bfc0ae80-9aeb-11eb-8cfa-48a4bb385a6d.png" alt="Mockup image" title="Mockup"/>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Features 💥
|
||||
|
||||
`☄️ Fast | ☁️ Fluent | 🌙 Smooth | 📱 Responsive`
|
||||
|
||||
- **Asset pipeline** -- Hugo's built-in asset generator with fingerprinting, bundling, and minification.
|
||||
- **Three layout modes** -- [Regular](https://github.com/adityatelange/hugo-PaperMod/wiki/Features#regular-mode-default-mode), [Home-Info](https://github.com/adityatelange/hugo-PaperMod/wiki/Features#home-info-mode), and [Profile](https://github.com/adityatelange/hugo-PaperMod/wiki/Features#profile-mode).
|
||||
- **Light and dark themes** -- Automatic switching based on browser preference, plus a manual toggle.
|
||||
- **Multilingual support** -- Includes a built-in language selector.
|
||||
- **Search** -- Client-side search powered by Fuse.js.
|
||||
- **SEO optimized** -- Open Graph, Twitter Cards, and Schema.org structured data out of the box.
|
||||
- **Cover images** -- Per-post cover images with responsive image support.
|
||||
- **Table of contents** -- Auto-generated from heading structure.
|
||||
- **Multiple authors** -- Native support for multi-author sites.
|
||||
- **Social icons and share buttons** -- Configurable social links and per-post sharing.
|
||||
- **Breadcrumb navigation**
|
||||
- **Post archives and taxonomies**
|
||||
- **Code block copy buttons** -- One-click copying with Chroma syntax highlighting.
|
||||
- **Related post suggestions**
|
||||
- **Zero JS build dependencies** -- No webpack, Node.js, or other tooling required.
|
||||
|
||||
| Topic | Description |
|
||||
| ------------------------------------------------------------------------------------------------- | ----------------------------------------------- |
|
||||
| **[Installation guide](https://github.com/adityatelange/hugo-PaperMod/wiki/Installation)** | Detailed installation and update instructions |
|
||||
| **[Features wiki page](https://github.com/adityatelange/hugo-PaperMod/wiki/Features)** | In-depth explanations of all features |
|
||||
| **[FAQ wiki](https://github.com/adityatelange/hugo-PaperMod/wiki/FAQs)** | Common questions and configuration walkthroughs |
|
||||
| **[Icons wiki](https://github.com/adityatelange/hugo-PaperMod/wiki/Icons)** | Documentation for social icons and share icons |
|
||||
| **[Variables wiki](https://github.com/adityatelange/hugo-PaperMod/wiki/Variables)** | List of all available template variables |
|
||||
| **[Overiding templates](https://github.com/adityatelange/hugo-PaperMod/wiki/Template_Overrides)** | Guide to customizing templates without forking |
|
||||
| **[Releases](https://github.com/adityatelange/hugo-PaperMod/releases)** | Detailed history of releases |
|
||||
|
||||
---
|
||||
|
||||
## Performance ☄️
|
||||
|
||||
PaperMod consistently scores near-perfect results on [Pagespeed Insights](https://pagespeed.web.dev/report?url=https://adityatelange.github.io/hugo-PaperMod/).
|
||||
|
||||
<img width="481" height="116" alt="image" src="https://github.com/user-attachments/assets/497d831b-d143-4a46-bc11-b1d7f8ef4a83" />
|
||||
|
||||
---
|
||||
|
||||
## Support 🫶
|
||||
|
||||
- Star this repository to show your support.
|
||||
- Share PaperMod with others who might find it useful.
|
||||
- Sponsor the project on [GitHub Sponsors](https://github.com/sponsors/adityatelange) or [Ko-Fi](https://ko-fi.com/adityatelange).
|
||||
|
||||
---
|
||||
|
||||
## Special Thanks 🌟
|
||||
|
||||
- [Highlight.js](https://github.com/highlightjs/highlight.js)
|
||||
- [Fuse.js](https://github.com/krisk/fuse)
|
||||
- [Feather Icons](https://github.com/feathericons/feather)
|
||||
- [Simple Icons](https://github.com/simple-icons/simple-icons)
|
||||
- All contributors and supporters
|
||||
|
||||
---
|
||||
|
||||
## Stargazers 📈
|
||||
|
||||
[](https://starchart.cc/adityatelange/hugo-PaperMod)
|
||||
@@ -0,0 +1,11 @@
|
||||
.not-found {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 80%;
|
||||
font-size: 160px;
|
||||
font-weight: 700;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
.archive-posts {
|
||||
width: 100%;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.archive-year {
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
.archive-year:not(:last-of-type) {
|
||||
border-bottom: 2px solid var(--border);
|
||||
}
|
||||
|
||||
.archive-month {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.archive-month-header {
|
||||
margin: 25px 0;
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.archive-month:not(:last-of-type) {
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.archive-entry {
|
||||
position: relative;
|
||||
padding: 5px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.archive-entry-title {
|
||||
margin: 5px 0;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.archive-count,
|
||||
.archive-meta {
|
||||
color: var(--secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
.footer,
|
||||
.top-link {
|
||||
font-size: 12px;
|
||||
color: var(--secondary);
|
||||
}
|
||||
|
||||
.footer {
|
||||
max-width: calc(var(--main-width) + var(--gap) * 2);
|
||||
margin: auto;
|
||||
padding: calc((var(--footer-height) - var(--gap)) / 2) var(--gap);
|
||||
text-align: center;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.footer span {
|
||||
margin-inline-start: 1px;
|
||||
margin-inline-end: 1px;
|
||||
}
|
||||
|
||||
.footer span:last-child {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.footer a {
|
||||
color: inherit;
|
||||
text-underline-offset: 0.25rem;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.top-link {
|
||||
position: fixed;
|
||||
bottom: 4rem;
|
||||
right: 2rem;
|
||||
z-index: 99;
|
||||
background: var(--tertiary);
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
padding: 10px;
|
||||
border-radius: 64px;
|
||||
transition: visibility .3s, opacity .3s cubic-bezier(0.4, 0, 1, 1);
|
||||
}
|
||||
|
||||
.hidden {
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.top-link,
|
||||
.top-link svg {
|
||||
filter: drop-shadow(0px 0px 0px var(--theme));
|
||||
}
|
||||
|
||||
.footer a:hover,
|
||||
.top-link:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
.header-nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
max-width: calc(var(--nav-width) + var(--gap) * 2);
|
||||
margin: auto;
|
||||
line-height: var(--header-height);
|
||||
padding: 0 var(--gap);
|
||||
column-gap: var(--gap);
|
||||
}
|
||||
|
||||
.header-nav a {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.logo,
|
||||
.menu {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.logo {
|
||||
align-items: center;
|
||||
column-gap: 0.55rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.logo a {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
column-gap: 0.55rem;
|
||||
}
|
||||
|
||||
.logo a img,
|
||||
.logo a svg {
|
||||
pointer-events: none;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
padding: 0 0.4rem;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .moon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-theme="light"] .sun {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.logo-switches {
|
||||
display: inline-flex;
|
||||
gap: 0.4rem;
|
||||
align-items: inherit;
|
||||
min-height: stretch;
|
||||
flex-wrap: inherit;
|
||||
}
|
||||
|
||||
.logo-switches>* {
|
||||
min-height: inherit;
|
||||
align-items: center;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.nav-sep {
|
||||
color: var(--secondary);
|
||||
}
|
||||
|
||||
.lang-menu * {
|
||||
display: inherit;
|
||||
min-height: inherit;
|
||||
align-items: inherit;
|
||||
}
|
||||
|
||||
.lang-menu a {
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
padding: 0 0.4rem;
|
||||
display: inline-flex
|
||||
}
|
||||
|
||||
.menu {
|
||||
list-style: none;
|
||||
word-break: keep-all;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
column-gap: var(--gap);
|
||||
}
|
||||
|
||||
.menu a {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.menu .active {
|
||||
font-weight: 500;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.3rem;
|
||||
text-decoration-thickness: 2px;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
.main {
|
||||
position: relative;
|
||||
min-height: calc(100vh - var(--header-height) - var(--footer-height));
|
||||
max-width: calc(var(--main-width) + var(--gap) * 2);
|
||||
margin: auto;
|
||||
padding: var(--gap);
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 40px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.pagination a {
|
||||
color: var(--theme);
|
||||
font-size: 13px;
|
||||
line-height: 36px;
|
||||
background: var(--primary);
|
||||
border-radius: calc(36px / 2);
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.pagination .next {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
|
||||
.social-icons a {
|
||||
display: inline-flex;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.social-icons a svg {
|
||||
height: 26px;
|
||||
width: 26px;
|
||||
}
|
||||
|
||||
code {
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
div.highlight,
|
||||
pre {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.copy-code {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
background: rgba(78, 78, 78, 0.8);
|
||||
border-radius: var(--radius);
|
||||
padding: 0 5px;
|
||||
font-size: 14px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
div.highlight:hover .copy-code,
|
||||
pre:hover .copy-code {
|
||||
display: block;
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
.md-content h3,
|
||||
.md-content h4,
|
||||
.md-content h5,
|
||||
.md-content h6 {
|
||||
margin: 24px 0 16px;
|
||||
}
|
||||
|
||||
.md-content h1 {
|
||||
margin: 40px auto 32px;
|
||||
font-size: 40px;
|
||||
}
|
||||
|
||||
.md-content h2 {
|
||||
margin: 32px auto 24px;
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.md-content h3 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.md-content h4 {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.md-content h5 {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.md-content h6 {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.md-content a:not(.anchor) {
|
||||
text-underline-offset: 0.3rem;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.md-content del {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.md-content dl:not(:last-child),
|
||||
.md-content ol:not(:last-child),
|
||||
.md-content p:not(:last-child),
|
||||
.md-content figure:not(:last-child),
|
||||
.md-content ul:not(:last-child) {
|
||||
margin-bottom: var(--content-gap);
|
||||
}
|
||||
|
||||
.md-content ol,
|
||||
.md-content ul {
|
||||
padding-inline-start: 1.25rem;
|
||||
}
|
||||
|
||||
.md-content li {
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
|
||||
.md-content li p {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.md-content dl {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.md-content dt {
|
||||
width: 25%;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.md-content dd {
|
||||
width: 75%;
|
||||
margin-inline-start: 0;
|
||||
padding-inline-start: 10px;
|
||||
}
|
||||
|
||||
.md-content dd~dd,
|
||||
.md-content dt~dt {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.md-content table {
|
||||
margin-bottom: var(--content-gap);
|
||||
}
|
||||
|
||||
.md-content table th,
|
||||
.md-content table:not(.highlighttable, .highlight table, .gist .highlight) td {
|
||||
min-width: 80px;
|
||||
padding: 6px 13px;
|
||||
line-height: 1.5;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.md-content table th {
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.md-content table:not(.highlighttable) td code:only-child {
|
||||
margin: auto 0;
|
||||
}
|
||||
|
||||
.md-content .highlight table {
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.md-content .highlight:not(table) {
|
||||
margin-bottom: var(--content-gap);
|
||||
background: var(--code-block-bg) !important;
|
||||
border-radius: var(--radius);
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
.md-content li>.highlight {
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
|
||||
.md-content ul pre {
|
||||
margin-inline-start: calc(var(--gap) * -2);
|
||||
}
|
||||
|
||||
.md-content .highlight pre {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.md-content .highlighttable {
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.md-content .highlighttable td:first-child {
|
||||
width: 40px;
|
||||
}
|
||||
|
||||
.md-content .highlighttable td .linenodiv {
|
||||
padding-inline-end: 0 !important;
|
||||
}
|
||||
|
||||
.md-content .highlighttable td .highlight,
|
||||
.md-content .highlighttable td .linenodiv pre {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.post-content code {
|
||||
padding: 0.2rem 0.3rem;
|
||||
font-size: 0.78em;
|
||||
line-height: 1.5;
|
||||
background: var(--code-bg);
|
||||
border-radius: 0.2rem;
|
||||
}
|
||||
|
||||
.md-content pre code {
|
||||
display: grid;
|
||||
margin: auto 0;
|
||||
padding: 10px;
|
||||
color: rgb(213, 213, 214);
|
||||
background: var(--code-block-bg) !important;
|
||||
border-radius: var(--radius);
|
||||
overflow-x: auto;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.md-content blockquote {
|
||||
margin: 1rem 0;
|
||||
padding-inline-start: 1rem;
|
||||
border-inline-start: 0.3rem solid var(--content);
|
||||
}
|
||||
|
||||
.md-content hr {
|
||||
margin: 30px 0;
|
||||
height: 2px;
|
||||
background: var(--tertiary);
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.md-content iframe {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.md-content img {
|
||||
border-radius: var(--radius);
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.md-content img[src*="#center"] {
|
||||
margin: 1rem auto;
|
||||
}
|
||||
|
||||
.md-content figure.align-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.md-content figure>figcaption {
|
||||
color: var(--primary);
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
margin: 8px 0 16px;
|
||||
}
|
||||
|
||||
.md-content figure>figcaption>p {
|
||||
color: var(--secondary);
|
||||
font-size: 14px;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.md-content h1:hover .anchor,
|
||||
.md-content h2:hover .anchor,
|
||||
.md-content h3:hover .anchor,
|
||||
.md-content h4:hover .anchor,
|
||||
.md-content h5:hover .anchor,
|
||||
.md-content h6:hover .anchor {
|
||||
display: inline-flex;
|
||||
color: var(--secondary);
|
||||
margin-inline-start: 0.5em;
|
||||
font-weight: 500;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.anchor:hover {
|
||||
color: var(--content) !important;
|
||||
}
|
||||
|
||||
.md-content img.in-text {
|
||||
display: inline;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
mark {
|
||||
border-radius: 2px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
audio {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
height: 2.5rem;
|
||||
margin-bottom: var(--content-gap);
|
||||
}
|
||||
|
||||
audio::-webkit-media-controls-enclosure {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
video {
|
||||
border: 1px solid var(--code-bg);
|
||||
border-radius: var(--radius);
|
||||
max-width: 100%;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
.first-entry {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
min-height: 320px;
|
||||
margin: var(--gap) 0 calc(var(--gap) * 2) 0;
|
||||
}
|
||||
|
||||
.first-entry .entry-header {
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
}
|
||||
|
||||
.first-entry .entry-header h1 {
|
||||
font-size: 34px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.first-entry .entry-header h2 {
|
||||
font-size: 40px;
|
||||
}
|
||||
|
||||
.first-entry .entry-content {
|
||||
margin: 14px 0;
|
||||
font-size: 16px;
|
||||
-webkit-line-clamp: 3;
|
||||
}
|
||||
|
||||
.first-entry .entry-footer {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.home-info .entry-content {
|
||||
--content-gap: 0.5rem;
|
||||
-webkit-line-clamp: unset;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.home-info .social-icons a:first-of-type {
|
||||
padding-inline-start: 0;
|
||||
}
|
||||
|
||||
|
||||
.post-entry {
|
||||
position: relative;
|
||||
margin-bottom: var(--gap);
|
||||
padding: var(--gap);
|
||||
background: var(--entry);
|
||||
border-radius: var(--radius);
|
||||
transition: transform 0.25s ease;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.post-entry:hover,
|
||||
.post-entry:focus-within {
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--tertiary);
|
||||
}
|
||||
|
||||
.tag-entry .entry-cover {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.entry-header h2 {
|
||||
font-size: 24px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.entry-content {
|
||||
margin: 8px 0;
|
||||
color: var(--secondary);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.home-info .entry-content p {
|
||||
margin-block-start: 1em;
|
||||
margin-block-end: 1em;
|
||||
}
|
||||
|
||||
.entry-footer {
|
||||
color: var(--secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.entry-link {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.entry-hint {
|
||||
color: var(--secondary);
|
||||
}
|
||||
|
||||
.entry-hint-parent {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.entry-cover {
|
||||
font-size: 14px;
|
||||
margin-bottom: var(--gap);
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .5rem;
|
||||
}
|
||||
|
||||
.entry-cover img {
|
||||
border-radius: var(--radius);
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.entry-cover a {
|
||||
text-underline-offset: 0.3rem;
|
||||
text-decoration: underline;
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
.page-header,
|
||||
.post-header {
|
||||
margin: 24px auto var(--content-gap) auto;
|
||||
}
|
||||
|
||||
.post-title {
|
||||
font-size: 40px;
|
||||
}
|
||||
|
||||
.post-description {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.post-meta {
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.post-meta,
|
||||
.breadcrumbs {
|
||||
color: var(--secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.breadcrumbs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.breadcrumbs a {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.breadcrumbs svg {
|
||||
height: 1em;
|
||||
}
|
||||
|
||||
.i18n_list {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.post-meta .i18n_list li {
|
||||
list-style: none;
|
||||
margin: auto 3px;
|
||||
}
|
||||
|
||||
.post-meta a,
|
||||
.toc a:hover {
|
||||
text-underline-offset: 0.3rem;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.post-meta a {
|
||||
color: var(--secondary);
|
||||
text-decoration-style: dotted;
|
||||
}
|
||||
|
||||
details.toc {
|
||||
margin-bottom: var(--content-gap);
|
||||
background: var(--code-bg);
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
[data-theme="dark"] details.toc {
|
||||
background: var(--entry);
|
||||
}
|
||||
|
||||
details.toc summary {
|
||||
padding: 0.3rem 1.2rem;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
details summary {
|
||||
cursor: pointer;
|
||||
display: list-item;
|
||||
width: 100%;
|
||||
margin-inline-start: 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
details .title {
|
||||
display: inline;
|
||||
font-weight: 500;
|
||||
margin-inline-start: 0.2rem;
|
||||
}
|
||||
|
||||
details {
|
||||
interpolate-size: allow-keywords;
|
||||
}
|
||||
|
||||
details::details-content {
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
overflow: clip;
|
||||
transition: height 150ms ease,
|
||||
opacity 150ms ease,
|
||||
content-visibility 150ms allow-discrete;
|
||||
}
|
||||
|
||||
details[open]::details-content {
|
||||
height: auto;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
details .inner {
|
||||
margin: 0 2.4rem;
|
||||
padding-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
details li ul {
|
||||
margin-inline-start: var(--gap);
|
||||
}
|
||||
|
||||
.post-content {
|
||||
color: var(--content);
|
||||
margin: 30px 0;
|
||||
}
|
||||
|
||||
.post-footer {
|
||||
margin-top: var(--content-gap);
|
||||
}
|
||||
|
||||
.post-footer>* {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.post-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.post-tags li {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.post-tags a,
|
||||
.share-buttons,
|
||||
.paginav {
|
||||
border-radius: var(--radius);
|
||||
background: var(--code-bg);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.post-tags a {
|
||||
display: block;
|
||||
padding: 0 14px;
|
||||
color: var(--secondary);
|
||||
font-size: 14px;
|
||||
line-height: 34px;
|
||||
background: var(--code-bg);
|
||||
}
|
||||
|
||||
.post-tags a:hover,
|
||||
.paginav a:hover {
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.share-buttons {
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
overflow-x: auto;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.share-buttons li,
|
||||
.share-buttons a {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.share-buttons a:not(:last-of-type) {
|
||||
margin-inline-end: 12px;
|
||||
}
|
||||
|
||||
.paginav {
|
||||
display: flex;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.paginav .title {
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.8rem;
|
||||
color: var(--secondary);
|
||||
}
|
||||
|
||||
.paginav a {
|
||||
width: 50%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.8rem;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.paginav span:hover:not(.title) {
|
||||
text-underline-offset: 0.2rem;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.paginav .next {
|
||||
margin-inline-start: auto;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
[dir="rtl"] .paginav .next {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
h1>a>svg {
|
||||
display: inline;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
.buttons,
|
||||
.main .profile {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.main .profile {
|
||||
align-items: center;
|
||||
min-height: calc(100vh - var(--header-height) - var(--footer-height) - (var(--gap) * 2));
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.profile .profile_inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.profile img {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
flex-wrap: wrap;
|
||||
max-width: 400px;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.button {
|
||||
background: var(--tertiary);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.4rem 0.8rem;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
.searchbox input {
|
||||
padding: 4px 10px;
|
||||
width: 100%;
|
||||
color: var(--primary);
|
||||
font-weight: bold;
|
||||
border: 2px solid var(--tertiary);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.searchResults li {
|
||||
list-style: none;
|
||||
border-radius: var(--radius);
|
||||
padding: 10px 15px;
|
||||
position: relative;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: var(--entry);
|
||||
transition: transform .25s ease;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.searchResults {
|
||||
margin: var(--content-gap) 0;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.searchResults li:hover,
|
||||
.searchResults li:focus-within {
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--tertiary);
|
||||
}
|
||||
|
||||
.searchResults li .entry-link:focus {
|
||||
outline: 2px solid var(--secondary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
.terms-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1em;
|
||||
margin-top: var(--content-gap);
|
||||
}
|
||||
|
||||
.terms-tags li {
|
||||
display: inline-block;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.terms-tags a {
|
||||
display: block;
|
||||
padding: 4px 10px;
|
||||
background: var(--tertiary);
|
||||
border-radius: var(--radius);
|
||||
transition: transform 0.1s;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/*
|
||||
PaperMod v8+
|
||||
License: MIT https://github.com/adityatelange/hugo-PaperMod/blob/master/LICENSE
|
||||
Copyright (c) 2020 nanxiaobei and adityatelange
|
||||
Copyright (c) 2021-2026 adityatelange
|
||||
*/
|
||||
@@ -0,0 +1,118 @@
|
||||
*,
|
||||
::after,
|
||||
::before {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
overflow-y: scroll;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
a,
|
||||
button,
|
||||
body,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
font-size: 18px;
|
||||
line-height: 1.6;
|
||||
word-break: break-word;
|
||||
background: var(--theme);
|
||||
}
|
||||
|
||||
article,
|
||||
aside,
|
||||
figcaption,
|
||||
figure,
|
||||
footer,
|
||||
header,
|
||||
hgroup,
|
||||
main,
|
||||
nav,
|
||||
section,
|
||||
table {
|
||||
display: block;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6,
|
||||
p {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
ul {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
body,
|
||||
figure,
|
||||
ul {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
overflow-x: auto;
|
||||
word-break: keep-all;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea {
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
background: 0 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
button,
|
||||
input[type=button],
|
||||
input[type=submit] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input:-webkit-autofill,
|
||||
textarea:-webkit-autofill {
|
||||
box-shadow: 0 0 0 50px var(--theme) inset;
|
||||
}
|
||||
|
||||
img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
:root {
|
||||
--gap: 24px;
|
||||
--content-gap: 20px;
|
||||
--nav-width: 1024px;
|
||||
--main-width: 720px;
|
||||
--header-height: 60px;
|
||||
--footer-height: 60px;
|
||||
--radius: 8px;
|
||||
--theme: rgb(255, 255, 255);
|
||||
--entry: rgb(255, 255, 255);
|
||||
--primary: rgb(30, 30, 30);
|
||||
--secondary: rgb(108, 108, 108);
|
||||
--tertiary: rgb(214, 214, 214);
|
||||
--content: rgb(31, 31, 31);
|
||||
--code-block-bg: rgb(28, 29, 33);
|
||||
--code-bg: rgb(245, 245, 245);
|
||||
--border: rgb(238, 238, 238);
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
--theme: rgb(29, 30, 32);
|
||||
--entry: rgb(46, 46, 51);
|
||||
--primary: rgb(218, 218, 219);
|
||||
--secondary: rgb(155, 156, 157);
|
||||
--tertiary: rgb(65, 66, 68);
|
||||
--content: rgb(196, 196, 197);
|
||||
--code-block-bg: rgb(46, 46, 51);
|
||||
--code-bg: rgb(55, 56, 62);
|
||||
--border: rgb(51, 51, 51);
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
.list {
|
||||
background: var(--code-bg);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .list {
|
||||
background: var(--theme);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
@media screen and (max-width: 768px) {
|
||||
/* theme-vars */
|
||||
:root {
|
||||
--gap: 14px;
|
||||
}
|
||||
|
||||
/* profile-mode */
|
||||
.profile img {
|
||||
transform: scale(0.85);
|
||||
}
|
||||
|
||||
/* post-entry */
|
||||
.first-entry {
|
||||
min-height: 260px;
|
||||
}
|
||||
|
||||
/* archive */
|
||||
.archive-month {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.archive-year {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
/* footer */
|
||||
.footer {
|
||||
padding: calc((var(--footer-height) - var(--gap) - 10px) / 2) var(--gap);
|
||||
}
|
||||
}
|
||||
|
||||
/* footer */
|
||||
@media screen and (max-width: 900px) {
|
||||
.list .top-link {
|
||||
transform: translateY(-5rem);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 340px) {
|
||||
.share-buttons {
|
||||
justify-content: unset;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion) {
|
||||
/* terms; profile-mode; post-single; post-entry; post-entry; search; search */
|
||||
.terms-tags a:active,
|
||||
.button:active,
|
||||
.post-entry:active,
|
||||
.top-link,
|
||||
.searchResults .focus,
|
||||
.searchResults li:active {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/*
|
||||
This is just a placeholder blank stylesheet so as to support adding custom styles budled with theme's default styles
|
||||
|
||||
Read https://github.com/adityatelange/hugo-PaperMod/wiki/FAQs#bundling-custom-css-with-themes-assets for more info
|
||||
*/
|
||||
@@ -0,0 +1,24 @@
|
||||
.chroma {
|
||||
background-color: unset !important;
|
||||
}
|
||||
|
||||
.chroma .hl {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.chroma .lnt {
|
||||
padding: 0 0 0 12px;
|
||||
}
|
||||
|
||||
.highlight pre.chroma code {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.highlight pre.chroma .line .cl,
|
||||
.chroma .ln {
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.chroma .lntd:last-of-type {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/* Background */ .bg { color: #cad3f5; background-color: #24273a; }
|
||||
/* PreWrapper */ .chroma { color: #cad3f5; background-color: #24273a; }
|
||||
/* Other */ .chroma .x { }
|
||||
/* Error */ .chroma .err { color: #ed8796 }
|
||||
/* CodeLine */ .chroma .cl { }
|
||||
/* LineLink */ .chroma .lnlinks { outline: none; text-decoration: none; color: inherit }
|
||||
/* LineTableTD */ .chroma .lntd { vertical-align: top; padding: 0; margin: 0; border: 0; }
|
||||
/* LineTable */ .chroma .lntable { border-spacing: 0; padding: 0; margin: 0; border: 0; }
|
||||
/* LineHighlight */ .chroma .hl { background-color: #474733 }
|
||||
/* LineNumbersTable */ .chroma .lnt { white-space: pre; -webkit-user-select: none; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em;color: #8087a2 }
|
||||
/* LineNumbers */ .chroma .ln { white-space: pre; -webkit-user-select: none; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em;color: #8087a2 }
|
||||
/* Line */ .chroma .line { display: flex; }
|
||||
/* Keyword */ .chroma .k { color: #c6a0f6 }
|
||||
/* KeywordConstant */ .chroma .kc { color: #f5a97f }
|
||||
/* KeywordDeclaration */ .chroma .kd { color: #ed8796 }
|
||||
/* KeywordNamespace */ .chroma .kn { color: #8bd5ca }
|
||||
/* KeywordPseudo */ .chroma .kp { color: #c6a0f6 }
|
||||
/* KeywordReserved */ .chroma .kr { color: #c6a0f6 }
|
||||
/* KeywordType */ .chroma .kt { color: #ed8796 }
|
||||
/* Name */ .chroma .n { }
|
||||
/* NameAttribute */ .chroma .na { color: #8aadf4 }
|
||||
/* NameBuiltin */ .chroma .nb { color: #91d7e3 }
|
||||
/* NameBuiltinPseudo */ .chroma .bp { color: #91d7e3 }
|
||||
/* NameClass */ .chroma .nc { color: #eed49f }
|
||||
/* NameConstant */ .chroma .no { color: #eed49f }
|
||||
/* NameDecorator */ .chroma .nd { color: #8aadf4; font-weight: bold }
|
||||
/* NameEntity */ .chroma .ni { color: #8bd5ca }
|
||||
/* NameException */ .chroma .ne { color: #f5a97f }
|
||||
/* NameFunction */ .chroma .nf { color: #8aadf4 }
|
||||
/* NameFunctionMagic */ .chroma .fm { color: #8aadf4 }
|
||||
/* NameLabel */ .chroma .nl { color: #91d7e3 }
|
||||
/* NameNamespace */ .chroma .nn { color: #f5a97f }
|
||||
/* NameOther */ .chroma .nx { }
|
||||
/* NameProperty */ .chroma .py { color: #f5a97f }
|
||||
/* NameTag */ .chroma .nt { color: #c6a0f6 }
|
||||
/* NameVariable */ .chroma .nv { color: #f4dbd6 }
|
||||
/* NameVariableClass */ .chroma .vc { color: #f4dbd6 }
|
||||
/* NameVariableGlobal */ .chroma .vg { color: #f4dbd6 }
|
||||
/* NameVariableInstance */ .chroma .vi { color: #f4dbd6 }
|
||||
/* NameVariableMagic */ .chroma .vm { color: #f4dbd6 }
|
||||
/* Literal */ .chroma .l { }
|
||||
/* LiteralDate */ .chroma .ld { }
|
||||
/* LiteralString */ .chroma .s { color: #a6da95 }
|
||||
/* LiteralStringAffix */ .chroma .sa { color: #ed8796 }
|
||||
/* LiteralStringBacktick */ .chroma .sb { color: #a6da95 }
|
||||
/* LiteralStringChar */ .chroma .sc { color: #a6da95 }
|
||||
/* LiteralStringDelimiter */ .chroma .dl { color: #8aadf4 }
|
||||
/* LiteralStringDoc */ .chroma .sd { color: #6e738d }
|
||||
/* LiteralStringDouble */ .chroma .s2 { color: #a6da95 }
|
||||
/* LiteralStringEscape */ .chroma .se { color: #8aadf4 }
|
||||
/* LiteralStringHeredoc */ .chroma .sh { color: #6e738d }
|
||||
/* LiteralStringInterpol */ .chroma .si { color: #a6da95 }
|
||||
/* LiteralStringOther */ .chroma .sx { color: #a6da95 }
|
||||
/* LiteralStringRegex */ .chroma .sr { color: #8bd5ca }
|
||||
/* LiteralStringSingle */ .chroma .s1 { color: #a6da95 }
|
||||
/* LiteralStringSymbol */ .chroma .ss { color: #a6da95 }
|
||||
/* LiteralNumber */ .chroma .m { color: #f5a97f }
|
||||
/* LiteralNumberBin */ .chroma .mb { color: #f5a97f }
|
||||
/* LiteralNumberFloat */ .chroma .mf { color: #f5a97f }
|
||||
/* LiteralNumberHex */ .chroma .mh { color: #f5a97f }
|
||||
/* LiteralNumberInteger */ .chroma .mi { color: #f5a97f }
|
||||
/* LiteralNumberIntegerLong */ .chroma .il { color: #f5a97f }
|
||||
/* LiteralNumberOct */ .chroma .mo { color: #f5a97f }
|
||||
/* Operator */ .chroma .o { color: #91d7e3; font-weight: bold }
|
||||
/* OperatorWord */ .chroma .ow { color: #91d7e3; font-weight: bold }
|
||||
/* Punctuation */ .chroma .p { }
|
||||
/* Comment */ .chroma .c { color: #6e738d; font-style: italic }
|
||||
/* CommentHashbang */ .chroma .ch { color: #6e738d; font-style: italic }
|
||||
/* CommentMultiline */ .chroma .cm { color: #6e738d; font-style: italic }
|
||||
/* CommentSingle */ .chroma .c1 { color: #6e738d; font-style: italic }
|
||||
/* CommentSpecial */ .chroma .cs { color: #6e738d; font-style: italic }
|
||||
/* CommentPreproc */ .chroma .cp { color: #6e738d; font-style: italic }
|
||||
/* CommentPreprocFile */ .chroma .cpf { color: #6e738d; font-weight: bold; font-style: italic }
|
||||
/* Generic */ .chroma .g { }
|
||||
/* GenericDeleted */ .chroma .gd { color: #ed8796; background-color: #363a4f }
|
||||
/* GenericEmph */ .chroma .ge { font-style: italic }
|
||||
/* GenericError */ .chroma .gr { color: #ed8796 }
|
||||
/* GenericHeading */ .chroma .gh { color: #f5a97f; font-weight: bold }
|
||||
/* GenericInserted */ .chroma .gi { color: #a6da95; background-color: #363a4f }
|
||||
/* GenericOutput */ .chroma .go { }
|
||||
/* GenericPrompt */ .chroma .gp { }
|
||||
/* GenericStrong */ .chroma .gs { font-weight: bold }
|
||||
/* GenericSubheading */ .chroma .gu { color: #f5a97f; font-weight: bold }
|
||||
/* GenericTraceback */ .chroma .gt { color: #ed8796 }
|
||||
/* GenericUnderline */ .chroma .gl { text-decoration: underline }
|
||||
/* TextWhitespace */ .chroma .w { }
|
||||
@@ -0,0 +1,194 @@
|
||||
import * as params from '@params';
|
||||
|
||||
const resList = document.getElementById('searchResults');
|
||||
const sInput = document.getElementById('searchInput');
|
||||
const searchBox = document.getElementById('searchbox');
|
||||
|
||||
let fuse;
|
||||
let currentElement = null;
|
||||
let firstResult = null;
|
||||
let lastResult = null;
|
||||
|
||||
const defaultFuseOptions = {
|
||||
distance: 100,
|
||||
threshold: 0.4,
|
||||
ignoreLocation: true,
|
||||
keys: ['title', 'permalink', 'summary', 'content']
|
||||
};
|
||||
|
||||
const buildFuseOptions = () => {
|
||||
if (!params.fuseOpts) {
|
||||
return defaultFuseOptions;
|
||||
}
|
||||
|
||||
return {
|
||||
isCaseSensitive: params.fuseOpts.iscasesensitive ?? false,
|
||||
includeScore: params.fuseOpts.includescore ?? false,
|
||||
includeMatches: params.fuseOpts.includematches ?? false,
|
||||
minMatchCharLength: params.fuseOpts.minmatchcharlength ?? 1,
|
||||
shouldSort: params.fuseOpts.shouldsort ?? true,
|
||||
findAllMatches: params.fuseOpts.findallmatches ?? false,
|
||||
keys: params.fuseOpts.keys ?? defaultFuseOptions.keys,
|
||||
location: params.fuseOpts.location ?? 0,
|
||||
threshold: params.fuseOpts.threshold ?? defaultFuseOptions.threshold,
|
||||
distance: params.fuseOpts.distance ?? defaultFuseOptions.distance,
|
||||
ignoreLocation: params.fuseOpts.ignorelocation ?? defaultFuseOptions.ignoreLocation
|
||||
};
|
||||
};
|
||||
|
||||
const debounce = (fn, delay) => {
|
||||
let timeout;
|
||||
return (...args) => {
|
||||
clearTimeout(timeout);
|
||||
timeout = window.setTimeout(() => fn(...args), delay);
|
||||
};
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
currentElement = null;
|
||||
firstResult = null;
|
||||
lastResult = null;
|
||||
resList.innerHTML = '';
|
||||
sInput.value = '';
|
||||
sInput.focus();
|
||||
};
|
||||
|
||||
const setActiveResult = (element) => {
|
||||
document.querySelectorAll('.focus').forEach((item) => item.classList.remove('focus'));
|
||||
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
element.focus();
|
||||
element.parentElement?.classList.add('focus');
|
||||
currentElement = element;
|
||||
};
|
||||
|
||||
const renderResults = (results) => {
|
||||
if (!Array.isArray(results) || results.length === 0) {
|
||||
resList.innerHTML = '';
|
||||
firstResult = lastResult = currentElement = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
for (const result of results) {
|
||||
const li = document.createElement('li');
|
||||
const titleText = document.createTextNode(result.item.title);
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.setAttribute('width', '24');
|
||||
svg.setAttribute('height', '24');
|
||||
svg.setAttribute('viewBox', '0 0 24 24');
|
||||
svg.setAttribute('fill', 'none');
|
||||
svg.setAttribute('stroke', 'currentColor');
|
||||
svg.setAttribute('stroke-width', '2');
|
||||
svg.setAttribute('stroke-linecap', 'round');
|
||||
svg.setAttribute('stroke-linejoin', 'round');
|
||||
svg.classList.add('feather', 'feather-chevrons-right');
|
||||
|
||||
svg.innerHTML = '<polyline points="13 17 18 12 13 7"></polyline><polyline points="6 17 11 12 6 7"></polyline>';
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.className = 'entry-link';
|
||||
link.href = result.item.permalink;
|
||||
link.setAttribute('aria-label', result.item.title);
|
||||
|
||||
li.appendChild(titleText);
|
||||
li.appendChild(svg);
|
||||
li.appendChild(link);
|
||||
fragment.appendChild(li);
|
||||
}
|
||||
|
||||
resList.innerHTML = '';
|
||||
resList.appendChild(fragment);
|
||||
firstResult = resList.firstElementChild;
|
||||
lastResult = resList.lastElementChild;
|
||||
};
|
||||
|
||||
const performSearch = () => {
|
||||
if (!fuse) {
|
||||
return;
|
||||
}
|
||||
|
||||
const query = sInput.value.trim();
|
||||
if (!query) {
|
||||
renderResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const searchOptions = params.fuseOpts?.limit ? { limit: params.fuseOpts.limit } : undefined;
|
||||
const results = searchOptions ? fuse.search(query, searchOptions) : fuse.search(query);
|
||||
renderResults(results);
|
||||
};
|
||||
|
||||
const initSearch = async () => {
|
||||
if (!sInput || !resList) {
|
||||
return;
|
||||
}
|
||||
|
||||
sInput.disabled = false;
|
||||
sInput.focus();
|
||||
|
||||
try {
|
||||
const response = await fetch('../index.json');
|
||||
if (!response.ok) {
|
||||
throw new Error(`Search index load failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data) {
|
||||
fuse = new Fuse(data, buildFuseOptions());
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('load', initSearch);
|
||||
|
||||
sInput?.addEventListener('input', debounce(performSearch, 150));
|
||||
|
||||
sInput?.addEventListener('search', () => {
|
||||
if (!sInput.value) {
|
||||
reset();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', (event) => {
|
||||
const { key } = event;
|
||||
const active = document.activeElement;
|
||||
const isInSearchBox = searchBox?.contains(active);
|
||||
|
||||
if (key === 'Escape') {
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!firstResult || !isInSearchBox) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
|
||||
if (active === sInput) {
|
||||
setActiveResult(firstResult.querySelector('.entry-link'));
|
||||
} else if (active?.parentElement !== lastResult) {
|
||||
setActiveResult(active?.parentElement?.nextElementSibling?.querySelector('.entry-link'));
|
||||
}
|
||||
} else if (key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
|
||||
if (active?.parentElement === firstResult) {
|
||||
setActiveResult(sInput);
|
||||
} else if (active !== sInput) {
|
||||
setActiveResult(active?.parentElement?.previousElementSibling?.querySelector('.entry-link'));
|
||||
}
|
||||
} else if (key === 'ArrowRight') {
|
||||
if (active?.matches?.('.entry-link')) {
|
||||
active.click();
|
||||
}
|
||||
}
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,6 @@
|
||||
/*
|
||||
PaperMod v8+
|
||||
License: MIT https://github.com/adityatelange/hugo-PaperMod/blob/master/LICENSE
|
||||
Copyright (c) 2020 nanxiaobei and adityatelange
|
||||
Copyright (c) 2021-2026 adityatelange
|
||||
*/
|
||||
@@ -0,0 +1,3 @@
|
||||
module github.com/adityatelange/hugo-PaperMod
|
||||
|
||||
go 1.16
|
||||
@@ -0,0 +1,28 @@
|
||||
- id: prev_page
|
||||
translation: "السابق"
|
||||
|
||||
- id: next_page
|
||||
translation: "التالي"
|
||||
|
||||
- id: read_time
|
||||
translation:
|
||||
one: "دقيقة واحدة"
|
||||
two: "دقيقتان"
|
||||
few: "بضع ثوان"
|
||||
zero: "الآن"
|
||||
other: "دقائق {{ .Count }}"
|
||||
|
||||
- id: toc
|
||||
translation: "فهرس المحتوى"
|
||||
|
||||
- id: translations
|
||||
translation: "ترجمات أخرى"
|
||||
|
||||
- id: home
|
||||
translation: "الصفحة الرئيسية"
|
||||
|
||||
- id: code_copied
|
||||
translation: "تم النسخ!"
|
||||
|
||||
- id: code_copy
|
||||
translation: "نسخ الكود"
|
||||
@@ -0,0 +1,39 @@
|
||||
- id: prev_page
|
||||
translation: "Папярэдняя"
|
||||
|
||||
- id: next_page
|
||||
translation: "Наступная"
|
||||
|
||||
- id: read_time
|
||||
translation:
|
||||
zero: "0 хвілін"
|
||||
one: "1 хвіліна"
|
||||
few: "{{ .Count }} хвіліны"
|
||||
many: "{{ .Count }} хвілін"
|
||||
other: "{{ .Count }} хвілін"
|
||||
|
||||
- id: words
|
||||
translation:
|
||||
zero: "няма слоў"
|
||||
one: "1 слова"
|
||||
few: "{{ .Count }} слова"
|
||||
many: "{{ .Count }} слоў"
|
||||
other: "{{ .Count }} слова"
|
||||
|
||||
- id: toc
|
||||
translation: "Змест"
|
||||
|
||||
- id: translations
|
||||
translation: "Пераклады"
|
||||
|
||||
- id: home
|
||||
translation: "Галоўная"
|
||||
|
||||
- id: edit_post
|
||||
translation: "Рэдагаваць"
|
||||
|
||||
- id: code_copy
|
||||
translation: "капіяваць"
|
||||
|
||||
- id: code_copied
|
||||
translation: "скапіявана!"
|
||||
@@ -0,0 +1,16 @@
|
||||
- id: prev_page
|
||||
translation: "Предишна страница"
|
||||
|
||||
- id: next_page
|
||||
translation: "Следваща страница"
|
||||
|
||||
- id: read_time
|
||||
translation:
|
||||
one : "1 мин"
|
||||
other: "{{ .Count }} мин"
|
||||
|
||||
- id: toc
|
||||
translation: "Съдържание"
|
||||
|
||||
- id: translations
|
||||
translation: "Преводи"
|
||||
@@ -0,0 +1,33 @@
|
||||
- id: prev_page
|
||||
translation: "পূর্ববর্তী"
|
||||
|
||||
- id: next_page
|
||||
translation: "পরবর্তী"
|
||||
|
||||
- id: read_time
|
||||
translation:
|
||||
one : "১ মিনিট"
|
||||
other: "{{ .Count }} মিনিট"
|
||||
|
||||
- id: words
|
||||
translation:
|
||||
one : "১ টি শব্দ"
|
||||
other: "{{ .Count }} টি শব্দ"
|
||||
|
||||
- id: toc
|
||||
translation: "সূচিপত্র"
|
||||
|
||||
- id: translations
|
||||
translation: "অনুবাদসমূহ"
|
||||
|
||||
- id: home
|
||||
translation: "হোম"
|
||||
|
||||
- id: edit_post
|
||||
translation: "সম্পাদনা করুন"
|
||||
|
||||
- id: code_copy
|
||||
translation: "কপি করুন"
|
||||
|
||||
- id: code_copied
|
||||
translation: "কপি হয়েছে!"
|
||||
@@ -0,0 +1,33 @@
|
||||
- id: prev_page
|
||||
translation: "Pàgina anterior"
|
||||
|
||||
- id: next_page
|
||||
translation: "Pàgina següent"
|
||||
|
||||
- id: read_time
|
||||
translation:
|
||||
one : "1 minut"
|
||||
other: "{{ .Count }} minuts"
|
||||
|
||||
- id: words
|
||||
translation:
|
||||
one : "paraula"
|
||||
other: "{{ .Count }} paraules"
|
||||
|
||||
- id: toc
|
||||
translation: "Taula de Continguts"
|
||||
|
||||
- id: translations
|
||||
translation: "Traduccions"
|
||||
|
||||
- id: home
|
||||
translation: "Inici"
|
||||
|
||||
- id: edit_post
|
||||
translation: "Editar"
|
||||
|
||||
- id: code_copy
|
||||
translation: "copiar"
|
||||
|
||||
- id: code_copied
|
||||
translation: "copiat!"
|
||||
@@ -0,0 +1,25 @@
|
||||
- id: prev_page
|
||||
translation: "پەڕەی پێشتر"
|
||||
|
||||
- id: next_page
|
||||
translation: "پەڕەی دواتر"
|
||||
|
||||
- id: read_time
|
||||
translation:
|
||||
one : "1 خولەک"
|
||||
other: "{{ .Count }} خولەک"
|
||||
|
||||
- id: toc
|
||||
translation: "پێڕست"
|
||||
|
||||
- id: translations
|
||||
translation: "وەرگێڕانەکان"
|
||||
|
||||
- id: home
|
||||
translation: "ماڵەوە"
|
||||
|
||||
- id: code_copy
|
||||
translation: "لەبەری بگرەوە"
|
||||
|
||||
- id: code_copied
|
||||
translation: "لەبەر گیرایەوە!"
|
||||
@@ -0,0 +1,33 @@
|
||||
- id: prev_page
|
||||
translation: "Předchozí"
|
||||
|
||||
- id: next_page
|
||||
translation: "Další"
|
||||
|
||||
- id: read_time
|
||||
translation:
|
||||
one : "1 min"
|
||||
other: "{{ .Count }} min"
|
||||
|
||||
- id: words
|
||||
translation:
|
||||
one : "slovo"
|
||||
other: "{{ .Count }} slov"
|
||||
|
||||
- id: toc
|
||||
translation: "Obsah"
|
||||
|
||||
- id: translations
|
||||
translation: "Překlady"
|
||||
|
||||
- id: home
|
||||
translation: "Domů"
|
||||
|
||||
- id: edit_post
|
||||
translation: "Upravit"
|
||||
|
||||
- id: code_copy
|
||||
translation: "kopírovat"
|
||||
|
||||
- id: code_copied
|
||||
translation: "zkopírováno!"
|
||||
@@ -0,0 +1,28 @@
|
||||
- id: prev_page
|
||||
translation: "Forrige Side"
|
||||
|
||||
- id: next_page
|
||||
translation: "Næste Side"
|
||||
|
||||
- id: read_time
|
||||
translation:
|
||||
one: "1 min"
|
||||
other: "{{ .Count }} min"
|
||||
|
||||
- id: toc
|
||||
translation: "Indholdsfortegnelse"
|
||||
|
||||
- id: translations
|
||||
translation: "Oversættelser"
|
||||
|
||||
- id: home
|
||||
translation: "Start"
|
||||
|
||||
- id: edit_post
|
||||
translation: "Rediger"
|
||||
|
||||
- id: code_copy
|
||||
translation: "kopier"
|
||||
|
||||
- id: code_copied
|
||||
translation: "kopieret!"
|
||||
@@ -0,0 +1,33 @@
|
||||
- id: prev_page
|
||||
translation: "Vorherige"
|
||||
|
||||
- id: next_page
|
||||
translation: "Nächste"
|
||||
|
||||
- id: read_time
|
||||
translation:
|
||||
one: "1 Minute"
|
||||
other: "{{ .Count }} Minuten"
|
||||
|
||||
- id: words
|
||||
translation:
|
||||
one : "Wort"
|
||||
other: "{{ .Count }} Wörter"
|
||||
|
||||
- id: toc
|
||||
translation: "Inhaltsverzeichnis"
|
||||
|
||||
- id: translations
|
||||
translation: "Übersetzungen"
|
||||
|
||||
- id: home
|
||||
translation: "Home"
|
||||
|
||||
- id: edit_post
|
||||
translation: "Bearbeiten"
|
||||
|
||||
- id: code_copy
|
||||
translation: "Kopieren"
|
||||
|
||||
- id: code_copied
|
||||
translation: "Kopiert!"
|
||||
@@ -0,0 +1,33 @@
|
||||
- id: prev_page
|
||||
translation: "Προηγούμενο"
|
||||
|
||||
- id: next_page
|
||||
translation: "Επόμενο"
|
||||
|
||||
- id: read_time
|
||||
translation:
|
||||
one: "1 λεπτό"
|
||||
other: "{{ .Count }} λεπτά"
|
||||
|
||||
- id: words
|
||||
translation:
|
||||
one: "λέξη"
|
||||
other: "{{ .Count }} λέξεις"
|
||||
|
||||
- id: toc
|
||||
translation: "Πίνακας Περιεχομένων"
|
||||
|
||||
- id: translations
|
||||
translation: "Μεταφράσεις"
|
||||
|
||||
- id: home
|
||||
translation: "Αρχική"
|
||||
|
||||
- id: edit_post
|
||||
translation: "Επεξεργασία"
|
||||
|
||||
- id: code_copy
|
||||
translation: "αντιγραφή"
|
||||
|
||||
- id: code_copied
|
||||
translation: "αντιγράφηκε!"
|
||||
@@ -0,0 +1,33 @@
|
||||
- id: prev_page
|
||||
translation: "Prev"
|
||||
|
||||
- id: next_page
|
||||
translation: "Next"
|
||||
|
||||
- id: read_time
|
||||
translation:
|
||||
one : "1 min"
|
||||
other: "{{ .Count }} min"
|
||||
|
||||
- id: words
|
||||
translation:
|
||||
one : "word"
|
||||
other: "{{ .Count }} words"
|
||||
|
||||
- id: toc
|
||||
translation: "Table of Contents"
|
||||
|
||||
- id: translations
|
||||
translation: "Translations"
|
||||
|
||||
- id: home
|
||||
translation: "Home"
|
||||
|
||||
- id: edit_post
|
||||
translation: "Edit"
|
||||
|
||||
- id: code_copy
|
||||
translation: "copy"
|
||||
|
||||
- id: code_copied
|
||||
translation: "copied!"
|
||||
@@ -0,0 +1,25 @@
|
||||
- id: prev_page
|
||||
translation: "antaŭa paĝo"
|
||||
|
||||
- id: next_page
|
||||
translation: "sekva paĝo"
|
||||
|
||||
- id: read_time
|
||||
translation:
|
||||
one : "1 min"
|
||||
other: "{{ .Count }} min"
|
||||
|
||||
- id: toc
|
||||
translation: "Enhavo"
|
||||
|
||||
- id: translations
|
||||
translation: "tradukoj"
|
||||
|
||||
- id: home
|
||||
translation: "ĉefpaĝo"
|
||||
|
||||
- id: code_copy
|
||||
translation: "kopii"
|
||||
|
||||
- id: code_copied
|
||||
translation: "kopiite!"
|
||||
@@ -0,0 +1,33 @@
|
||||
- id: prev_page
|
||||
translation: "Anterior"
|
||||
|
||||
- id: next_page
|
||||
translation: "Siguiente"
|
||||
|
||||
- id: read_time
|
||||
translation:
|
||||
one : "1 min"
|
||||
other: "{{ .Count }} min"
|
||||
|
||||
- id: words
|
||||
translation:
|
||||
one : "palabra"
|
||||
other: "{{ .Count }} palabras"
|
||||
|
||||
- id: toc
|
||||
translation: "Tabla de Contenidos"
|
||||
|
||||
- id: translations
|
||||
translation: "Traducciones"
|
||||
|
||||
- id: home
|
||||
translation: "Inicio"
|
||||
|
||||
- id: edit_post
|
||||
translation: "Editar"
|
||||
|
||||
- id: code_copy
|
||||
translation: "copiar"
|
||||
|
||||
- id: code_copied
|
||||
translation: "¡copiado!"
|
||||
@@ -0,0 +1,28 @@
|
||||
- id: prev_page
|
||||
translation: "صفحه قبلی"
|
||||
|
||||
- id: next_page
|
||||
translation: "صفحه بعدی"
|
||||
|
||||
- id: read_time
|
||||
translation:
|
||||
one: "۱ دقیقه"
|
||||
other: "{{ .Count }} دقیقه"
|
||||
|
||||
- id: toc
|
||||
translation: "فهرست مطالب"
|
||||
|
||||
- id: translations
|
||||
translation: "ترجمه ها"
|
||||
|
||||
- id: home
|
||||
translation: "خانه"
|
||||
|
||||
- id: edit_post
|
||||
translation: "ویرایش"
|
||||
|
||||
- id: code_copy
|
||||
translation: "کپی"
|
||||
|
||||
- id: code_copied
|
||||
translation: "کپی شد!"
|
||||
@@ -0,0 +1,33 @@
|
||||
- id: prev_page
|
||||
translation: "Edellinen"
|
||||
|
||||
- id: next_page
|
||||
translation: "Seuraava"
|
||||
|
||||
- id: read_time
|
||||
translation:
|
||||
one : "1 min"
|
||||
other: "{{ .Count }} minuuttia"
|
||||
|
||||
- id: words
|
||||
translation:
|
||||
one : "sana"
|
||||
other: "{{ .Count }} sanaa"
|
||||
|
||||
- id: toc
|
||||
translation: "Sisällysluettelo"
|
||||
|
||||
- id: translations
|
||||
translation: "Käännökset"
|
||||
|
||||
- id: home
|
||||
translation: "Etusivu"
|
||||
|
||||
- id: edit_post
|
||||
translation: "Muokkaa"
|
||||
|
||||
- id: code_copy
|
||||
translation: "Kopioi"
|
||||
|
||||
- id: code_copied
|
||||
translation: "Kopioitu!"
|
||||
@@ -0,0 +1,33 @@
|
||||
- id: prev_page
|
||||
translation: "Précédent"
|
||||
|
||||
- id: next_page
|
||||
translation: "Suivant"
|
||||
|
||||
- id: read_time
|
||||
translation:
|
||||
one : "1 min"
|
||||
other: "{{ .Count }} min"
|
||||
|
||||
- id: words
|
||||
translation:
|
||||
one : "mot"
|
||||
other: "{{ .Count }} mots"
|
||||
|
||||
- id: toc
|
||||
translation: "Table des matières"
|
||||
|
||||
- id: translations
|
||||
translation: "Traductions"
|
||||
|
||||
- id: home
|
||||
translation: "Accueil"
|
||||
|
||||
- id: edit_post
|
||||
translation: "Modifier"
|
||||
|
||||
- id: code_copy
|
||||
translation: "Copier"
|
||||
|
||||
- id: code_copied
|
||||
translation: "Copié !"
|
||||
@@ -0,0 +1,33 @@
|
||||
- id: prev_page
|
||||
translation: "הקודם"
|
||||
|
||||
- id: next_page
|
||||
translation: "הבא"
|
||||
|
||||
- id: read_time
|
||||
translation:
|
||||
one: "דקה אחת"
|
||||
other: "{{ .Count }} דקות"
|
||||
|
||||
- id: words
|
||||
translation:
|
||||
one: "מילה אחת"
|
||||
other: "{{ .Count }} מילים"
|
||||
|
||||
- id: toc
|
||||
translation: "תוכן עניינים"
|
||||
|
||||
- id: translations
|
||||
translation: "תרגומים"
|
||||
|
||||
- id: home
|
||||
translation: "בית"
|
||||
|
||||
- id: edit_post
|
||||
translation: "ערוך"
|
||||
|
||||
- id: code_copy
|
||||
translation: "העתק"
|
||||
|
||||
- id: code_copied
|
||||
translation: "הועתק!"
|
||||
@@ -0,0 +1,19 @@
|
||||
- id: prev_page
|
||||
translation: "पिछला"
|
||||
|
||||
- id: next_page
|
||||
translation: "अगला"
|
||||
|
||||
- id: read_time
|
||||
translation:
|
||||
one : "एक मिनट"
|
||||
other: "{{ .Count }} मिनट"
|
||||
|
||||
- id: edit_post
|
||||
translation: "सुधारें"
|
||||
|
||||
- id: toc
|
||||
translation: "विषय - सूची"
|
||||
|
||||
- id: translations
|
||||
translation: "अनुवाद"
|
||||
@@ -0,0 +1,33 @@
|
||||
- id: prev_page
|
||||
translation: "Prethodna stranica"
|
||||
|
||||
- id: next_page
|
||||
translation: "Sljedeća stranica"
|
||||
|
||||
- id: read_time
|
||||
translation:
|
||||
one : "1 minuta"
|
||||
other: "{{ .Count }} minute"
|
||||
|
||||
- id: words
|
||||
translation:
|
||||
one : "riječ"
|
||||
other: "{{ .Count }} riječi"
|
||||
|
||||
- id: toc
|
||||
translation: "Tablica Sadržaja"
|
||||
|
||||
- id: translations
|
||||
translation: "Prijevodi"
|
||||
|
||||
- id: home
|
||||
translation: "Početna stranica"
|
||||
|
||||
- id: edit_post
|
||||
translation: "Promjeni"
|
||||
|
||||
- id: code_copy
|
||||
translation: "kopiraj"
|
||||
|
||||
- id: code_copied
|
||||
translation: "kopirano!"
|
||||
@@ -0,0 +1,16 @@
|
||||
- id: prev_page
|
||||
translation: "Előző oldal"
|
||||
|
||||
- id: next_page
|
||||
translation: "Következő oldal"
|
||||
|
||||
- id: read_time
|
||||
translation:
|
||||
one: "1 perc"
|
||||
other: "{{ .Count }} perc"
|
||||
|
||||
- id: toc
|
||||
translation: "Tartalomjegyzék"
|
||||
|
||||
- id: translations
|
||||
translation: "Fordítások"
|
||||
@@ -0,0 +1,33 @@
|
||||
- id: prev_page
|
||||
translation: "Sebelumnya"
|
||||
|
||||
- id: next_page
|
||||
translation: "Selanjutnya"
|
||||
|
||||
- id: read_time
|
||||
translation:
|
||||
one : "1 menit"
|
||||
other: "{{ .Count }} menit"
|
||||
|
||||
- id: words
|
||||
translation:
|
||||
one : "kata"
|
||||
other: "{{ .Count }} kata"
|
||||
|
||||
- id: toc
|
||||
translation: "Daftar isi"
|
||||
|
||||
- id: translations
|
||||
translation: "Terjemahan"
|
||||
|
||||
- id: home
|
||||
translation: "Beranda"
|
||||
|
||||
- id: edit_post
|
||||
translation: "Sunting"
|
||||
|
||||
- id: code_copy
|
||||
translation: "salin"
|
||||
|
||||
- id: code_copied
|
||||
translation: "disalin!"
|
||||
@@ -0,0 +1,33 @@
|
||||
- id: prev_page
|
||||
translation: "Precedente"
|
||||
|
||||
- id: next_page
|
||||
translation: "Successivo"
|
||||
|
||||
- id: read_time
|
||||
translation:
|
||||
one: "1 minuto"
|
||||
other: "{{ .Count }} minuti"
|
||||
|
||||
- id: words
|
||||
translation:
|
||||
one : "parola"
|
||||
other: "{{ .Count }} parole"
|
||||
|
||||
- id: toc
|
||||
translation: "Indice contenuti"
|
||||
|
||||
- id: translations
|
||||
translation: "Traduzioni"
|
||||
|
||||
- id: home
|
||||
translation: "Home"
|
||||
|
||||
- id: edit_post
|
||||
translation: "Modifica"
|
||||
|
||||
- id: code_copy
|
||||
translation: "copia"
|
||||
|
||||
- id: code_copied
|
||||
translation: "copiato!"
|
||||
@@ -0,0 +1,33 @@
|
||||
- id: prev_page
|
||||
translation: "前へ"
|
||||
|
||||
- id: next_page
|
||||
translation: "次へ"
|
||||
|
||||
- id: read_time
|
||||
translation:
|
||||
one : "1 分"
|
||||
other: "{{ .Count }} 分"
|
||||
|
||||
- id: words
|
||||
translation:
|
||||
one: "文字"
|
||||
other: "{{ .Count }} 文字"
|
||||
|
||||
- id: toc
|
||||
translation: "目次"
|
||||
|
||||
- id: translations
|
||||
translation: "言語"
|
||||
|
||||
- id: home
|
||||
translation: "ホーム"
|
||||
|
||||
- id: edit_post
|
||||
translation: "編集"
|
||||
|
||||
- id: code_copy
|
||||
translation: "コピー"
|
||||
|
||||
- id: code_copied
|
||||
translation: "コピーされました!"
|
||||
@@ -0,0 +1,33 @@
|
||||
- id: prev_page
|
||||
translation: "이전 페이지"
|
||||
|
||||
- id: next_page
|
||||
translation: "다음 페이지"
|
||||
|
||||
- id: read_time
|
||||
translation:
|
||||
one : "1 분"
|
||||
other: "{{ .Count }} 분"
|
||||
|
||||
- id: words
|
||||
translation:
|
||||
one : "단어"
|
||||
other: "{{ .Count }} 단어"
|
||||
|
||||
- id: toc
|
||||
translation: "목차"
|
||||
|
||||
- id: translations
|
||||
translation: "번역"
|
||||
|
||||
- id: home
|
||||
translation: "홈"
|
||||
|
||||
- id: edit_post
|
||||
translation: "편집"
|
||||
|
||||
- id: code_copy
|
||||
translation: "복사"
|
||||
|
||||
- id: code_copied
|
||||
translation: "복사 완료!"
|
||||
@@ -0,0 +1,25 @@
|
||||
- id: prev_page
|
||||
translation: "Rûpela Paş"
|
||||
|
||||
- id: next_page
|
||||
translation: "Rûpela Pêş"
|
||||
|
||||
- id: read_time
|
||||
translation:
|
||||
one : "1 xulek"
|
||||
other: "{{ .Count }} xulek"
|
||||
|
||||
- id: toc
|
||||
translation: "Pêrist"
|
||||
|
||||
- id: translations
|
||||
translation: "Wergeran"
|
||||
|
||||
- id: home
|
||||
translation: "Xanî"
|
||||
|
||||
- id: code_copy
|
||||
translation: "Jê bigire"
|
||||
|
||||
- id: code_copied
|
||||
translation: "Hat jêgirtin!"
|
||||
@@ -0,0 +1,25 @@
|
||||
- id: prev_page
|
||||
translation: "Ѳмнѳх"
|
||||
|
||||
- id: next_page
|
||||
translation: "Дараах"
|
||||
|
||||
- id: read_time
|
||||
translation:
|
||||
one : "1 МИН"
|
||||
other: "{{ .Count }} МИН"
|
||||
|
||||
- id: toc
|
||||
translation: "Агуулга"
|
||||
|
||||
- id: translations
|
||||
translation: "Орчуулга"
|
||||
|
||||
- id: home
|
||||
translation: "Нүүр"
|
||||
|
||||
- id: code_copy
|
||||
translation: "хуулах"
|
||||
|
||||
- id: code_copied
|
||||
translation: "хуулсан!"
|
||||
@@ -0,0 +1,28 @@
|
||||
- id: prev_page
|
||||
translation: "Halaman Sebelumnya"
|
||||
|
||||
- id: next_page
|
||||
translation: "Halaman Seterusnya"
|
||||
|
||||
- id: read_time
|
||||
translation:
|
||||
one: "1 minit"
|
||||
other: "{{ .Count }} minit"
|
||||
|
||||
- id: toc
|
||||
translation: "Isi Kandungan"
|
||||
|
||||
- id: translations
|
||||
translation: "Terjemahan"
|
||||
|
||||
- id: home
|
||||
translation: "Home"
|
||||
|
||||
- id: edit_post
|
||||
translation: "Sunting"
|
||||
|
||||
- id: code_copy
|
||||
translation: "Salin"
|
||||
|
||||
- id: code_copied
|
||||
translation: "Disalin!"
|
||||
@@ -0,0 +1,33 @@
|
||||
- id: prev_page
|
||||
translation: "Vorige"
|
||||
|
||||
- id: next_page
|
||||
translation: "Volgende"
|
||||
|
||||
- id: read_time
|
||||
translation:
|
||||
one: "1 min"
|
||||
other: "{{ .Count }} min"
|
||||
|
||||
- id: words
|
||||
translation:
|
||||
one : "woord"
|
||||
other: "{{ .Count }} woorden"
|
||||
|
||||
- id: toc
|
||||
translation: "Inhoudsopgave"
|
||||
|
||||
- id: translations
|
||||
translation: "Vertalingen"
|
||||
|
||||
- id: home
|
||||
translation: "Startpagina"
|
||||
|
||||
- id: edit_post
|
||||
translation: "Bewerk"
|
||||
|
||||
- id: code_copy
|
||||
translation: "kopieer"
|
||||
|
||||
- id: code_copied
|
||||
translation: "gekopieerd!"
|
||||
@@ -0,0 +1,33 @@
|
||||
- id: prev_page
|
||||
translation: "Forrige Side"
|
||||
|
||||
- id: next_page
|
||||
translation: "Neste Side"
|
||||
|
||||
- id: read_time
|
||||
translation:
|
||||
one: "1 min"
|
||||
other: "{{ .Count }} min"
|
||||
|
||||
- id: words
|
||||
translation:
|
||||
one: "ord"
|
||||
other: "{{ .Count }} ord"
|
||||
|
||||
- id: toc
|
||||
translation: "Innholdsfortegnelse"
|
||||
|
||||
- id: translations
|
||||
translation: "Oversettelser"
|
||||
|
||||
- id: home
|
||||
translation: "Hjem"
|
||||
|
||||
- id: edit_post
|
||||
translation: "Rediger"
|
||||
|
||||
- id: code_copy
|
||||
translation: "Kopier"
|
||||
|
||||
- id: code_copied
|
||||
translation: "Kopiert!"
|
||||
@@ -0,0 +1,33 @@
|
||||
- id: prev_page
|
||||
translation: "Prec."
|
||||
|
||||
- id: next_page
|
||||
translation: "Seg."
|
||||
|
||||
- id: read_time
|
||||
translation:
|
||||
one : "1 min"
|
||||
other: "{{ .Count }} min"
|
||||
|
||||
- id: words
|
||||
translation:
|
||||
one : "mot"
|
||||
other: "{{ .Count }} motss"
|
||||
|
||||
- id: toc
|
||||
translation: "Taula de contengut"
|
||||
|
||||
- id: translations
|
||||
translation: "Traduccions"
|
||||
|
||||
- id: home
|
||||
translation: "Acuèlh"
|
||||
|
||||
- id: edit_post
|
||||
translation: "Modificar"
|
||||
|
||||
- id: code_copy
|
||||
translation: "copiar"
|
||||
|
||||
- id: code_copied
|
||||
translation: "copiat !"
|
||||
@@ -0,0 +1,33 @@
|
||||
- id: prev_page
|
||||
translation: "ਪਿਛਲਾ"
|
||||
|
||||
- id: next_page
|
||||
translation: "ਅਗਲਾ"
|
||||
|
||||
- id: read_time
|
||||
translation:
|
||||
one: "1 ਮਿੰਟ"
|
||||
other: "{{ .Count }} ਮਿੰਟ"
|
||||
|
||||
- id: words
|
||||
translation:
|
||||
one: "ਸ਼ਬਦ"
|
||||
other: "{{ .Count }} ਸ਼ਬਦ"
|
||||
|
||||
- id: toc
|
||||
translation: "ਤਤਕਰਾ"
|
||||
|
||||
- id: translations
|
||||
translation: "ਅਨੁਵਾਦ"
|
||||
|
||||
- id: home
|
||||
translation: "ਘਰ"
|
||||
|
||||
- id: edit_post
|
||||
translation: "ਸੋਧ"
|
||||
|
||||
- id: code_copy
|
||||
translation: "ਕਾਪੀ"
|
||||
|
||||
- id: code_copied
|
||||
translation: "ਕਾਪੀ ਕੀਤੀ ਗਈ!!"
|
||||
@@ -0,0 +1,33 @@
|
||||
- id: prev_page
|
||||
translation: "Poprzednia"
|
||||
|
||||
- id: next_page
|
||||
translation: "Następna"
|
||||
|
||||
- id: read_time
|
||||
translation:
|
||||
one: "1 min"
|
||||
other: "{{ .Count }} min"
|
||||
|
||||
- id: words
|
||||
translation:
|
||||
one : "słowo"
|
||||
other: "{{ .Count }} słów"
|
||||
|
||||
- id: toc
|
||||
translation: "Spis treści"
|
||||
|
||||
- id: translations
|
||||
translation: "Tłumaczenia"
|
||||
|
||||
- id: home
|
||||
translation: "Strona Główna"
|
||||
|
||||
- id: edit_post
|
||||
translation: "Edytuj"
|
||||
|
||||
- id: code_copy
|
||||
translation: "Kopiuj"
|
||||
|
||||
- id: code_copied
|
||||
translation: "Skopiowano!"
|
||||
@@ -0,0 +1,33 @@
|
||||
- id: prev_page
|
||||
translation: "پِچھلا"
|
||||
|
||||
- id: next_page
|
||||
translation: "اگلا"
|
||||
|
||||
- id: read_time
|
||||
translation:
|
||||
one: "ایک منٹ"
|
||||
other: "مِنٹ {{ .Count }}"
|
||||
|
||||
- id: words
|
||||
translation:
|
||||
one: "لفظ"
|
||||
other: "لفظ {{ .Count }}"
|
||||
|
||||
- id: toc
|
||||
translation: "تتکرا"
|
||||
|
||||
- id: translations
|
||||
translation: "انوواد"
|
||||
|
||||
- id: home
|
||||
translation: "گھر"
|
||||
|
||||
- id: edit_post
|
||||
translation: "سودھ"
|
||||
|
||||
- id: code_copy
|
||||
translation: "کاپی"
|
||||
|
||||
- id: code_copied
|
||||
translation: "کاپی کیتی گئی!"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user