Compare commits
18
Commits
@@ -13,20 +13,42 @@ jobs:
|
||||
deploy:
|
||||
name: Build & Deploy Website
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- 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 Website
|
||||
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
|
||||
env:
|
||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
|
||||
SSH_USER: ${{ secrets.SSH_USER }}
|
||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||
run: task website-deploy
|
||||
DAGGER_NO_NAG: "1"
|
||||
run: task publish-website
|
||||
|
||||
- name: Verify Website
|
||||
env:
|
||||
SSH_HOST: ${{ secrets.WEBSITE_SSH_HOST }}
|
||||
run: scripts/website-verify.sh
|
||||
|
||||
- name: Cleanup TLS credentials
|
||||
if: always()
|
||||
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
||||
|
||||
@@ -33,12 +33,12 @@ repos:
|
||||
- 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'"
|
||||
entry: "bash -c 'git --no-pager 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'"
|
||||
entry: "bash -c 'git --no-pager 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
|
||||
|
||||
+12
-1
@@ -844,13 +844,24 @@ func (m *Ci) PublishAndroid(
|
||||
|
||||
// Renovate runs Renovate bot against the repository on Forgejo/Codeberg.
|
||||
func (m *Ci) Renovate(ctx context.Context, renovateToken *dagger.Secret) (string, error) {
|
||||
// Codeberg's GET /pulls?state=all&limit=100 times out with a 504, but limit=10
|
||||
// completes in ~9 s. Patch the compiled pr-cache.js to use 10 instead of the
|
||||
// hardcoded 20/100 values before launching renovate.
|
||||
const patchCmd = `for f in \
|
||||
/usr/local/renovate/dist/modules/platform/forgejo/pr-cache.js \
|
||||
/usr/local/renovate/dist/modules/platform/gitea/pr-cache.js; do \
|
||||
sed -i 's/limit: this\.items\.length ? 20 : 100/limit: this.items.length ? 10 : 10/' "$f" && echo "patched $f"; \
|
||||
done`
|
||||
return dag.Container().
|
||||
From("renovate/renovate:39").
|
||||
From("renovate/renovate:43").
|
||||
WithSecretVariable("RENOVATE_TOKEN", renovateToken).
|
||||
WithEnvVariable("RENOVATE_PLATFORM", "forgejo").
|
||||
WithEnvVariable("RENOVATE_ENDPOINT", "https://codeberg.org").
|
||||
WithEnvVariable("RENOVATE_REPOSITORIES", "guettli/sharedinbox").
|
||||
WithEnvVariable("LOG_LEVEL", "info").
|
||||
WithUser("root").
|
||||
WithExec([]string{"/bin/sh", "-c", patchCmd}).
|
||||
WithUser("ubuntu").
|
||||
WithExec([]string{"renovate"}).
|
||||
Stdout(ctx)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,16 @@ This file contains tasks which got implemented.
|
||||
|
||||
Tasks get moved from next.md to done.md
|
||||
|
||||
## Tasks (2026-05-26)
|
||||
|
||||
- **Renovate Bot (Issue #257)**: Renovate Bot runs daily via Forgejo Actions to keep
|
||||
dependencies up to date. All required components are in main:
|
||||
- `renovate.json` — Renovate configuration covering pub, Dockerfile, and Forgejo Actions
|
||||
- `ci/main.go` — `Renovate()` Dagger function using Forgejo platform and Codeberg endpoint
|
||||
- `.forgejo/workflows/renovate.yml` — daily cron (06:00 UTC) workflow
|
||||
- `Taskfile.yml` — `renovate` task
|
||||
- Issue #257 closed.
|
||||
|
||||
## Tasks (2026-05-11)
|
||||
|
||||
- **Stabilize Email List UI during Selection (Issue #14)**: Prevented layout shifts when entering
|
||||
|
||||
+53
-42
@@ -46,7 +46,7 @@ import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
# Cron runs with a minimal PATH; ensure Nix profile binaries (tea, claude) and ~/go/bin (fgj) are found.
|
||||
# Cron runs with a minimal PATH; ensure Nix profile binaries (claude) and ~/go/bin (fgj) are found.
|
||||
os.environ["PATH"] = (
|
||||
f"{Path.home()}/.nix-profile/bin"
|
||||
f":{Path.home()}/go/bin"
|
||||
@@ -97,22 +97,27 @@ def _fgj(*args: str) -> None:
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
def _fgj_run_list(limit: int = 20) -> list[dict]:
|
||||
"""Return workflow runs via fgj actions run list."""
|
||||
result = subprocess.run(
|
||||
["fgj", "--hostname", "codeberg.org", "actions", "run", "list",
|
||||
"--repo", REPO, "--json", "-L", str(limit)],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"tea api {path} failed:\n{result.stderr or result.stdout}"
|
||||
f"fgj actions run list failed:\n{result.stderr or result.stdout}"
|
||||
)
|
||||
out = result.stdout.strip()
|
||||
if not out:
|
||||
return None
|
||||
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
|
||||
return []
|
||||
try:
|
||||
data = json.loads(out)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise RuntimeError(
|
||||
f"fgj actions run list returned non-JSON:\n{out[:500]}"
|
||||
) from exc
|
||||
return data if isinstance(data, list) else []
|
||||
|
||||
|
||||
def _set_labels(issue: int, add: list[str], remove: list[str]) -> None:
|
||||
@@ -181,9 +186,7 @@ def _latest_main_ci_run() -> dict | None:
|
||||
event=push and prettyref=main, so filtering by event alone is not enough.
|
||||
We also require workflow_id == "ci.yml".
|
||||
"""
|
||||
data = _tea_get(f"repos/{REPO}/actions/runs?limit=20")
|
||||
runs = (data or {}).get("workflow_runs", [])
|
||||
for run in runs:
|
||||
for run in _fgj_run_list(limit=20):
|
||||
if (run.get("event") == "push"
|
||||
and run.get("prettyref") == "main"
|
||||
and run.get("workflow_id") == "ci.yml"):
|
||||
@@ -194,20 +197,16 @@ def _latest_main_ci_run() -> dict | 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"]``.
|
||||
For push events fgj reports the branch in ``prettyref``; for pull_request
|
||||
events ``prettyref`` is ``#N``, so we resolve the PR number first.
|
||||
"""
|
||||
data = _tea_get(f"repos/{REPO}/actions/runs?limit=20")
|
||||
runs = (data or {}).get("workflow_runs", [])
|
||||
runs = _fgj_run_list(limit=20)
|
||||
pr_data = _find_pr_for_branch(branch)
|
||||
pr_ref = f"#{pr_data['number']}" if pr_data else None
|
||||
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
|
||||
if pr_ref and run.get("prettyref") == pr_ref:
|
||||
return run
|
||||
elif run.get("event") == "push":
|
||||
if run.get("prettyref") == branch:
|
||||
return run
|
||||
@@ -254,24 +253,27 @@ def _open_issue_prs() -> list[dict]:
|
||||
|
||||
def _latest_ci_run_for_pr(pr_number: int) -> dict | None:
|
||||
"""Return the latest CI run triggered by a pull_request event for the given PR number."""
|
||||
data = _tea_get(f"repos/{REPO}/actions/runs?event=pull_request&limit=50")
|
||||
runs = (data or {}).get("workflow_runs", [])
|
||||
for run in runs:
|
||||
try:
|
||||
payload = json.loads(run.get("event_payload", "{}"))
|
||||
if payload.get("pull_request", {}).get("number") == pr_number:
|
||||
return run
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
pass
|
||||
pr_ref = f"#{pr_number}"
|
||||
for run in _fgj_run_list(limit=50):
|
||||
if run.get("event") == "pull_request" and run.get("prettyref") == pr_ref:
|
||||
return run
|
||||
return None
|
||||
|
||||
|
||||
def _get_issue_labels(issue: int) -> list[str]:
|
||||
"""Return label names for an issue."""
|
||||
data = _tea_get(f"repos/{REPO}/issues/{issue}")
|
||||
if not data:
|
||||
result = subprocess.run(
|
||||
["fgj", "--hostname", "codeberg.org", "issue", "view", str(issue),
|
||||
"--repo", REPO, "--json"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if result.returncode != 0 or not result.stdout.strip():
|
||||
return []
|
||||
return [lbl["name"] for lbl in data.get("labels", [])]
|
||||
try:
|
||||
data = json.loads(result.stdout)
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
return [lbl["name"] for lbl in data.get("issue", {}).get("labels", [])]
|
||||
|
||||
|
||||
def _merge_pr(pr_number: int) -> None:
|
||||
@@ -287,8 +289,18 @@ def _handle_pr_still_open_after_merge(pr_number: int, branch: str, issue_num: in
|
||||
"merged" — PR closed after a retry
|
||||
"fallback" — all options exhausted; caller should set State/Question
|
||||
"""
|
||||
pr_data = _tea_get(f"repos/{REPO}/pulls/{pr_number}")
|
||||
mergeable = (pr_data or {}).get("mergeable")
|
||||
result = subprocess.run(
|
||||
["fgj", "--hostname", "codeberg.org", "pr", "view", str(pr_number),
|
||||
"--repo", REPO, "--json"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
pr_data: dict = {}
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
try:
|
||||
pr_data = json.loads(result.stdout)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
mergeable = pr_data.get("mergeable")
|
||||
|
||||
if mergeable is False:
|
||||
prompt = (
|
||||
@@ -831,9 +843,8 @@ def _run_loop() -> int:
|
||||
# spawning another agent, check whether any CI run is currently in
|
||||
# progress (the branch run) and wait if so.
|
||||
if ci_run_id_at_start is not None and run["id"] == ci_run_id_at_start:
|
||||
check = _tea_get(f"repos/{REPO}/actions/runs?limit=5")
|
||||
in_flight = [
|
||||
r for r in (check or {}).get("workflow_runs", [])
|
||||
r for r in _fgj_run_list(limit=5)
|
||||
if r.get("status") == "running"
|
||||
]
|
||||
if in_flight:
|
||||
|
||||
@@ -25,7 +25,8 @@ The app processes the following data **exclusively on your device**:
|
||||
device's secure storage and never transmitted to us.
|
||||
- **Email messages and attachments** — fetched directly from your email provider's IMAP server and
|
||||
displayed in the app. We never receive, store, or process your emails.
|
||||
- **App settings and configuration** — stored locally on your device.
|
||||
- **App settings and configuration** — stored locally on your device. The app will never upload
|
||||
this data to sharedinbox.de or any third-party service.
|
||||
|
||||
### Network connections
|
||||
|
||||
|
||||
Reference in New Issue
Block a user