Compare commits
177
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.
|
||||
|
||||
+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 {
|
||||
|
||||
@@ -15,6 +15,8 @@ class CrashScreen extends StatelessWidget {
|
||||
final Object exception;
|
||||
final StackTrace? stackTrace;
|
||||
|
||||
static const _gitHash = String.fromEnvironment('GIT_HASH');
|
||||
|
||||
Future<String> _buildReport() async {
|
||||
String version = 'unknown';
|
||||
try {
|
||||
@@ -23,7 +25,11 @@ class CrashScreen extends StatelessWidget {
|
||||
} catch (_) {}
|
||||
final platform =
|
||||
'${Platform.operatingSystem} ${Platform.operatingSystemVersion}';
|
||||
final gitLine = _gitHash.isNotEmpty
|
||||
? 'Git Commit: [$_gitHash](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash)\n'
|
||||
: '';
|
||||
return 'App Version: $version\n'
|
||||
'$gitLine'
|
||||
'Platform: $platform\n\n'
|
||||
'Error:\n```\n$exception\n```\n\n'
|
||||
'Stack Trace:\n```\n$stackTrace\n```';
|
||||
@@ -37,39 +43,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 +69,120 @@ 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(
|
||||
),
|
||||
),
|
||||
],
|
||||
if (_gitHash.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Git Commit:',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
GestureDetector(
|
||||
onTap: () async {
|
||||
final url = Uri.parse(
|
||||
'https://codeberg.org/guettli/sharedinbox/commit/$_gitHash',
|
||||
);
|
||||
await launchUrl(
|
||||
url,
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
},
|
||||
child: const Text(
|
||||
_gitHash,
|
||||
style: TextStyle(
|
||||
color: Colors.blue,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
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"
|
||||
|
||||
+358
-86
@@ -7,12 +7,15 @@ 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,
|
||||
@@ -24,6 +27,7 @@ Resume the Claude conversation afterward with:
|
||||
claude --resume issue-91
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
@@ -32,47 +36,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 +95,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,18 +204,21 @@ 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:
|
||||
@@ -161,11 +231,12 @@ def _clear_state() -> None:
|
||||
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 +254,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: claude --resume {shlex.quote(session_name)}")
|
||||
return proc.pid
|
||||
|
||||
|
||||
@@ -211,6 +282,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 +314,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 +375,128 @@ 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")
|
||||
resume_cmd = f"claude --resume {shlex.quote(session_name)}" if session_name else ""
|
||||
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 +506,35 @@ 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)
|
||||
print(f"CI passed — 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 +542,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 +565,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
|
||||
+262
-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,250 @@ 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_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)
|
||||
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mockito/mockito.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
@@ -59,6 +60,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 +72,89 @@ 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 copy-to-clipboard includes version and platform info',
|
||||
(tester) async {
|
||||
tester.view.physicalSize = const Size(800, 1200);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(() => tester.view.resetPhysicalSize());
|
||||
|
||||
String? clipboardText;
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
SystemChannels.platform,
|
||||
(MethodCall call) async {
|
||||
if (call.method == 'Clipboard.setData') {
|
||||
clipboardText =
|
||||
(call.arguments as Map<dynamic, dynamic>)['text'] as String?;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
addTearDown(
|
||||
() => tester.binding.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(SystemChannels.platform, null),
|
||||
);
|
||||
|
||||
const exception = 'TestException: clipboard test';
|
||||
final stackTrace = StackTrace.current;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: CrashScreen(exception: exception, stackTrace: stackTrace),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Copy to Clipboard'));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(clipboardText, isNotNull);
|
||||
expect(clipboardText, contains('App Version: 1.0.0+42'));
|
||||
expect(clipboardText, contains('Platform:'));
|
||||
expect(clipboardText, contains('TestException: clipboard test'));
|
||||
// GIT_HASH is empty in test builds — no Git Commit line expected
|
||||
expect(clipboardText, isNot(contains('Git Commit:')));
|
||||
},
|
||||
);
|
||||
|
||||
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