Compare commits

...
Author SHA1 Message Date
Thomas SharedInboxandClaude Sonnet 4.6 d0973fafbf fix(agent_loop): prevent infinite catch-up merge retry and wrong issue closure
Two bugs fixed:

1. Catch-up scan (section 2b) called _merge_pr and immediately returned,
   claiming success even when fgj exits 0 but the merge silently failed
   (e.g. branch-protection rules not satisfied). PR #163 was retried 30+
   times in a row because the PR stayed open after each attempt.
   Fix: verify the PR is no longer open after the merge call; if it is still
   open, set the issue to State/Question instead of looping forever.

2. ci-fix agents wrote "Closes #198" in commit messages, causing Forgejo to
   auto-close issue #198 ("Unable to load asset: assets/changelog.txt") even
   though the commit only fixed the unrelated Play Store upload.
   Fix: both ci-fix prompts now explicitly forbid issue-number references in
   commit messages and close operations. Also save ci_run_id_at_start in
   the ci-fix state (was only done for issue agents) so future guard logic
   can compare run IDs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 10:48:00 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 7310568157 fix: merge orphaned issue PRs whose CI passed but state was cleared (#200)
Add catch-up scan in agent_loop that finds all open issue-N-fix PRs and
merges those with passed CI, using event-filtered API query (limit=50)
to cover weeks of history instead of the previous ~1.5 h window.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 08:49:33 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 a569177637 fix: treat MissingPluginException from secure storage as permanent sync error (#200)
When flutter_secure_storage's platform channel is unavailable (e.g. on
certain Android devices), getPassword() throws MissingPluginException.
Previously this was not recognised as a permanent error, so the IMAP and
JMAP sync loops retried indefinitely with exponential back-off, filling
the sync log with repeated failures (as shown in the screenshot).

Treat MissingPluginException as a permanent error in both _AccountSync
and _JmapAccountSync so the loop stops immediately instead of retrying.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 08:46:29 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 375fd5d914 ci: skip jobs when unrelated files change, skip Android/Linux when paths unchanged (#144)
- ci.yml: add paths filters to push and pull_request triggers so the full
  Dagger check only runs when source-relevant files change (lib/, test/,
  android/, linux/, scripts/, ci/, Taskfile.yml, etc.).  Pure website,
  docs, and assets/changelog.txt commits no longer trigger ci.yml.

- deploy.yml: add check-changes job that diffs HEAD~1..HEAD and outputs
  android/linux booleans.  On workflow_dispatch both are always true.
  test-android-firebase, deploy-playstore, and deploy-apk are now
  conditional on android==true; build-linux is conditional on linux==true.
  label-deploy-health only fires when at least one build job actually ran
  (not all skipped) and treats 'skipped' as acceptable in ALL_SUCCEEDED.

- ci/main.go Graph(): update Mermaid diagram to reflect the new two-
  workflow structure (ci.yml fast-check + deploy.yml with change-gated jobs).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 08:24:50 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 7ece6f09e5 feat: make sharedinbox.de heading a link and add git commit row to about table (#199)
- Wrap the '## sharedinbox.de' heading in a markdown hyperlink to https://sharedinbox.de
- Add a dedicated 'Git Commit' table row with a clickable link to the commit on Codeberg when GIT_HASH is set
- Update clipboard test to assert the heading link is present in copied markdown

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 08:04:54 +02:00
Thomas SharedInbox 3f946dfca0 fix: switch Play Store upload from httplib2 to requests
The Play Store AAB upload was failing with httplib2.error.RedirectMissingLocation
when Google's API returned a redirect during the resumable upload initiation.
Switched from google-api-python-client (which uses httplib2 internally) to
pure requests-based AuthorizedSession, which handles redirects correctly.

Closes #198
2026-05-24 07:52:12 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 c517f604e0 test: update deploy_playstore tests for requests-based transport
The previous tests patched google_auth_httplib2 and googleapiclient which
no longer exist in the new implementation. Rewrite to mock AuthorizedSession
and _upload_aab_resumable, covering the same scenarios: happy path, retry
on transient errors, backoff delays, and exhausted attempts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 07:40:17 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 7d393ec818 fix: switch Play Store upload from httplib2 to requests
httplib2 treats 308 Resume Incomplete responses (used by Google's
resumable upload API) as redirects and raises RedirectMissingLocation
when the response lacks a Location header. Switch to
google.auth.transport.requests.AuthorizedSession + direct HTTP calls
so the upload uses the requests library, which handles 308 correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 07:32:22 +02:00
Bot of Thomas Güttler 5c38357033 fix: limit dagger-data volume growth by pruning named caches (#193) (#197) 2026-05-24 06:00:14 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 7715190cbf fix: retry AAB upload on RedirectMissingLocation with exponential backoff
Adds a 3-attempt retry loop around the resumable AAB upload that catches
httplib2.error.RedirectMissingLocation (a transient network error) and
retries with exponential backoff (10s, 20s). A fresh MediaFileUpload is
created on each attempt because resumable upload objects cannot be reused
after failure. Also adds TestUploadRetry covering the retry path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 05:30:24 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 80cde04d87 fix: retry AAB upload on RedirectMissingLocation with exponential backoff (#186)
Wrap the resumable bundle upload in a loop of up to _MAX_UPLOAD_ATTEMPTS (3)
attempts. On httplib2.error.RedirectMissingLocation, recreate MediaFileUpload
(resumable uploads cannot reuse the same object) and wait 10 s / 20 s before
retrying. After all attempts are exhausted, raise RuntimeError chained to the
last exception. Add tests covering the retry path, backoff delays, fresh
MediaFileUpload on each attempt, and exhaustion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 04:59:05 +02:00
Bot of Thomas Güttler 83060bc1bf fix: add timeout and retries to Play Store upload (#185) (#195) 2026-05-24 04:45:07 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 fb6f2cca68 fix: add timeout and retries to Play Store upload (#185)
Switch deploy_playstore.py from requests/AuthorizedSession to the
googleapiclient.discovery client with google-auth-httplib2, so that
AuthorizedHttp(timeout=300) enforces a hard socket timeout on all
requests and num_retries=3 on every .execute() call enables automatic
retries for transient failures.

Update flake.nix and ci/main.go to install the new dependencies
(google-api-python-client, google-auth-httplib2, httplib2) instead of
the old google-auth + requests pair.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 04:38:36 +02:00
Bot of Thomas Güttler 71ccf24d0c fix: survive permanently broken path_provider channel on Android (#192) (#194) 2026-05-24 03:50:07 +02:00
Bot of Thomas Güttler 4f6f1d9437 fix: migrate to Riverpod 3.x and update dependencies (#175) (#190) 2026-05-23 19:50:11 +02:00
Bot of Thomas Güttler 833e8d49b0 fix: remove continue-on-error from CI workflows (#172) (#189) 2026-05-23 19:05:08 +02:00
Bot of Thomas Güttler 6adba9b001 perf: parallelize APK deploy and reduce fetch-depth in deploy.yml (#171) (#188) 2026-05-23 18:55:08 +02:00
Bot of Thomas Güttler 11d9805fca test: cover _resolveDatabasePath retry logic (#167) (#187) 2026-05-23 18:35:15 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 3019fdf145 refactor(deploy_cron): trigger Forgejo Actions workflow via fgj instead of deploying locally
Replace local `task publish-website` invocation with `fgj actions workflow run website.yml`
so the deploy runs in CI rather than on the local machine. Remove failure-tracking state
files and issue-creation logic — Forgejo Actions handles its own reporting.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 17:42:20 +02:00
Bot of Thomas Güttler 14342f6472 fix: use exact grep patterns for build_runner and flutter pub get (#136) (#159) 2026-05-23 17:25:08 +02:00
Bot of Thomas Güttler b86c1a5c69 fix: verify Hugo binary SHA-256 checksum after download (#162) (#182) 2026-05-23 17:10:11 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 6e22683f5b fix(crash_screen): remove duplicate gitLine definition left by rebase conflict resolution
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 17:02:39 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 dc181d0d85 fix: add git hash to crash screen and extend DB path retries (#179)
Two issues from #179:
- crash_screen.dart now reads GIT_HASH compile-time constant and includes
  'Git Commit: <hash>' in both the on-screen UI and the copied report, so
  crash reports always show the exact build that crashed.
- _resolveDatabasePath() retry delays extended from [100, 300, 600] ms
  (total ~1 s, 4 attempts) to [200, 500, 1000, 2000, 4000] ms (total
  ~7.7 s, 6 attempts) to handle slow/non-standard Android devices where
  the path_provider Pigeon channel takes several seconds to become ready.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 16:53:07 +02:00
Bot of Thomas Güttler e37d8066cb fix: prevent Gradle daemon hang in Android test build (#155) (#178) 2026-05-23 15:45:08 +02:00
Bot of Thomas Güttler 1b1f9788fd docs: explain why continue-on-error is intentional on deploy steps (#154) (#177) 2026-05-23 15:30:14 +02:00
Bot of Thomas Güttler 19d8d282ba fix: show UUID in agent-loop resume command (#152) (#176) 2026-05-23 15:20:08 +02:00
Bot of Thomas Güttler aa59dbb852 feat: show CI run link in 'CI passed' message (#151) (#174) 2026-05-23 15:05:07 +02:00
826488192d fix: update flutter packages (#148) (#165)
## Summary

- Upgrades 9 direct dependencies and their transitive peers to resolve the CI warning: *"38 packages have newer versions incompatible with dependency constraints"*
- Reduces incompatible-version count from **38 → 21** (the remaining 21 are either deliberately pinned, constrained by transitive dep ceilings, or require a separate riverpod 2→3 migration)
- Adapts two source files to breaking API changes in the upgraded packages:
  - `notification_service.dart`: `flutter_local_notifications` 21.x changed positional args to named params (`initialize(settings:…)`, `show(id:…, title:…, body:…, notificationDetails:…)`)
  - `compose_screen.dart`: `file_picker` 12.x removed `FilePicker.platform` static getter; calls are now `FilePicker.pickFiles()`

## Packages changed

| Package | Before | After |
|---|---|---|
| `go_router` | ^14.8.1 | ^17.2.3 |
| `flutter_local_notifications` | ^18.0.1 | ^21.0.0 |
| `file_picker` | ^8.0.0 | ^12.0.0-beta.4 |
| `mobile_scanner` | ^5.0.0 | ^7.2.0 |
| `package_info_plus` | ^8.0.0 | ^10.1.0 |
| `share_plus` | ^12.0.2 | ^13.1.0 |
| `sqlite3_flutter_libs` | ^0.5.28 | ^0.6.0+eol |
| `flutter_lints` | ^4.0.0 | ^6.0.0 |
| `flutter_secure_storage` | 10.2.0 | 10.3.0 (patch) |

## Test plan

- [x] `flutter analyze` — no issues
- [x] Unit tests (324 passed)
- [x] Widget tests (116 passed)
- [ ] CI full check suite

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Thomas SharedInbox <sharedinbox@thomas-guettler.de>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/165
2026-05-23 14:58:54 +02:00
Bot of Thomas Güttler d683da9c59 docs: credential security options — four solutions for keeping production secrets off Codeberg (#141) (#173) 2026-05-23 14:50:12 +02:00
77fc6964f6 fix: extend path_provider retry budget on slow Android devices (#166) (#169)
## Summary

- Increases the retry delays in `_resolveDatabasePath()` from `[100, 300, 600]` ms (~1 s) to `[200, 500, 1000, 2000]` ms (~3.7 s).
- Adds a regression test (`test/unit/database_path_test.dart`) that verifies `initDatabasePath()` does not throw when the `path_provider` channel is unavailable.

## Root cause

On some slow Android devices (e.g. the Motorola reported in #166), the `path_provider` Pigeon channel is not ready even several seconds after `runApp()` returns. The previous back-off budget of ~1 s was not enough, causing `_resolveDatabasePath()` to exhaust all retries and throw a `PlatformException`, crashing the app with the message shown in the issue.

## Test plan

- [ ] `flutter test test/unit/database_path_test.dart` passes (new regression test)
- [ ] `flutter test test/unit/` — all 325 unit tests pass
- [ ] `flutter analyze` — no issues

Fixes #166

Co-authored-by: Thomas SharedInbox <sharedinbox@thomas-guettler.de>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/169
2026-05-23 14:40:17 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 47824c5711 Handle transient git fetch failures gracefully
Exit cleanly instead of crashing so the next cron run retries.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 14:13:14 +02:00
Bot of Thomas Güttler a6c231f293 feat: show git commit link on crash screen (#150) (#170) 2026-05-23 13:45:08 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 5ad6599951 fix(agent_loop): match CI run to PR branch via event_payload, not head_branch
The Forgejo workflow_runs API has no head_branch field.  For pull_request
events the branch lives in event_payload["pull_request"]["head"]["ref"];
for push events it is in prettyref.  The old code used run.get("head_branch")
which always returned None, causing _latest_ci_run_for_branch to never find
the run and the loop to declare "no CI run after 15 min" and set the issue to
State/Question — even when CI had already passed.

Also fixes a pre-existing test mock that was missing the session_name kwarg.
Adds TestLatestCiRunForBranch covering both event types and the regression.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 13:36:21 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 49176623b3 fix(ci): use file: prefix for SSH key in publish-website
env:SSH_PRIVATE_KEY passes the key through shell $() which strips the
trailing newline, causing dagger to write a truncated key that OpenSSH
rejects with "error in libcrypto". Using file: reads it directly from
disk, preserving exact content.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 12:20:09 +02:00
Thomas SharedInbox 55c15177d8 fix(publish-website): survive SSH failure in generate_build_history (#164)
The Dagger container running generate_build_history.py may not always
reach the deployment server (network constraints on the Dagger engine).
Rather than aborting the entire publish-website pipeline, log the SSH
verbose output (already added in the previous debug commit) and return
an empty file list so Hugo still builds and rsync still deploys the
site — just without updated build-history pages.

This unblocks the cron deploy that has been failing since c259d2da.
2026-05-23 12:17:58 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 54cd6623c4 debug(ci): add ssh -v to generate_build_history for exit-255 diagnosis
Temporary: print verbose SSH output on failure to identify why the
connection fails from inside the dagger container.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 12:13:26 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 7e234b4835 fix(ci): chmod 700 /root/.ssh in GenerateBuildHistory container
Dagger mounts the secret file with 0600 but the parent directory may
get created with world-readable permissions, causing SSH to refuse
the key with exit 255.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 12:09:35 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 565b6f8e33 fix(publish-website): add -i to ssh call in generate_build_history.py
All other ssh/scp calls in the dagger module use explicit -i /root/.ssh/id_ed25519.
This one was missing it, causing exit 255 inside the dagger container.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 12:02:30 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 bf3accd676 deploy.sh: read SSH_PRIVATE_KEY from key file, not .env
Dagger parses .env directly and fails on multiline quoted values.
Move SSH_PRIVATE_KEY out of .env and export it from ~/.ssh/id_ed25519
in the wrapper instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 11:47:48 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 57902e8218 deploy: give up and open issue after 5 failures on same commit
Tracks consecutive failure count in .fail_count. On the 5th failure
for the same SHA, creates a Prio/High + State/Ready Codeberg issue.
Before creating, checks local .last_issue_sha and queries Codeberg
open issues to avoid duplicates.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 11:37:57 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 c259d2dabe deploy: create Codeberg issue when deploy fails and main is unchanged
If the last deploy failed and origin/main has not advanced, opens a
Prio/High + State/Ready issue via tea with the failing SHA, commit link,
and captured deploy output. Skips duplicate issues (tracked by
.last_issue_sha). Cron interval changed to */5.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 11:24:21 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 8d49a6b267 deploy.sh: source .env, add dagger to PATH from nix store
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 11:18:44 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 eecef1a4a8 add deploy.sh wrapper: finds task via nix store, short crontab line
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 11:17:30 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 ad150bce53 add deploy_cron.py: local 15-min cron deploy, skip if main unchanged
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 11:07:41 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 b6a2f91820 security: fix log/state file permissions, Firebase key on disk, TLS cleanup
- agent_loop.py: create log dir with mode 0700 and enforce it on
  existing dirs; open log files with mode 0600; chmod state file
  to 0600 after every write. Prevents other local processes from
  reading agent output (which may contain credential paths) or
  tampering with the state file's pid field.

- ci/main.go (TestAndroidFirebase): replace
    echo "$FIREBASE_SA_KEY" > /tmp/key.json
  with bash process substitution
    --key-file=<(echo "$FIREBASE_SA_KEY")
  The key is now passed via a file descriptor — it never touches
  disk, so it cannot be stranded by a failed gcloud auth call or
  snapshotted into the Dagger layer cache.

- ci.yml / deploy.yml: add "Cleanup TLS credentials" step
  (if: always()) at the end of every job that calls
  setup_dagger_remote.sh. Removes /tmp/dagger-tls,
  /tmp/stunnel-dagger.conf, /tmp/stunnel.pid from the self-hosted
  runner after each job, so client certs do not accumulate between
  job runs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 10:54:53 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 509a0bc954 fix(ci): remove Gradle cache mount from pubGetLayer()
flutter pub get is pure Dart — it never invokes Gradle. The mutable
gradle-cache volume mount caused the same execution-cache instability
we just fixed for the pub cache: Dagger sees a changed volume and
cache-misses pubGetLayer() on every run.

The Gradle cache stays in Base(), which is only used for steps that
actually build Android code.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 10:15:39 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 6cfc3dfda4 fix(ci): remove pub cache volume from Base() and pubGetLayer()
The mutable flutter-pub-cache volume made the execution cache key unstable —
pub get cache-missed every run because the volume's mutable layer changed the
snapshot hash.  Removing the volume lets Dagger snapshot packages inside the
execution-cache layer, which is stable and reclaimable via dagger prune.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 10:11:08 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 8a4ca223e9 fix: retry path_provider on PlatformException at database open (#153, #157)
On some Android versions the path_provider Pigeon channel
('dev.flutter.pigeon.path_provider_android.PathProviderApi.getApplicationSupportPath')
is not ready when initDatabasePath() runs before runApp().  The existing code
already catches PlatformException there, leaving _dbPath null — but the
LazyDatabase callback called getApplicationSupportDirectory() a second time
without any protection, causing an unhandled crash on those devices.

Fix: extract _resolveDatabasePath() which retries three times with back-off
(100 ms → 300 ms → 600 ms) before re-throwing with a descriptive error
message. By the time the database is first accessed (after runApp()), the
channel is almost always available; if it still isn't, the CrashScreen is
shown with a clear explanation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 10:08:04 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 1a7b585dd4 fix(agent-loop): filter issues by author; comment when setting State/Question (#158)
- Only pick up issues created by guettli, guettlibot, or guettlibot2
  to prevent the loop from acting on external/bot issues.
- Post an explanatory comment on the issue whenever the loop sets
  State/Question (agent killed, no CI run, no push detected), so the
  reason is visible without digging through cron logs. Closes #158.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 10:04:44 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 959ce92a69 fix(ci): drop false-positive 'error' grep in Firebase test check
Firebase CLI emits "A non-retryable error occurred." even for passing runs.
The grep -qwi 'error' triggered on this message despite gcloud exiting 0
and the result table showing Passed. The gcloud exit code, device-count,
and Passed checks are sufficient to detect real failures.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 23:22:25 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 9cd18ba70e feat: agent loop uses PRs; ci.yml fast-only; hourly deploy workflow (#156)
- agent_loop.py: agents now create an `issue-N-fix` branch and open a PR;
  the loop discovers the PR via `fgj pr list`, tracks its CI run, squash-merges
  on green, and falls back to the global-CI path if no PR exists (backward compat).
  Adds `_find_pr_for_branch`, `_latest_ci_run_for_branch`, `_merge_pr` helpers.

- .forgejo/workflows/ci.yml: strip to the single fast `check` job only
  (removes build-linux, deploy-playstore, publish-website).

- .forgejo/workflows/deploy.yml (new, replaces android-emulator-tests.yml):
  scheduled hourly + workflow_dispatch; runs firebase tests, Play Store deploy,
  Linux build/deploy, website publish; on completion sets CI/Full-Pass or
  CI/Full-Fail label on the repo's DEPLOY_HEALTH_ISSUE tracking issue.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 22:05:09 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 b48cb98813 fix(agent-loop): detect agent crash — do not close issue when no new CI run appeared
If the agent exits immediately (e.g. rate-limit), the loop was closing the
pending issue against the *previous* CI run, which was still green.

Fix: record the latest CI run ID when an issue agent starts. If the run ID
hasn't changed when the agent exits, the agent pushed nothing → set
State/Question instead of closing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 21:52:02 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 acd9483e8b chore: replace flutter_markdown with flutter_markdown_plus (#147)
flutter_markdown 0.7.7+1 has been discontinued in favour of
flutter_markdown_plus. Switch the dependency and update both import
sites.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 16:44:10 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 7e3a63f507 ci: validate gcloud auth stderr, fail on 'error' in output, check test count (#145)
- Capture gcloud auth stderr separately and fail on unexpected output;
  ignore the two known informational lines ("Activated service account
  credentials for: [...]" and "Updated property [core/project].") while
  keeping a strict "fail if unknown stderr" check for anything else.
- Replace the narrow pattern grep (non-retryable error|infrastructure_failure|
  test execution failed) with a broad whole-word case-insensitive grep for
  'error', so any infrastructure or Firebase error in the output causes CI
  failure.
- Verify that the number of device result rows in the result table matches
  the expected device count (1), so a silent test-run failure cannot slip
  through.
- Add scripts/test_firebase_check.sh with 18 unit tests for the three new
  bash patterns (auth stderr filter, error-word detection, device count).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 16:31:14 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 ea712bdda9 docs: document dagger.Secret usage for sensitive credentials (#142)
All production secrets (SSH key, Android keystore, Play Store config,
Firebase service account) are already typed as dagger.Secret and injected
via WithMountedSecret / WithSecretVariable. Add a Secrets section to
DAGGER.md to make this explicit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 16:07:21 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 e057e1f483 fix: set Owner: "ci" on gradle and pub cache mounts
The gradle-cache volume was mounted without an owner, so the root-owned
volume caused "Permission denied" when the ci user tried to create
gradle-8.14-all.zip.lck during bundleRelease. Add Owner: "ci" to all
three WithMountedCache calls so the ci user can write to the caches.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 15:55:30 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 cc51abd1fa fix: reduce CI noise from apt-get, sdkmanager, stunnel, and Gradle (#140)
- Add -qq to apt-get update/install in Dagger toolchain to suppress
  verbose package-list output (hundreds of lines on cold cache)
- Wrap sdkmanager in silent-on-success pattern — only shows output
  on failure, like the build_runner and flutter pub get steps
- Set debug = warning in stunnel config to suppress LOG5 (info/notice)
  startup lines while keeping LOG4 (warning) and above
- Add org.gradle.welcome=never to android/gradle.properties to
  suppress the "Welcome to Gradle N.NN!" banner
- Filter SKIPPED Gradle tasks, Gradle Daemon startup messages, and
  gcloud support-page promo lines in run_firebase_test.sh

Errors and warnings are preserved in all cases.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 15:37:12 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 9e4a36b330 fix: drop -u 1000 from useradd in Dagger toolchain — UID already taken in flutter image
The cirruslabs/flutter:3.41.6 image already has UID 1000 assigned to
another user, so `useradd -u 1000` exits with code 4 ("UID not unique")
and the ci user is never created. Dagger then fails to resolve `owner:
"ci"` on subsequent WithDirectory calls. Removing the explicit UID lets
useradd pick the next available one.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 15:19:05 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 f9a5aa0372 fix: do not run Flutter as root in CI (#138)
Create a non-root user 'ci' (UID 1000) in the Dagger toolchain container,
transfer ownership of the Flutter SDK and Android SDK to that user, and
switch to it with WithUser("ci"). Update all cache mount paths from /root/
to /home/ci/ and set Owner: "ci" on every WithDirectory call so Flutter
can write build output. Flutter emits a strong warning when run as root;
this change eliminates that warning by running the tool as a regular user.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 15:09:42 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 a1cd31a2eb fix: survive PlatformException(channel-error) in registerBackgroundSync (#149)
On some Android devices (e.g. Android S1RXS32.50-13-25) the WorkManager
platform channel fails to connect at startup, throwing
PlatformException(channel-error, ...).  registerBackgroundSync() now catches
PlatformException and MissingPluginException (plus any other unexpected
failure) and silently disables background sync rather than crashing the app.

Test added: test/unit/background_sync_test.dart verifies the function
completes without throwing in the unit-test environment (where the native
plugin is absent).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 14:23:40 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 78b3d40a70 fix(agent-loop): use fgj for writes; tea api silently ignores auth errors
`tea api` exits 0 even on 401 responses, so `_close_issue` and
`_set_labels` appeared to succeed but did nothing. Issues were never
actually closed, causing them to be picked up again every cron tick.

Switch all write operations (close issue, set labels) and issue-list
reads to `fgj`, which has proper authentication. Keep `tea api` only
for CI run fetches where `fgj` times out (504). Add ~/go/bin to the
cron PATH so fgj is found.

Also add an error check in `_tea_get` for API-level error responses,
and strip State/InProgress when closing an issue.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 14:22:07 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 f7d021c62a fix: survive MissingPluginException on startup, fix crash report URL (#146)
Two fixes:

1. notification_service.dart: initNotifications() now catches
   MissingPluginException (and any other init failure) so the app no
   longer crashes when flutter_local_notifications is unavailable on
   some Android devices.  _initialized tracks success; showNewMailNotification
   skips the plugin call when it never initialised.

2. crash_screen.dart: "Report Issue on Codeberg" no longer puts the full
   report in the URL query string.  Long stack traces exceeded browser
   URL-length limits and caused "create issue failed".  The URL now
   carries only the pre-filled title; the user copies the full report
   via "Copy to Clipboard" and pastes it in the issue body.

Tests added:
- test/unit/notification_service_test.dart: verifies initNotifications()
  completes without throwing when the plugin channel is unavailable.
- test/widget/crash_screen_test.dart: verifies the Codeberg URL contains
  the title but no &body= parameter.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 13:01:34 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 ea52e89934 fix: run build_runner once via shared codegenBase, fix CheckMocks staleness detection (#137)
Previously build_runner compiled separately for each setup() variant
(checkSrc, backendSrc, integrationSrc, etc.) since their differing
source inputs produced distinct Dagger cache keys. CheckMocks also ran
build_runner twice: once inside setup() and again explicitly — and the
second run always compared two freshly-generated outputs, so stale mocks
in the repo were never detected.

Introduce codegenBase() that runs build_runner on the minimal common
source (lib/, test/, assets/, pubspec.*) excluding committed generated
files. All setup() calls now share this single Dagger cache entry, so
build_runner compiles only once per pipeline run instead of once per
source variant.

Fix CheckMocks to start from pubGetLayer() + committed source (including
any stale *.mocks.dart), commit that state as the git baseline, then run
build_runner once. The subsequent git diff now correctly detects stale
mocks in the repository, matching the behaviour of check_mocks_fresh.sh.

Also update Graph() to reflect the new codegenBase node.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 12:23:52 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 d72df5086c feat: close issues in Python loop after CI passes, not in agent (#134)
Previously issue agents were instructed to close the issue via prompt text
immediately after pushing. If CI then failed, the issue was already closed.

Now the loop tracks a pending_issue across cron ticks:
- When an agent finishes (issue or ci-fix), the issue number is extracted
  from state before it is cleared.
- If CI is still running, a "pending-ci" state preserves the issue number.
- If CI fails, the ci-fix agent is started with the issue number in state
  so it survives the fix cycle.
- Once CI passes, _close_issue() is called from Python — never by the agent.

The agent prompt no longer instructs the agent to close the issue.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 12:02:16 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 e46dc2961f feat(agent-loop): improve output format with header, URLs, and no prefix (#133)
- Add `---------------------- Starting YYYY-MM-DD HH:MMZ` header at each run
- Remove `[agent_loop]` prefix from all output lines
- Show full Codeberg URL for CI runs instead of bare run ID
- Show full issue URL and title when referencing issues
- Store issue_title in state file so "still running" messages include the title

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 11:50:30 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 3bd38e7a69 fix(agent-loop): update AGENTS.md and fix test invocation for InProgress workflow (#131)
State/Ready → State/InProgress is already set by agent_loop.py before
the agent starts. Update AGENTS.md to reflect that agents invoked via
the loop must not set InProgress themselves (only manual workflows need
to). Also fix TestMain tests that called main() directly, which caused
argparse to consume sys.argv; they now call _run_loop() instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 11:41:28 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 d36d9a679d fix: fail Android CI when gcloud reports non-retryable error (#143)
Previously, `gcloud firebase test android run` could exit 0 while printing
"A non-retryable error occurred." in its output. The old check
`&& echo "$out" || { exit 1; }` only caught non-zero exit codes, and the
success grep `'Passed|passed|test cases'` was too broad — "test cases" can
appear in Firebase output before the error, giving a false positive.

The fix captures gcloud's exit code explicitly via `rc=$?`, adds an explicit
error-string check for known Firebase failure phrases (non-retryable error,
infrastructure_failure, test execution failed), and tightens the success
pattern to `'Passed|passed'` only.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 11:30:56 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 23cbe4611c fix: resolve startup crash and CrashScreen button crashes (#127)
Two bugs caused the crash-at-startup report:

1. CrashScreen used the widget's build context (above its own MaterialApp)
   for ScaffoldMessenger.of() in button callbacks. When the screen is the
   root widget — the runApp() path after a startup crash — there is no
   ScaffoldMessenger above it, so both 'Copy to Clipboard' and 'Report Issue
   on Codeberg' crashed with a null check error. Fix: wrap Scaffold.body in
   Builder to obtain a context that is a descendant of the Scaffold.

2. path_provider_android 2.2.21 updated to Pigeon 26, which causes a
   channel-error on startup for some Android devices. Pin to <2.2.21
   (resolves to 2.2.20, which uses the stable pre-Pigeon-26 implementation).
   Additionally, make initDatabasePath() catch PlatformException so a
   channel error at the very start of main() no longer hard-crashes the app;
   _openConnection()'s lazy fallback retries after runApp() completes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 11:16:09 +02:00
Thomas SharedInbox c4e7042430 agent-loop: pick Prio/High issues first among Ready issues 2026-05-22 10:54:27 +02:00
Thomas SharedInbox f30c5076da docs 2026-05-22 10:16:19 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 ee4f93752d ci: check runner tools are pre-installed instead of downloading them
Replace curl-based install of dagger/task with a hard check that
fails immediately if any tool is missing from the runner image,
pointing to .forgejo/Dockerfile as the fix location.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 10:07:55 +02:00
Thomas SharedInbox 19771a2060 docs 2026-05-22 10:02:36 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 e6baaaed74 ci: add Dockerfile for custom runner image
Based on ghcr.io/catthehacker/ubuntu:go-24.04 with stunnel4,
netcat-openbsd, dagger v0.20.8 and task v3.48.0 baked in so
nothing is downloaded during CI runs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 09:51:35 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 92f3e30e00 ci: fail if Firebase Test Lab reports no test case results
gcloud exits 0 even when no tests ran. Add a post-check that greps
the output for 'Passed/passed/test cases' and fails explicitly if
none are found, so 'no test case results' turns the CI red.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 08:58:09 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 ec195271c8 test: fail explicitly when Stalwart env vars are missing
Previously setUpAll() fell back to 127.0.0.1 defaults when env vars
were absent, causing Firebase Test Lab to report '0 test case results'
instead of a clear failure. Now it calls fail() immediately with the
list of missing variables.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 08:52:45 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 7936bf0a47 ci: require stunnel4/netcat-openbsd pre-installed on runner host
Replace apt-get install with a hard check — if the packages are missing
the job fails immediately with a clear error. Avoids flaky failures when
archive.ubuntu.com is unreachable.

Install once on the runner: sudo apt-get install -y stunnel4 netcat-openbsd

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 08:43:19 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 44d6227ba8 chore: track pubspec.lock and pin sqlite3 to ^3.1.5
pubspec.lock was incorrectly gitignored — this is a Flutter app, not a
package, so the lockfile should be committed for reproducible builds.
Without it, CI resolved drift to its minimum (2.20.3) which constrains
sqlite3 to 2.x, causing dart analyze to disagree on whether
Database.close() exists vs the local environment using 3.3.1.

Also pins sqlite3: ^3.1.5 explicitly in pubspec.yaml as belt-and-
suspenders so the constraint is visible without reading the lockfile.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 08:19:14 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 cd7455d3a5 ci: remove unnecessary CACHE_BUSTER from Firebase step
The results-bucket change already busts the cache; Dagger doesn't
cache failed execs anyway.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 07:43:13 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 f047dd34ea ci: use project-owned bucket for Firebase Test Lab results
The default Firebase Test Lab bucket is in a Google-managed project so
project-level IAM grants have no effect on it. Use sharedinbox-ftl-results
which is in sharedinbox-496103 where the service account has storage.admin.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 07:32:09 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 cc34b9b4b6 ci: retrigger Firebase Test Lab after billing enabled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 07:24:26 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 8278b2f33c ci: retrigger Firebase Test Lab after cloudtestservice.testAdmin grant
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 06:15:12 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 357f6e194c ci: bust Dagger cache for Firebase Test Lab step
WithEnvVariable(CACHE_BUSTER, time.Now()) ensures gcloud firebase test
always runs fresh rather than returning a cached result from a prior run.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 06:08:36 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 bf769db4dd ci: retrigger Firebase Test Lab after IAM fix
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 06:05:47 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 47ab77feea ci: retrigger Firebase Test Lab
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 21:46:36 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 12c95537f0 ci: retrigger Firebase Test Lab after Dagger engine restart
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 21:39:11 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 bcd87c642d Add retry logic to run_firebase_test.sh for transient Dagger errors
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 21:23:12 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 24f479b0ad Filter Gradle/Dagger noise from Firebase Test Lab CI output
Add scripts/run_firebase_test.sh that strips ANSI codes and removes
UP-TO-DATE task lines, libsqlite warnings, Gradle deprecation notices
and other high-volume noise before it hits the CI log.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 21:21:04 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 4f663dd0c8 ci: retrigger Firebase Test Lab
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 20:39:55 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 e44aabe210 ci: retrigger Firebase Test Lab after granting storage.admin role
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 20:32:59 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 3b90d42389 ci: retrigger Firebase Test Lab after enabling Cloud Tool Results API
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 19:56:47 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 1991508a8b Fix Firebase Test Lab device model ID: Pixel6 -> oriole
'Pixel6' is not a valid Firebase Test Lab model ID.
'oriole' is the correct internal codename for Pixel 6.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 18:58:56 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 cf674009ee ci: retrigger Firebase Test Lab after fixing project ID and enabling APIs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 18:54:20 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 569c8b2e7a ci: retrigger Firebase Test Lab after enabling Cloud Testing API
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 18:21:14 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 689ce8721d Fix androidTest APK search path — Flutter redirects Gradle output to /src/build
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 17:40:17 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 6bb191ee99 Fix androidTest APK path using find instead of hardcoded path
The exact output path varies by AGP version. Use find to locate the
test APK and copy it to a known location.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 17:34:41 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 01cbf5b805 Add Firebase Test Lab integration for Android instrumented tests
Implements issue #132. Builds debug app APK + androidTest APK via Dagger,
then runs them on Firebase Test Lab using the FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY
secret and FIREBASE_PROJECT_ID variable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 17:20:26 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 2e080dd4ed fix(ci): remove SIGKILL fallback from check-dagger cleanup
The GET /shutdown endpoint on otel-receiver.py is the one clean shutdown
path. cleanup() only needs to remove temp files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 15:24:11 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 041e496e58 fix(ci): rename otelrecv→otel-receiver, fix teardown hang
Rename ci/otelrecv.py to ci/otel-receiver.py for readability.

Replace SIGTERM+wait shutdown (which could hang indefinitely) with an
HTTP-based approach: add GET /shutdown to otel-receiver.py that calls
self.server.shutdown() directly. After dagger call returns, curl that
endpoint so the receiver prints its timing report and exits cleanly.
Cleanup is reduced to a SIGKILL fallback in case the process is already
gone.

Also fix the do_GET handler to reference self.server instead of the
local variable server, which was inaccessible from the handler class.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 15:18:34 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 f2d24a8514 fix(ci): reduce noise in CI output (#128)
- Filter flutter pub get package-listing lines (^[+~><] ) in pubGetLayer
- Filter build_runner compilation-progress lines (^\[) in setup() and CheckMocks()
- Add -q to git commit in CheckMocks to suppress "460 files changed" stats
- Wrap flutter test in Coverage, TestBackend, TestIntegration, TestSyncReliability
  to show only the summary line on success and full output on failure
- Apply same build_runner filter to scripts/check_mocks_fresh.sh for local runs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 14:51:56 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 9dc34cefe5 ci: add 30-minute Dagger-side timeout to Check pipeline
If any step hangs (stuck service, deadlocked test, network stall), the
pipeline will now cancel itself after 30 min rather than blocking the
runner indefinitely.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 11:53:49 +02:00
Thomas SharedInbox f315c21c9a add "list" sub-command to agent-loop to resume via UUID. 2026-05-21 11:49:32 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 541c1a0b53 fix(ci): reduce noise in CI output (#128)
Remove per-request debug logs from otelrecv.py (POST, decoding,
decoded, 200 sent, signal) that were added to diagnose the CI hang,
which has since been resolved.

Remove verbose [HH:MM:SS] timestamp messages from check-dagger
(start, pipeline done, otelrecv started/ready, final RC, cleanup
start/done) for the same reason.

Fix cleanup to send SIGTERM + wait instead of SIGKILL so the OTEL
timing report is actually printed at the end of each CI run.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 10:45:40 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 34fb51d85d feat(ci): add Graph() to visualize CI pipeline as Mermaid diagram (#126)
Adds a Ci.Graph() Dagger function that emits a Mermaid flowchart showing
both the Dagger Check pipeline (toolchain → pubGetLayer → parallel steps)
and the Codeberg CI job dependencies (check → build-linux / deploy-playstore
→ publish-website).

Usage: dagger call -m ci --source=. graph
       task ci-graph

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 10:28:28 +02:00
Thomas SharedInbox 58f1a4da42 feat(website): vendor PaperMod theme, remove git submodule (#125)
Replace the git submodule with directly tracked files so that
`git commit .` no longer fails with 'does not have a commit
checked out'. Removed .github/ from the vendored copy since
upstream CI workflows are not needed here.
2026-05-21 10:21:09 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 07823373c1 fix(ci): add withGoCache helper and pip cache for UploadToPlayStore
Adds withGoCache() that mounts GOCACHE and GOMODCACHE as Dagger cache
volumes — the standard pattern for any Go container added to the pipeline.
Also adds pip cache to UploadToPlayStore so pip wheel downloads are reused
between Play Store deploys.

Closes #123

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 06:41:04 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 1af2a36af7 fix(ci): remove pub cache volume from pubGetLayer for stable execution cache
flutter pub get was re-running on every CI run because Base() attached a
mutable WithMountedCache volume to /root/.pub-cache, making the execution
cache key unstable. Extract toolchain() without cache mounts; pubGetLayer()
now uses toolchain() so Dagger execution-caches pub get between runs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 06:35:14 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 0cb181b138 fix(ci): remove wait from otelrecv cleanup; add pkill by name as fallback
wait "$RECV_PID" was blocking despite kill -9 (possibly because $RECV_PID
was garbled by ANSI escape codes from dagger output, making kill target the
wrong PID). Fix:
- Remove wait entirely — zombie is reaped when the shell exits
- Add pkill -9 -f otelrecv.py as fallback in case kill-by-PID misses
- Log PID at capture time to verify correctness in CI logs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 21:09:24 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 320fbcabc3 fix(ci): kill otelrecv with SIGKILL in cleanup, add timing logs, re-enable OTEL
Three changes:
- cleanup() now uses kill -9 instead of kill (SIGTERM) to prevent wait hanging
  if otelrecv's signal handler stalls
- adds [HH:MM:SS] log lines at key points so CI logs show exactly where time is spent
- restores OTEL env vars (via env VAR=val) since they were confirmed not to cause the hang

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 21:00:20 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 f187012f58 debug(ci): temporarily disable OTEL env vars to test if they cause dagger hang
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 20:31:48 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 d4b265724e fix(otelrecv): set close_connection=True so server actually closes after response
Sending Connection: close in the header without closing the server-side
socket left both dagger's Go HTTP client and Python's HTTPServer waiting
for the other to send FIN first. This blocked dagger's OTLP exporter
shutdown, which in turn blocked dagger from exiting.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 20:14:27 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 36b54a08d6 fix(ci): use --kill-after=10 on timeout so dagger is SIGKILLed if it ignores SIGTERM
dagger ignores SIGTERM, keeping the pipe's write end open; tee can never
get EOF and the script hangs. --kill-after=10 follows up with SIGKILL which
closes the pipe and unblocks the script.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 20:02:44 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 4a99d47aa5 fix(ci): add TCP keepalive to stunnel to prevent NAT connection resets
Connection drops consistently at ~50s suggest NAT/firewall idle timeout.
Keepalive probes every 10s on the remote side prevent the RST.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 19:43:16 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 c3737fb47f fix(ci): retry dagger call on TCP connection failures (up to 3 attempts)
On network errors (connection reset, context canceled, connection refused)
retry the dagger call rather than failing immediately. Real test failures
propagate without retry.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 18:47:38 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 88e8a9ab5c fix(ci): add 10-minute timeout to dagger call; treat teardown hang as success
dagger call hangs after function completion due to HTTP/2 teardown bug in
remote engine mode. Capture output via tee; if timeout fires but output
contains "All tests passed", exit 0 instead of 124.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 16:38:33 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 a078122d28 refactor(ci): replace dual DAGGER_STUNNEL_URL1/2 with single DAGGER_STUNNEL_URL
The engine is stable; no fallback needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 15:48:38 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 e60459ea2e fix(ci): add .task/ and .fvm/ to .daggerignore to skip walk
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 13:52:19 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 92cc725913 refactor: simplify .daggerignore and fix hardcoded path after repo move to sharedinbox/
.daggerignore no longer needs to exclude $HOME dirs (fvm/, go/, .pub-cache/,
.claude/, snap/, etc.) since the project root is now sharedinbox/, not $HOME.
agent_loop.py: replace hardcoded /home/si with Path.home().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 13:43:29 +02:00
Thomas SharedInbox bd03484fcc Revert "fix(ci): kill dagger via timeout when it hangs in gRPC teardown"
This reverts commit 7e155f5785.
2026-05-20 13:11:07 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 242e1ce4a4 fix(ci): exclude fvm/ and other large dirs from Dagger source sync
The source sync (Directory.Sync in selectFunc) was uploading ~7.4 GB /
78k files to the remote engine, blocking dagger call for 16+ minutes.

Root cause: .daggerignore had '.fvm/' but the actual directory is 'fvm/'
(no leading dot), so the 1.9 GB Flutter SDK cache was always uploaded.
Also missing: go/ pkg cache (309 MB), .claude/ session files, agent logs.

goroutine dump confirmed the hang in directoryValue.Get → Directory.Sync
→ HTTP/2 roundTrip waiting on the engine — not gRPC teardown as suspected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 13:04:53 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 7e155f5785 fix(ci): kill dagger via timeout when it hangs in gRPC teardown
After tests complete, dagger call hangs in gRPC connection close to the
remote engine — OTEL shuts down cleanly (spans stop) but the process
never exits. Wrapping with timeout 900s and treating exit 124 as success
unblocks CI and lets the OTEL timing report print.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 12:36:13 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 95d114cc38 debug(otelrecv): add stderr logging to diagnose CI hang
Log each POST request, decode step, 200 response, signal receipt, and
server shutdown to understand where the hang occurs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 12:22:04 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 d5e3974d94 fix(otelrecv): send explicit Content-Length + Connection: close
Without Content-Length the Go HTTP/1.1 client can't tell the response
body is empty, causing dagger call to hang waiting for more data.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 12:07:57 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 1c27dc4f71 fix(ci): use http/protobuf OTEL protocol with binary protobuf receiver
http/json is not supported by the Go OTEL SDK used in Dagger v0.20.8.
Switch to http/protobuf (the SDK default) and rewrite the Python receiver
to decode binary protobuf using stdlib struct — no pip required.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 11:46:58 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 691f2beec2 fix(ci): switch timing from OTEL receiver to --progress=plain pipe filter
Dagger v0.20.8 only supports 'grpc' and 'http/protobuf' OTLP protocols;
'http/json' triggers a WARN and exports nothing.  The new approach pipes
dagger's --progress=plain output through a Python script that echoes it
in real-time and prints a timing table at EOF.  No HTTP server, no port
files, no protocol issues — works locally and in CI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 11:43:26 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 ac2178916e refactor(ci): replace Go OTEL receiver with Python (stdlib, no deps)
python3 is pre-installed on ubuntu-latest so the timing report now also
runs in CI, not just locally.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 11:30:08 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 b10696a41e fix(ci): remove tmp timing file — receiver writes directly to stdout
TIMINGFILE=$(mktemp) was an unnecessary /tmp path. The receiver already
prints its report to stdout on shutdown; wait $RECV_PID captures it in
place. Only PORTFILE remains in /tmp (unique via mktemp, deleted in cleanup).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 10:38:26 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 3471e1fd2c feat(ci): OTEL timing receiver for check-dagger
Adds ci/otelrecv/main.go — a minimal OTLP HTTP/JSON trace receiver that
listens on a random port (port 0) so parallel runs never collide.

The check-dagger Taskfile task now starts the receiver in the background,
passes the port via a mktemp file, runs dagger with OTEL env vars set,
then prints a per-span timing report on shutdown. Falls back to plain
dagger call when Go is not available (e.g. CI containers without Go).

First run will show raw attribute keys so we can learn Dagger's exact
telemetry format and refine the cached/live detection logic.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 10:27:57 +02:00
Thomas SharedInbox f23328fd1f ci: empty commit to verify cache stability 2026-05-20 09:39:47 +02:00
Thomas SharedInbox 41ac45a92e ci: empty commit to retry after stunnel fix 2026-05-20 09:18:25 +02:00
Thomas SharedInbox c090a320f6 ci: empty commit to verify cache after disk cleanup + parallelization 2026-05-20 09:07:57 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 2748517d1c feat(ci): run TestBackend and TestIntegration in parallel
Saves ~1 minute on every CI run by starting the integration test build
concurrently with the backend Stalwart tests instead of waiting for them
to finish first.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 08:58:55 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 2fd82eadc4 ci: empty commit to verify dart analyze + unit test caching
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 00:09:39 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 e0a99d7d63 fix(ci): remove --no-pub from integration tests; use dart analyze instead of flutter analyze
Integration tests build native Linux app via CMake which requires pub get side effects
(plugin registrant file generation) — --no-pub broke the CMake step.

Switch flutter analyze to dart analyze --fatal-infos to eliminate the flutter wrapper's
non-deterministic state writes to /root/.dartServer/, which were preventing action cache
hits on the analyze step.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 23:57:25 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 7fce683375 fix(ci): add --no-pub to flutter analyze and flutter test execs
Without --no-pub, flutter re-runs pub get internally before each
analyze/test call, writing a fresh package_config.json with new
timestamps. This makes the exec output snapshot non-deterministic
and prevents BuildKit from caching the result across CI runs.

With --no-pub, flutter uses the package_config.json already produced
by pubGetLayer(), and the exec output is stable → persistent cache hits.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 23:30:47 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 d369c26470 ci: empty commit to verify cache propagation to dart format and analyze
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 23:19:19 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 ef4bcd1eeb ci: empty commit to verify cache stability after dart-tool-build mount removal
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 23:03:26 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 39204fabcd fix(ci): remove dart-tool-build cache mount from setup()
Shared mutable cache mounts prevent BuildKit from persistently caching
the exec result across sessions. Without the mount, build_runner output
is stored in the content-addressed snapshot and survives GC cycles,
allowing downstream analyze/test steps to also be stably cached.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 22:46:39 +02:00
Thomas SharedInbox b2a15aee09 ci: empty commit 2026-05-19 22:25:31 +02:00
Thomas SharedInbox 8f66047a2e ci: empty commit — run 369, verify full caching under 50GB reservedSpace 2026-05-19 22:23:54 +02:00
Thomas SharedInbox 4eb06d487a ci: empty commit — verify snapshot retention under 50GB reservedSpace 2026-05-19 22:11:26 +02:00
Thomas SharedInbox 07d90f7d50 ci: empty commit to verify pub-get exec-cache survival after run-365 crash 2026-05-19 19:11:55 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 b090342637 fix(ci): revert bad /root/.flutter cache mount — it is a file, not a directory
WithMountedCache requires a directory. /root/.flutter in the cirruslabs/flutter
image is a plain text file (Flutter SDK marker), causing "not a directory" at
container startup. Reverts to the pre-365 Base() so run-364 exec cache entries
are still valid.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 19:11:40 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 6dcefa856e fix(ci): mount /root/.flutter as cache volume to keep pub-get snapshot small
Flutter writes tool state to /root/.flutter on every invocation. Without a
cache mount this ends up in the pub-get snapshot, making it large and prone
to GC eviction. Moving it to a cache volume keeps the snapshot tiny so
BuildKit's exec cache for pub get survives between CI runs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 19:00:45 +02:00
Thomas SharedInbox 1393a31d61 ci: empty commit to verify pub get fully cached after date_created fix 2026-05-19 18:45:40 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 59dfb0cfb3 fix(ci): also strip date_created from .flutter-plugins-dependencies
flutter pub get writes a date_created timestamp into .flutter-plugins-
dependencies in addition to the generated field in package_config.json.
Both files are part of the pub-get execution snapshot, so both timestamps
must be removed to make the layer deterministic and cacheable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 18:33:20 +02:00
Thomas SharedInbox df0c3910cb ci: empty commit to verify pub get determinism caching 2026-05-19 17:59:50 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 4daf47f7a3 fix(ci): make pub get layer deterministic to enable test caching
Remove non-deterministic "generated" and "generatorVersion" fields from
.dart_tool/package_config.json after flutter pub get, so the snapshot
hash is stable across runs and all downstream test steps can be cached.
Mount only .dart_tool/build as a mutable cache volume so the incremental
build graph persists without polluting the deterministic snapshot.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 17:39:20 +02:00
Thomas SharedInbox dd9bd24f09 perf(ci): cache pub get separately from source to fix downstream cache misses
flutter pub get embeds a timestamp in .dart_tool/package_config.json, making
its output snapshot non-deterministic and busting the cache for dart format,
flutter analyze, unit tests, mocks, and integration tests on every run.

Fix: isolate pub get into its own layer using only pubspec.yaml + pubspec.lock
as inputs, then normalise the generated timestamp. setup() now overlays the
full source on top of this stable layer before running build_runner.

Result: on an empty commit, all steps downstream of pub get should be cached.
2026-05-19 16:59:19 +02:00
Thomas SharedInbox 1e0679c324 ci: second empty commit — sdkmanager should be cached now 2026-05-19 16:06:21 +02:00
Thomas SharedInbox d826522072 ci: empty commit to verify sdkmanager cache after GC policy fix 2026-05-19 15:55:18 +02:00
Thomas SharedInbox ec60566a33 ci: empty commit to verify Dagger cache after disk pressure resolved 2026-05-19 14:21:16 +02:00
Thomas SharedInbox 00625e318a ci: empty commit to verify SDK pre-install caching 2026-05-19 11:57:49 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 354f7959f6 fix(ci): pre-install Android SDK components in container layer
Cache volumes for NDK/CMake proved unreliable on the remote Dagger
engine: the android-ndk-cache volume was empty on each run, causing
Gradle to re-download NDK + CMake + build-tools + platform during every
`flutter build appbundle` (~3-4 min of extra downloads).

Pre-install all four SDK components via sdkmanager in Base() so Dagger's
execution cache captures them. Base() is CACHED on subsequent runs with
identical inputs, eliminating the per-run SDK downloads.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 11:35:44 +02:00
Thomas SharedInbox 75a7b947cd ci: retry after transient ghcr.io 502 2026-05-19 11:11:54 +02:00
Thomas SharedInbox fda0210bd0 ci: empty commit to verify source-scoped Dagger cache hits 2026-05-19 11:09:15 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 9e709873b9 refactor(ci): scope source inputs per pipeline — android/linux builds no longer bust on unrelated changes
Base() no longer mounts m.Source. Each function gets only the files it
needs via a narrow filter, so Dagger's content-addressed cache is scoped
correctly: changing website/, scripts/, or stalwart-dev/ no longer
invalidates the Android or Linux build cache.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 10:52:57 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 45c3a8088b ci: empty commit to verify Dagger cache hits
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 18:44:30 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 f60beaa199 fix: XmlNode.element is at proto field 1, not 2 — versionCode patch was silently skipping all elements
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 17:49:10 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 d9cde7cacf debug: dump manifest proto structure when versionCode not found
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 17:23:44 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 bb163542bb fix: add versionCode read-back verification and handle fixed-width wire types
- _parse now handles wire types 1 (fixed64) and 5 (fixed32) so it doesn't
  crash on unknown fields in the manifest proto
- _patch_prim patches both int_decimal_value (field 6) and int_hexadecimal_value
  (field 7) — AAPT2 may use either
- patch() reads versionCode before and after patching and exits with a clear
  error if the patch didn't take effect

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 17:11:20 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 8319002e0c fix: use publish-android task for Play Store deploy (stamps + signs + uploads)
The old workflow built with build-android-bundle (debug-signed) then uploaded
separately. publish-android stamps the versionCode, re-signs with the release
keystore, and uploads in one Dagger call.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 16:42:23 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 02e8c2200a fix: fail fast with clear error when keystore secrets are empty
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 14:17:41 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 8518715bcf ci: retrigger to verify Dagger cache hits
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 13:54:23 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 2d559d4947 feat: cache Android AAB build; stamp versionCode + resign after cache hit
BuildAndroidRelease() drops all params and builds with --build-number 1
(no keystore injected, Gradle uses debug signing). The command is now
stable across all commits — full Dagger cache hit whenever source is
unchanged.

Three new Dagger functions handle the post-cache steps:
- StampAndroidVersionCode(aab, versionCode): pure-stdlib Python patches
  the AAB's compiled manifest proto (android:versionCode resource ID
  0x0101021b) and strips META-INF/ to clear the old signature.
- SignAndroidBundle(aab, keystoreBase64, keystorePassword): decodes the
  base64 keystore secret and re-signs with jarsigner.
- PublishAndroid(ctx, playStoreConfig, keystoreBase64, keystorePassword):
  chains all three + UploadToPlayStore, computing time.Now().Unix() as
  the versionCode internally.

Taskfile: build-android-bundle simplified (no keystore params); publish-
android now calls publish-android in a single Dagger call instead of the
two-step build-then-upload.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 13:35:20 +02:00
Thomas GüttlerandClaude Sonnet 4.6 f6bb6aed82 ci: empty commit to verify Dagger cache
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 11:34:21 +02:00
Thomas GüttlerandClaude Sonnet 4.6 007e7b57f1 fix: revert CacheSharingModeLocked to fix deadlock in Check()
Locked exclusive cache access caused concurrent Dagger operations inside
Check() to deadlock waiting on each other, resulting in a 60-minute timeout.
Shared mode is correct here — cache volumes are pre-warmed so pub get is fast.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 11:19:41 +02:00
Thomas GüttlerandClaude Sonnet 4.6 0ea06e8634 fix: use CacheSharingModeLocked instead of dagger.Locked
dagger.Locked is not exported in this SDK version; the correct
constant is dagger.CacheSharingModeLocked.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 10:16:02 +02:00
Thomas GüttlerandClaude Sonnet 4.6 592efae934 perf: lock cache volumes and add --no-pub to fix Dagger cache misses
flutter pub get was not being cached by Dagger because the pub-cache
CacheVolume used Shared mode: concurrent writes from the check and
deploy-playstore jobs made the mount non-deterministic, causing a cache
miss on every run. Locked mode gives each operation exclusive access so
the output snapshot is stable and Dagger can cache subsequent steps.

Also add --no-pub to both flutter build commands: pub get already ran
explicitly in Setup(), so skipping it again inside the build step avoids
a duplicate network-touching operation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 09:45:39 +02:00
Thomas GüttlerandClaude Sonnet 4.6 9466f03936 perf: use commit timestamp as build number to enable Dagger cache hits
$(date +%s) changed every run, making the flutter build WithExec args
unique each time and busting the Dagger layer cache (500s build every run).

$(git log -1 --format=%ct HEAD) is stable for the same commit, so a
retry of a failed upload gets a full cache hit on the build step.
Still monotonically increasing across commits, satisfying Play Store.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 09:20:05 +02:00
Thomas Güttler b79ea77f69 ci: empty commit to measure cache performance 2026-05-18 09:06:56 +02:00
Thomas GüttlerandClaude Sonnet 4.6 ef8268a41e fix: rename duplicate build-android-bundle task to build-android-bundle-local
The old fvm-based task had the same name as the new Dagger-based one,
causing go-task to error immediately (1-second CI failure).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 08:37:18 +02:00
Thomas GüttlerandClaude Sonnet 4.6 8783bcf5f0 fix: unique build number and split build/upload steps
- Pass --build-number $(date +%s) to flutter build for both APK and AAB
  so each CI run gets a unique version code (fixes "already been used" error)
- Extract UploadToPlayStore(aab, playStoreConfig) as its own Dagger function
  so the build and upload are independently callable
- Add build-android-bundle task (exports AAB via dagger export) and
  upload-android-bundle task (calls UploadToPlayStore with the local file)
- CI deploy-playstore job now has two steps: Build Android Bundle and
  Upload to Play Store, so a failed upload can be retried without rebuilding
- deploy-apk also gets --build-number to avoid version code collisions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 08:18:33 +02:00
Thomas GüttlerandClaude Sonnet 4.6 484a183a19 fix: pass release keystore into Dagger Android builds
Both BuildAndroidApk and BuildAndroidRelease were using the debug
signing config because the keystore and password were never forwarded
into the Dagger container. Add setupKeystore() helper that decodes
ANDROID_KEYSTORE_BASE64 into android/app/upload-keystore.jks and
sets ANDROID_KEYSTORE_PASSWORD, then wire both secrets through
DeployApk, PublishAndroid, and the Taskfile/CI env blocks.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 07:49:45 +02:00
Thomas GüttlerandClaude Sonnet 4.6 3c403369fb ci: verify keystore SHA1 before Play Store build
Decodes ANDROID_KEYSTORE_BASE64 and prints the SHA1 fingerprint via
keytool before invoking the Dagger build, to confirm which key is in
the secret vs. what the build actually uses.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 07:20:25 +02:00
Thomas Güttler 24cafd1a93 ci: retrigger to capture Play Store error with debug logging 2026-05-18 06:25:12 +02:00
Thomas Güttler 2cc6188a43 fix: log HTTP status and response body on Play Store upload failure
Without the response body we can't tell why Google Play rejects the
upload. Logs the status code and first 500 bytes of the response for
both the init POST and the upload PUT on each failed attempt. Also
moves the init call inside the try/except so init failures are retried.
2026-05-18 05:49:55 +02:00
Thomas Güttler 83654fb4c9 fix: re-initialize resumable upload URL on each retry attempt
The resumable upload URL returned by Google Play is session-specific and
expires after a failed attempt. Retrying with the same URL always fails.
Also broadens the caught exception from HTTPError to RequestException so
timeouts and connection errors are retried too.
2026-05-18 05:06:42 +02:00
Thomas Güttler 0733a4bf8a ci: trigger run after runner security fix 2026-05-17 22:58:11 +02:00
Thomas Güttler 21cc94110d Revert "ci: switch to codeberg-small runner to avoid workspace permission failure"
This reverts commit 32b465bd1a.
2026-05-17 22:50:18 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 32b465bd1a ci: switch to codeberg-small runner to avoid workspace permission failure
The ubuntu-latest pool now includes nodes that run Docker containers with
user namespace isolation, causing chown of the workspace to fail before
checkout can run. The codeberg-small label routes consistently to the
actions-tiny nodes (act-latest image, no user namespace restriction) where
Dagger CI succeeded previously.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 22:36:26 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 91ec75c82f ci: restore Dagger & Task installation steps for Docker-based runner
The ubuntu-latest runner uses Docker containers (ghcr.io/catthehacker/ubuntu:act-22.04)
which don't have task or dagger pre-installed. These steps were mistakenly removed when
switching from the dagger-dagger host runner back to ubuntu-latest.

Also adds DAGGER_NO_NAG=1 to all dagger-invoking steps.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 22:23:49 +02:00
Thomas SharedInbox c712199d0b feat: decrease dagger output size with -q and DAGGER_NO_NAG=1 (#124)
Add -q (quiet) flag to all dagger call invocations to suppress INFO-level
engine messages while keeping warnings and errors visible. Set DAGGER_NO_NAG=1
globally to suppress the Dagger Cloud tracing nag line. --progress=plain
is retained on all calls as required.
2026-05-17 22:15:25 +02:00
Thomas GüttlerandClaude Sonnet 4.6 5562f82f35 ci: rename runner label from dagger-dagger to ubuntu-latest
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 22:11:57 +02:00
Gemini CLI 6863e309cd ci: use sharedinbox-runner for GitHub Actions 2026-05-17 22:02:02 +02:00
Gemini CLI 896886e130 ci: trigger run for dagger-dagger switch 2026-05-17 22:00:13 +02:00
Gemini CLI 9f636a992d ci: switch to dagger-dagger runner and remove manual setup steps 2026-05-17 21:53:44 +02:00
Gemini CLI b76b05307a ci: install Dagger and Task to local bin to avoid sudo issues 2026-05-17 21:52:11 +02:00
Gemini CLI 0a121979f8 ci: use sudo for Dagger and Task installation on self-hosted runner 2026-05-17 21:51:27 +02:00
Gemini CLI a617af70d5 ci: use ubuntu-latest runner for Forgejo workflows 2026-05-17 21:50:00 +02:00
Gemini CLI 9a0cb93970 ci: switch back to codeberg-small and rely on Dagger NDK caching 2026-05-17 19:43:09 +02:00
Gemini CLI cd0a807cb2 ci: switch to codeberg-medium runner and fix fetch-depth for website build 2026-05-17 19:41:51 +02:00
Gemini CLI f93198c0ca ci: optimize Android NDK installation and switch to ubuntu-latest runner 2026-05-17 19:39:46 +02:00
Gemini CLI 146baa50ea fix: include website directory in Dagger source filter 2026-05-17 18:18:16 +02:00
Gemini CLI 06d1be05ee fix: increase CI timeouts and add missing mock checks in Dagger 2026-05-17 18:04:25 +02:00
Gemini CLI 22dcd4c293 fix: CI task installation path 2026-05-17 17:16:43 +02:00
Gemini CLI b8acf37c24 fix: CI Dagger syntax, missing deps, and Stalwart startup 2026-05-17 17:14:35 +02:00
Gemini CLI 52473d216d ci: centralize Dagger calls in Taskfile and enforce standards via pre-commit 2026-05-17 16:43:52 +02:00
Gemini CLI 1266fd6338 gitignore 2026-05-17 16:34:28 +02:00
Gemini CLI 8cbe8c01bb ci: use idiomatic Dagger service bindings for Stalwart
Refactor the CI pipeline to use WithServiceBinding for the Stalwart mail
server, replacing legacy shell scripts and manual port management.
Introduces pre-seeded data for the Stalwart service to avoid network
hits and improves headless UI testing with Xvfb.
2026-05-17 16:01:42 +02:00
GuettliBot2 e6fc65a345 fix(ci): run backend tests sequentially to prevent contention 2026-05-17 14:41:00 +02:00
GuettliBot2 982618c9fe fix(ci): pin Stalwart to v0.14.1 and fix local start script 2026-05-17 14:24:06 +02:00
GuettliBot2 a22a4d1015 ci: remove Nix dependency from workflows and refactor Dagger module for native source fetching 2026-05-17 13:20:26 +02:00
GuettliBot2 92778346d3 ci: remove Nix dependency and modernize Stalwart test setup with Dagger Services 2026-05-17 13:17:28 +02:00
GuettliBot2 34d28d8a56 ci: use codeberg-small runner labels instead of ubuntu-latest 2026-05-17 12:19:35 +02:00
GuettliBot2 ef28d25f77 ci: enforce strict Dagger probing using URL1/URL2 and migrate website.yml to Dagger 2026-05-17 11:52:38 +02:00
GuettliBot2 b2d4695112 ci: add remote Dagger server setup with port probing 2026-05-17 11:50:39 +02:00
GuettliBot2 73c1a09d47 ci: minor cleanup of self-hosted runner references 2026-05-17 11:38:27 +02:00
GuettliBot2 27ce3961b1 ci: switch from self-hosted runners to Codeberg default ubuntu-latest 2026-05-17 11:37:58 +02:00
178 changed files with 11211 additions and 713 deletions
+8 -10
View File
@@ -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
+21
View File
@@ -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
+87 -115
View File
@@ -3,137 +3,109 @@ name: CI
on:
push:
branches: [main]
paths:
- 'lib/**'
- 'test/**'
- 'integration_test/**'
- 'android/**'
- 'linux/**'
- 'assets/**'
- '!assets/changelog.txt'
- 'pubspec.yaml'
- 'pubspec.lock'
- 'analysis_options.yaml'
- 'scripts/**'
- 'stalwart-dev/**'
- 'ci/**'
- 'Taskfile.yml'
- 'drift_schemas/**'
- '.forgejo/workflows/ci.yml'
pull_request:
paths:
- 'lib/**'
- 'test/**'
- 'integration_test/**'
- 'android/**'
- 'linux/**'
- 'assets/**'
- '!assets/changelog.txt'
- 'pubspec.yaml'
- 'pubspec.lock'
- 'analysis_options.yaml'
- 'scripts/**'
- 'stalwart-dev/**'
- 'ci/**'
- 'Taskfile.yml'
- 'drift_schemas/**'
- '.forgejo/workflows/ci.yml'
jobs:
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: Locate Docker daemon for local Dagger engine
run: |
# Skip if remote Dagger engine is already configured (preferred path)
if [ -n "${_DAGGER_RUNNER_HOST:-}" ]; then
echo "Remote Dagger engine configured, no local Docker needed."
exit 0
fi
# Try host Docker socket (DooD) if runner mounts it
if [ -S /var/run/docker.sock ]; then
if DOCKER_HOST=unix:///var/run/docker.sock docker info >/dev/null 2>&1; then
echo "Docker available via host socket."
echo "DOCKER_HOST=unix:///var/run/docker.sock" >> "$GITHUB_ENV"
exit 0
fi
fi
echo "WARNING: No remote Dagger engine and no local Docker found." >&2
echo " - Remote engine: check DAGGER_STUNNEL_URL secret and that the host proxy is running." >&2
echo " - Local Docker: runner does not expose /var/run/docker.sock." >&2
echo "CI will likely fail at the Dagger step." >&2
- name: Prune Dagger cache before check
env:
DAGGER_NO_NAG: "1"
# prune(maxUsedSpace) also reclaims named cache volumes (gradle-cache, go-build-cache, etc.)
# when total cache exceeds the limit; without args only unreferenced entries are removed.
run: |
dagger query '{ engine { localCache { prune(maxUsedSpace: "75gb", targetSpace: "50gb") } } }' || true
- name: Run Full Check Suite
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
- name: Prune Dagger cache after check
if: always()
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
DAGGER_NO_NAG: "1"
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
dagger query '{ engine { localCache { prune(maxUsedSpace: "75gb", targetSpace: "50gb") } } }' || true
- 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
+316
View File
@@ -0,0 +1,316 @@
name: Deploy
on:
schedule:
- cron: '0 * * * *' # every hour on the hour
workflow_dispatch:
jobs:
check-changes:
name: Detect Changed Files
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
android: ${{ steps.diff.outputs.android }}
linux: ${{ steps.diff.outputs.linux }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Detect Android and Linux changes
id: diff
shell: bash
run: |
# On workflow_dispatch always build everything
if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then
echo "android=true" >> "$GITHUB_OUTPUT"
echo "linux=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# Diff the HEAD commit against its parent; fall back to listing HEAD's files
# when the parent is unavailable (initial commit, shallow clone).
CHANGED=$(git diff --name-only HEAD~1 HEAD 2>/dev/null \
|| git show --name-only --format= HEAD)
echo "Changed files:"
echo "$CHANGED"
android_re='^(android/|integration_test/|lib/|pubspec\.yaml|pubspec\.lock|drift_schemas/)'
linux_re='^(linux/|lib/|pubspec\.yaml|pubspec\.lock)'
echo "$CHANGED" | grep -qE "$android_re" \
&& echo "android=true" >> "$GITHUB_OUTPUT" \
|| echo "android=false" >> "$GITHUB_OUTPUT"
echo "$CHANGED" | grep -qE "$linux_re" \
&& echo "linux=true" >> "$GITHUB_OUTPUT" \
|| echo "linux=false" >> "$GITHUB_OUTPUT"
test-android-firebase:
name: Android Instrumented Tests (Firebase Test Lab)
runs-on: ubuntu-latest
timeout-minutes: 60
needs: [check-changes]
if: needs.check-changes.outputs.android == 'true'
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Check runner tools
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
if: ${{ secrets.FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY != '' }}
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
needs: [check-changes]
if: needs.check-changes.outputs.android == 'true'
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Check runner tools
run: |
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
- name: Setup Dagger Remote Engine (via stunnel)
env:
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
run: scripts/setup_dagger_remote.sh
- name: Publish Android to Play Store
if: ${{ secrets.PLAY_STORE_CONFIG_JSON != '' }}
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: Cleanup TLS credentials
if: always()
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
deploy-apk:
name: Build & Deploy APK to Server
runs-on: ubuntu-latest
timeout-minutes: 60
needs: [check-changes]
if: needs.check-changes.outputs.android == 'true'
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Check runner tools
run: |
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
- name: Setup Dagger Remote Engine (via stunnel)
env:
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
run: scripts/setup_dagger_remote.sh
- name: Build & Deploy APK to server
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
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
needs: [check-changes]
if: needs.check-changes.outputs.linux == 'true'
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Check runner tools
run: |
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
- name: Setup Dagger Remote Engine (via stunnel)
env:
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
run: scripts/setup_dagger_remote.sh
- name: Build & Deploy Linux to server
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
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, deploy-apk]
if: |
always() &&
(needs.build-linux.result == 'success' || needs.deploy-playstore.result == 'success' || needs.deploy-apk.result == 'success')
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Check runner tools
run: |
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
- name: Setup Dagger Remote Engine (via stunnel)
env:
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
run: scripts/setup_dagger_remote.sh
- name: Generate build history and deploy website
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
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, deploy-apk, build-linux]
if: |
always() && vars.DEPLOY_HEALTH_ISSUE != '' && (
needs.test-android-firebase.result == 'success' || needs.test-android-firebase.result == 'failure' ||
needs.deploy-playstore.result == 'success' || needs.deploy-playstore.result == 'failure' ||
needs.deploy-apk.result == 'success' || needs.deploy-apk.result == 'failure' ||
needs.build-linux.result == 'success' || needs.build-linux.result == 'failure'
)
timeout-minutes: 5
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.test-android-firebase.result == 'skipped') && (needs.deploy-playstore.result == 'success' || needs.deploy-playstore.result == 'skipped') && (needs.deploy-apk.result == 'success' || needs.deploy-apk.result == 'skipped') && (needs.build-linux.result == 'success' || needs.build-linux.result == 'skipped') }}
run: |
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
+8 -23
View File
@@ -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
-3
View File
@@ -11,7 +11,6 @@ jobs:
name: Build & Deploy Windows (Nightly)
runs-on: windows-runner
if: false
continue-on-error: true
steps:
- uses: actions/checkout@v4
@@ -32,7 +31,6 @@ jobs:
- name: Set up SSH key
if: env.SKIP_BUILD != 'true'
continue-on-error: true
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
run: |
@@ -42,7 +40,6 @@ jobs:
- name: Deploy Windows to server
if: env.SKIP_BUILD != 'true'
continue-on-error: true
env:
SSH_USER: ${{ secrets.SSH_USER }}
SSH_HOST: ${{ secrets.SSH_HOST }}
+5 -5
View File
@@ -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
View File
@@ -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
-3
View File
@@ -1,3 +0,0 @@
[submodule "website/themes/PaperMod"]
path = website/themes/PaperMod
url = https://github.com/adityatelange/hugo-PaperMod.git
+12
View File
@@ -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
+3 -1
View File
@@ -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"
```
+111 -1
View File
@@ -61,9 +61,29 @@ _DAGGER_RUNNER_HOST=tcp://127.0.0.1:8080
Once the environment is set up, you can run the Dagger pipeline. For non-interactive environments (CI, LLMs), use `--progress=plain` for readable logs:
```bash
nix develop --command dagger call --progress=plain -m ci check --source .
nix develop --command dagger call --progress=plain -q -m ci --source=. check
```
## Secrets
All sensitive credentials are passed as `dagger.Secret` (never as plain strings).
This prevents values from appearing in Dagger logs or being cached in the engine.
| Parameter | Functions |
|---|---|
| `sshKey *dagger.Secret` | `Deployer`, `GenerateBuildHistory`, `BuildWebsite`, `PublishWebsite`, `DeployLinux`, `DeployApk` |
| `keystoreBase64 *dagger.Secret` | `setupKeystore`, `BuildAndroidApk`, `DeployApk`, `SignAndroidBundle`, `PublishAndroid` |
| `keystorePassword *dagger.Secret` | same as above |
| `playStoreConfig *dagger.Secret` | `UploadToPlayStore`, `PublishAndroid` |
| `serviceAccountKey *dagger.Secret` | `TestAndroidFirebase` |
Secrets are injected via `WithMountedSecret` (file-based, e.g. SSH key) or
`WithSecretVariable` (env-var-based, e.g. keystore data, Play Store JSON).
The only credentials not typed as `dagger.Secret` are the test passwords
(`STALWART_PASS_B`, `STALWART_PASS_C`) in `WithStalwart`. These are hardcoded
development values defined in `stalwart-dev/` — not production secrets.
## CI Integration (Codeberg/Forgejo)
The CI workflow in `.forgejo/workflows/ci.yml` is configured to use the Dagger module located in the `ci/` directory.
@@ -71,3 +91,93 @@ The CI workflow in `.forgejo/workflows/ci.yml` is configured to use the Dagger m
- **Check Suite:** Runs analysis and tests in parallel.
- **Builds:** Produces Linux and Android artifacts.
- **Caching:** When using the shared engine, CI runners benefit from the persistent cache on the host.
## Credential Security — Keeping Production Secrets Off Codeberg
### Problem
The current setup stores two categories of secrets in Codeberg repository secrets:
1. **Dagger access credentials** — TLS certificates used to connect to the remote Dagger engine via stunnel (`DAGGER_CA_CERT`, `DAGGER_CLIENT_CERT`, `DAGGER_CLIENT_KEY`, `DAGGER_STUNNEL_URL`).
2. **Production secrets** — actual credentials for external services: `ANDROID_KEYSTORE_BASE64`, `ANDROID_KEYSTORE_PASSWORD`, `PLAY_STORE_CONFIG_JSON`, `SSH_PRIVATE_KEY`, `FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY`.
If Codeberg is compromised, both categories are leaked. The Dagger TLS certificates enable access only to the Dagger engine and have limited blast radius. But the production secrets give direct access to the Play Store, the Android signing key, the deployment server, and Firebase — a much larger blast radius.
**Goal:** Keep only Dagger access credentials in Codeberg. Store all production secrets on the Dagger host machine so they never touch Codeberg.
### Option 1: Runner-level environment variables
Store production secrets as environment variables in the Forgejo runner's systemd service (e.g., via a `EnvironmentFile=` in the service override). The runner injects host env vars into job processes automatically. CI workflows drop the `${{ secrets.XYZ }}` references for production secrets entirely — the variables are already present in the job environment.
**Pro:**
- No new infrastructure required.
- Works with the existing `dagger call --progress=plain --secret env:VAR_NAME` argument style.
- Secrets never enter Codeberg.
- Straightforward to set up on a single self-hosted runner.
**Con:**
- Env vars are visible to every process on the runner host (e.g., via `/proc/<pid>/environ`).
- Rotating a secret requires host access (no API).
- Does not scale cleanly to multiple runners without a shared secrets mechanism.
### Option 2: Secret files on the CI host with restricted permissions
Store production secrets as files owned by the runner user with mode `600` (e.g., `/home/forgejo-runner/secrets/play_store.json`). A small setup script reads the files and either exports them as env vars or passes them directly as file-type arguments to `dagger call --progress=plain`. CI workflows contain no secret references at all.
**Pro:**
- OS-level file permissions limit access to the runner user.
- Natural format for JSON payloads and key files.
- Easy to audit (list files, check mtime).
- No new infrastructure.
**Con:**
- Plaintext files on disk; root or backup access exposes them.
- Workflow must know file paths (either hardcoded or by convention).
- Rotation still requires host filesystem access.
### Option 3: Dagger host as pipeline orchestrator
Instead of the CI runner invoking the Dagger CLI directly, the CI job sends a trigger to the Dagger host over SSH. The Dagger host runs the pipeline locally against its own environment, where secrets live as env vars or files. Codeberg only stores the SSH key to reach the Dagger host — not the production secrets.
```yaml
# CI job only does this:
- name: Trigger pipeline on Dagger host
run: ssh dagger-host "cd sharedinbox && task publish-android"
env:
SSH_PRIVATE_KEY: ${{ secrets.DAGGER_TRIGGER_SSH_KEY }}
```
**Pro:**
- Production secrets never leave the Dagger host.
- Codeberg stores exactly one secret: the trigger SSH key.
- All deployment logic and secrets are fully contained on the host.
**Con:**
- Harder to stream structured CI logs back to Codeberg Actions.
- Dynamic context (commit SHA, PR branch) must be passed explicitly over SSH.
- The trigger SSH key still grants shell access to the host, so its compromise has its own blast radius.
- CI becomes a "fire-and-forget" call, making failure attribution harder.
### Option 4: External secret manager (e.g., HashiCorp Vault)
Run a secret manager co-located with the Dagger host. The CI job authenticates with a short-lived AppRole credential (stored in Codeberg) and retrieves secrets at runtime. Vault can also be configured with IP-allow-lists to further restrict who can authenticate.
**Pro:**
- Full audit trail: every secret read is logged with a timestamp and caller identity.
- Fine-grained access control per secret.
- Built-in versioning and rotation support.
- Industry-standard approach; scales to team or multi-runner setups.
**Con:**
- Significant additional infrastructure to install, configure, and maintain.
- Vault credentials (RoleID + SecretID) still need to be in Codeberg, though with a smaller blast radius than raw secrets.
- Vault itself becomes a security-critical single point of failure.
- Operational overhead likely disproportionate for a small single-developer project.
### Recommendation
**Option 1** (runner-level env vars) or **Option 2** (secret files) are the pragmatic starting point for a single self-hosted runner. They require no new infrastructure and move all production secrets off Codeberg immediately.
**Option 3** (Dagger host as orchestrator) is worth considering once the trigger SSH key replaces all other secrets in Codeberg — it offers the cleanest security boundary at the cost of reduced CI observability.
**Option 4** (Vault) becomes worthwhile if the project grows to multiple runners or team members who each need audited access to deploy credentials.
+153 -16
View File
@@ -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,156 @@ 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|invalid return status code" "$DAGGER_OUT"; then
echo "$(_ts) dagger: network error on attempt $attempt/3, retrying..." >&2
elif [ "$attempt" -lt 3 ] && grep -q "No space left on device" "$DAGGER_OUT"; then
echo "$(_ts) dagger: disk space error on attempt $attempt/3, pruning Dagger cache..." >&2
dagger query '{ engine { localCache { prune(targetSpace: "20gb") } } }' 2>/dev/null || true
echo "$(_ts) dagger: waiting 90s for freed space to settle..." >&2
sleep 90
else
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
dagger-prune:
desc: Prune the Dagger engine cache (keeps named volumes unless total exceeds 75 GB, then targets 50 GB)
cmds:
- |
dagger query '{ engine { localCache { prune(maxUsedSpace: "75gb", targetSpace: "50gb") } } }'
integration-android:
desc: UI integration tests on a connected Android emulator (Stalwart on host, emulator reaches it via 10.0.2.2)
@@ -351,16 +488,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
View File
@@ -1,2 +1,3 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
org.gradle.welcome=never
+663 -129
View File
@@ -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 '^(\+|Downloading packages)' "$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 '^\[.*s\] \|' "$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
@@ -53,6 +312,7 @@ func (m *Ci) Hugo() *dagger.Container {
From("alpine:3.21").
WithExec([]string{"apk", "--no-cache", "add", "curl", "tar", "libc6-compat", "libstdc++", "gcompat"}).
WithExec([]string{"curl", "-sL", "https://github.com/gohugoio/hugo/releases/download/v0.152.2/hugo_extended_0.152.2_linux-amd64.tar.gz", "-o", "/tmp/hugo.tar.gz"}).
WithExec([]string{"sh", "-c", "echo '416bcfbdf5f68469ec9644dbe507da50fc21b94b69a125b059d64ed2cb4d8c27 /tmp/hugo.tar.gz' | sha256sum -c -"}).
WithExec([]string{"tar", "-xzf", "/tmp/hugo.tar.gz", "-C", "/usr/local/bin", "hugo"}).
WithExec([]string{"rm", "/tmp/hugo.tar.gz"})
}
@@ -66,103 +326,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 '^\[.*s\] \|' "$tmp" || true`}).
WithExec([]string{"/bin/bash", "-c", "CHANGED=$(find . -name '*.mocks.dart' | xargs -r git diff --exit-code); if [ $? -ne 0 ]; then echo \"ERROR: Mocks are out of date\"; exit 1; fi; echo \"Mocks are up to date.\""}).
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 +525,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 +534,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 +554,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 +571,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 +607,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,32 +646,100 @@ 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()).
WithMountedCache("/home/ci/.gradle", dag.CacheVolume("gradle-cache"), dagger.ContainerWithMountedCacheOpts{Owner: "ci"}).
WithExec([]string{"flutter", "build", "apk", "--debug", "--no-pub"}).
WithWorkdir("/src/android").
// --no-daemon avoids connecting to a stale daemon whose registry file was
// preserved in the Dagger layer snapshot but whose process no longer exists.
WithExec([]string{"./gradlew", "--no-daemon", "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"}).
WithExec([]string{"pip", "install", "requests", "google-auth"}).
WithMountedCache("/root/.cache/pip", dag.CacheVolume("pip-cache")).
WithExec([]string{"pip", "install", "google-auth", "requests"}).
WithFile("/src/build/app/outputs/bundle/release/app-release.aab", aab).
WithFile("/src/scripts/deploy_playstore.py", scriptSource.File("scripts/deploy_playstore.py")).
WithSecretVariable("PLAY_STORE_CONFIG_JSON", playStoreConfig).
@@ -325,3 +747,115 @@ 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_ci ["Codeberg CI · ci.yml (push/PR, source paths only)"]
ciCheck["check"]
end
subgraph forgejo_deploy ["Codeberg CI · deploy.yml (hourly schedule + workflow_dispatch)"]
detectChanges["check-changes\ndetect android / linux diff"]
buildLinux["build-linux\n(linux changed)"]
deployPS["deploy-playstore\n(android changed)"]
deployApk["deploy-apk\n(android changed)"]
fbTest["test-android-firebase\n(android changed)"]
pubWeb["publish-website\n(any build succeeded)"]
detectChanges --> buildLinux
detectChanges --> deployPS
detectChanges --> deployApk
detectChanges --> fbTest
buildLinux --> pubWeb
deployPS --> pubWeb
deployApk --> pubWeb
end
check -- "task check-dagger" --> ciCheck
` + "```"
}
+195
View File
@@ -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()
Executable
+21
View File
@@ -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"
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env python3
"""
Cron deploy script for sharedinbox website.
Runs every 5 minutes; skips if origin/main has not changed since last trigger.
Triggers the 'Deploy Website' Forgejo Actions workflow via fgj on each new commit.
Forgejo Actions handles failure reporting.
"""
import subprocess
import sys
from pathlib import Path
REPO_DIR = Path(__file__).parent.resolve()
SHA_FILE = REPO_DIR / '.last_deployed_sha'
REPO = 'guettli/sharedinbox'
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 main():
try:
git('fetch', 'origin', 'main')
except subprocess.CalledProcessError as exc:
print(f'git fetch failed (transient?): {exc} — skipping this run.', file=sys.stderr)
return
remote_sha = git('rev-parse', 'origin/main')
last_sha = read(SHA_FILE)
if remote_sha == last_sha:
print(f'No changes since {remote_sha[:8]}, skipping.')
return
print(f'New commit {remote_sha[:8]} (was {last_sha[:8] or "none"}) — triggering workflow...')
result = subprocess.run(
['fgj', 'actions', 'workflow', 'run', 'website.yml', '-R', REPO],
capture_output=True, text=True,
)
if result.returncode != 0:
print(f'fgj workflow run failed: {result.stderr}', file=sys.stderr)
sys.exit(1)
SHA_FILE.write_text(remote_sha + '\n')
print('Workflow triggered.')
if __name__ == '__main__':
main()
+7 -2
View File
@@ -29,7 +29,11 @@
cairo
gdk-pixbuf
harfbuzz
# Dagger remote setup dependencies
stunnel
netcat
];
fgj = pkgs.stdenv.mkDerivation {
pname = "fgj";
version = "0.4.0";
@@ -90,8 +94,9 @@
sqlite
# python3 base + Google Play API client (for scripts/deploy_playstore.py)
(python3.withPackages (ps: with ps; [
google-auth
requests
google-api-python-client
google-auth-httplib2
httplib2
])) # used by stalwart-dev/start and deploy_playstore.py
fgj # Codeberg/Forgejo CLI (like gh for GitHub)
]);
+22 -6
View File
@@ -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(
+23 -14
View File
@@ -1,31 +1,40 @@
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(
settings: 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',
accountEmail,
const NotificationDetails(
id: accountEmail.hashCode & 0x7FFFFFFF,
title: 'New mail',
body: accountEmail,
notificationDetails: const NotificationDetails(
android: AndroidNotificationDetails(
_kChannelId,
_kChannelName,
+16 -15
View File
@@ -4,38 +4,39 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/di.dart';
class UndoService extends StateNotifier<List<UndoAction>> {
UndoService(this._ref) : super([]);
final Ref _ref;
class UndoService extends Notifier<List<UndoAction>> {
static const int _maxHistory = 10;
// Resolves once init() has loaded persisted history. Default to an already-
// resolved future so operations are safe even if init() is never called.
Future<void> _ready = Future.value();
// Resolves once build() has loaded persisted history.
late Future<void> _ready;
Future<void> init() async {
_ready = _ref.read(undoRepositoryProvider).getHistory().then((history) {
if (mounted) state = history;
@override
List<UndoAction> build() {
_ready = ref.read(undoRepositoryProvider).getHistory().then((history) {
if (ref.mounted) state = history;
});
await _ready;
return [];
}
/// Waits for the persisted history to finish loading. Called by tests to
/// ensure the provider is ready before asserting state.
Future<void> init() => _ready;
Future<void> pushAction(UndoAction action) async {
await _ready;
final newList = [...state, action];
if (newList.length > _maxHistory) {
final removed = newList.removeAt(0);
await _ref.read(undoRepositoryProvider).deleteAction(removed.id);
await ref.read(undoRepositoryProvider).deleteAction(removed.id);
}
state = newList;
await _ref.read(undoRepositoryProvider).saveAction(action);
await ref.read(undoRepositoryProvider).saveAction(action);
}
Future<void> clear() async {
await _ready;
state = [];
unawaited(_ref.read(undoRepositoryProvider).clearHistory());
unawaited(ref.read(undoRepositoryProvider).clearHistory());
}
Future<void> undo({String? actionId}) async {
@@ -57,7 +58,7 @@ class UndoService extends StateNotifier<List<UndoAction>> {
// happened and retry if the undo failed (e.g. after an IMAP sync reverted
// the local change). The inverse action added below allows undoing the undo.
final repo = _ref.read(emailRepositoryProvider);
final repo = ref.read(emailRepositoryProvider);
for (final id in action.emailIds) {
// 1. Try to cancel the original change (if not started yet).
+3
View File
@@ -1,6 +1,7 @@
import 'dart:async';
import 'package:enough_mail/enough_mail.dart' as imap;
import 'package:flutter/services.dart' show MissingPluginException;
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/models/email.dart' show SyncEmailsResult;
import 'package:sharedinbox/core/repositories/account_repository.dart';
@@ -294,6 +295,7 @@ class _AccountSync implements _SyncLoop {
bool _isPermanentError(Object e) {
if (isTlsConfigError(e)) return true;
if (e is MissingPluginException) return true;
final s = e.toString().toLowerCase();
// enough_mail doesn't always have typed exceptions for auth, so we check strings.
return s.contains('invalid credentials') ||
@@ -546,6 +548,7 @@ class _JmapAccountSync implements _SyncLoop {
bool _isPermanentError(Object e) {
if (isTlsConfigError(e)) return true;
if (e is MissingPluginException) return true;
final s = e.toString().toLowerCase();
return s.contains('invalid credentials') ||
s.contains('authentication failed') ||
+21 -8
View File
@@ -5,6 +5,8 @@ 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:flutter/widgets.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
@@ -23,6 +25,9 @@ const _kResourceType = 'background_check';
@pragma('vm:entry-point')
void callbackDispatcher() {
// Required so that path_provider and other plugins are available in this
// background isolate (issue #192).
WidgetsFlutterBinding.ensureInitialized();
Workmanager().executeTask((_, __) async {
try {
await _doBackgroundSync();
@@ -32,14 +37,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 {
+86 -9
View File
@@ -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,96 @@ 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 back-off. Some slow Android
// devices need several seconds for the Pigeon channel to become ready
// (issue #166), so use a longer schedule than the initial attempt.
const delays = [200, 500, 1000, 2000, 4000];
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));
}
}
// On Android, path_provider can be permanently broken on some devices
// regardless of how long we wait (issue #192). Derive the path from
// /proc/self/cmdline (the Android process name == package name) without
// a platform channel as a last resort so the app can still open its DB.
if (Platform.isAndroid) {
final fallback = await _androidFallbackPath();
if (fallback != null) {
_dbPath = fallback;
return _dbPath!;
}
}
throw PlatformException(
code: 'channel-error',
message: 'path_provider unavailable after ${delays.length + 1} attempts — '
'cannot open database.',
);
}
// Reads /proc/self/cmdline to extract the Android package name, then
// constructs the standard app files-dir path without a platform channel.
// Returns null when the path cannot be determined or created.
Future<String?> _androidFallbackPath() async {
try {
final bytes = await File('/proc/self/cmdline').readAsBytes();
final end = bytes.indexOf(0);
final packageName = String.fromCharCodes(
end >= 0 ? bytes.sublist(0, end) : bytes,
).trim();
// A valid Android package name contains dots but not slashes.
if (packageName.isEmpty ||
!packageName.contains('.') ||
packageName.contains('/')) {
return null;
}
for (final base in [
'/data/user/0/$packageName/files',
'/data/data/$packageName/files',
]) {
try {
await Directory(base).create(recursive: true);
return p.join(base, 'sharedinbox.db');
} catch (_) {
continue;
}
}
return null;
} catch (_) {
return null;
}
}
// These functions are only called from unit tests (database_path_test.dart).
// They expose internals that cannot be reached via the public API.
Future<String> resolveDatabasePathForTesting() => _resolveDatabasePath();
void resetDatabasePathForTesting() => _dbPath = null;
Future<String?> androidFallbackPathForTesting() => _androidFallbackPath();
LazyDatabase _openConnection() {
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) {
+11 -12
View File
@@ -11,6 +11,7 @@ import 'package:sharedinbox/core/repositories/email_repository.dart';
import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
import 'package:sharedinbox/core/repositories/search_history_repository.dart';
import 'package:sharedinbox/core/repositories/share_key_repository.dart';
import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
import 'package:sharedinbox/core/repositories/undo_repository.dart';
import 'package:sharedinbox/core/services/account_discovery_service.dart';
import 'package:sharedinbox/core/services/connection_test_service.dart';
@@ -101,7 +102,7 @@ final searchHistoryRepositoryProvider =
return SearchHistoryRepositoryImpl(ref.watch(dbProvider));
});
final syncLogRepositoryProvider = Provider((ref) {
final syncLogRepositoryProvider = Provider<SyncLogRepository>((ref) {
return SyncLogRepositoryImpl(ref.watch(dbProvider));
});
@@ -181,11 +182,7 @@ final manageSieveProbeServiceProvider = Provider<ManageSieveProbeService>((
});
final undoServiceProvider =
StateNotifierProvider<UndoService, List<UndoAction>>((ref) {
final service = UndoService(ref);
unawaited(service.init());
return service;
});
NotifierProvider<UndoService, List<UndoAction>>(UndoService.new);
/// Loads email header + body and marks the email as seen.
/// Owned by [EmailDetailScreen]; decouples data loading from the widget tree.
@@ -194,16 +191,18 @@ final emailDetailProvider = AsyncNotifierProvider.autoDispose
EmailDetailNotifier.new,
);
class EmailDetailNotifier
extends AutoDisposeFamilyAsyncNotifier<(Email?, EmailBody), String> {
class EmailDetailNotifier extends AsyncNotifier<(Email?, EmailBody)> {
EmailDetailNotifier(this._emailId);
final String _emailId;
@override
Future<(Email?, EmailBody)> build(String emailId) async {
Future<(Email?, EmailBody)> build() async {
final repo = ref.read(emailRepositoryProvider);
final results = await Future.wait([
repo.getEmail(emailId),
repo.getEmailBody(emailId),
repo.getEmail(_emailId),
repo.getEmailBody(_emailId),
]);
unawaited(repo.setFlag(emailId, seen: true));
unawaited(repo.setFlag(_emailId, seen: true));
return (results[0] as Email?, results[1] as EmailBody);
}
}
+1
View File
@@ -3,6 +3,7 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/misc.dart' show Override;
import 'package:sharedinbox/core/services/notification_service.dart';
import 'package:sharedinbox/core/sync/background_sync.dart';
+6 -2
View File
@@ -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';
@@ -47,10 +47,14 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
final osName = _capitalize(Platform.operatingSystem);
final isDark = MediaQuery.of(context).platformBrightness == Brightness.dark;
return '## sharedinbox.de\n\n'
final gitCommitLine = _gitHash.isNotEmpty
? '| Git Commit | [$_gitHash](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash) |\n'
: '';
return '## [sharedinbox.de](https://sharedinbox.de)\n\n'
'| Property | Value |\n'
'|----------|-------|\n'
'| App Version | $versionDisplay |\n'
'$gitCommitLine'
'| Platform | ${Platform.operatingSystem} |\n'
'| $osName Version | ${Platform.operatingSystemVersion} |\n'
'| Resolution | ${physW}x$physH px'
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -162,7 +162,7 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
}
Future<void> _pickAttachments() async {
final result = await FilePicker.platform.pickFiles(allowMultiple: true);
final result = await FilePicker.pickFiles();
if (result == null) return;
final files = result.files.where((f) => f.path != null).toList();
if (!mounted) return;
+129 -82
View File
@@ -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,30 @@ 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,
),
if (_gitHash.isNotEmpty) ...[
const SizedBox(height: 8),
const Text(
'Git Commit: $_gitHash',
style: TextStyle(fontSize: 12, color: Colors.grey),
textAlign: TextAlign.center,
),
],
const SizedBox(height: 24),
const Text(
'Stack Trace:',
'Error Details:',
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
@@ -80,70 +77,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'),
),
],
),
),
),
),
+3 -3
View File
@@ -43,15 +43,15 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
ref.listen<AsyncValue<(Email?, EmailBody)>>(
emailDetailProvider(widget.emailId),
(_, next) {
final email = next.valueOrNull?.$1;
final email = next.value?.$1;
if (email != null && mounted) {
setState(() => _isFlagged = email.isFlagged);
}
},
);
final header = detail.valueOrNull?.$1;
final body = detail.valueOrNull?.$2;
final header = detail.value?.$1;
final body = detail.value?.$2;
final isMobile = defaultTargetPlatform == TargetPlatform.android ||
defaultTargetPlatform == TargetPlatform.iOS;
+3 -3
View File
@@ -261,9 +261,9 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
Widget _buildSyncButton(EmailRepository emailRepo) {
final isSyncing =
ref.watch(isSyncingProvider(widget.accountId)).valueOrNull ?? false;
ref.watch(isSyncingProvider(widget.accountId)).value ?? false;
final hasError =
ref.watch(syncLastErrorProvider(widget.accountId)).valueOrNull != null;
ref.watch(syncLastErrorProvider(widget.accountId)).value != null;
return IconButton(
tooltip: isSyncing
? 'Syncing…'
@@ -350,7 +350,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
Widget _buildSyncErrorBanner() {
final errorAsync = ref.watch(syncLastErrorProvider(widget.accountId));
final error = errorAsync.valueOrNull;
final error = errorAsync.value;
if (error == null || error == _dismissedError) {
return const SizedBox.shrink();
}
+1345
View File
File diff suppressed because it is too large Load Diff
+16 -15
View File
@@ -19,15 +19,15 @@ dependencies:
# Local persistence (offline-first)
drift: ^2.20.3
sqlite3_flutter_libs: ^0.5.28
sqlite3_flutter_libs: ^0.6.0+eol
path_provider: ^2.1.5
path: ^1.9.1
# State management
flutter_riverpod: ^2.6.1
flutter_riverpod: ^3.0.0
# Navigation
go_router: ^14.8.1
go_router: ^17.2.3
# Secure credential storage (passwords)
flutter_secure_storage: ^10.0.0
@@ -36,7 +36,7 @@ dependencies:
intl: any
# File picking (compose attachments) and opening downloaded attachments
file_picker: ^8.0.0
file_picker: ^12.0.0-beta.4
open_filex: ^4.6.0
mime: ^2.0.0
@@ -47,34 +47,34 @@ dependencies:
cryptography: ^2.7.0
# QR code scanning (camera) for secure account import
mobile_scanner: ^5.0.0
mobile_scanner: ^7.2.0
# 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
flutter_local_notifications: ^21.0.0
workmanager: ^0.9.0
# App version metadata for crash reports
package_info_plus: ^8.0.0
share_plus: ^12.0.2
package_info_plus: ^10.1.0
share_plus: ^13.1.0
dev_dependencies:
flutter_test:
sdk: flutter
integration_test:
sdk: flutter
flutter_lints: ^4.0.0
flutter_lints: ^6.0.0
drift_dev: ^2.20.3
build_runner: ^2.4.13
test: ^1.25.0
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"
+508 -88
View File
@@ -7,72 +7,88 @@ Flow
1. Agent already running?
a. Age > 1 h → kill it, set its issue to State/Question, exit 1
b. Age ≤ 1 h → print status, exit 0 (let it keep working)
2. No agent running → check Codeberg CI
a. CI is running → print "CI running, waiting", exit 0
b. Latest CI failed → start fix-CI agent, save state, exit 0
c. CI ok (or no run yet) → find oldest Ready issue, start issue agent,
2. No agent running → extract pending_issue from state (if any), then check CI
a. CI is running → save pending-ci state, exit 0
b. Latest CI failed → start fix-CI agent (preserving pending_issue), exit 0
c. CI ok + pending_issue → close the issue (CI passed), exit 0
d. CI ok (or no run yet) → find oldest Ready issue, start issue agent,
save state, exit 0
d. No Ready issues → print "nothing to do", exit 0
e. No Ready issues → print "nothing to do", exit 0
Issue agents must NOT close the issue themselves; the loop closes it after CI passes.
State file: ~/.sharedinbox-agent-state.json
{ "pid": 12345, "issue": 91,
"started_at": "2026-05-15T12:00:00+00:00", "type": "issue" }
Output is written to ~/.sharedinbox-agent-logs/<session>-<timestamp>.log.
Resume the Claude conversation afterward with:
To resume the Claude conversation, look up the session UUID first:
claude --resume issue-91
scripts/agent_loop.py list # shows NAME and UUID columns
claude --resume <uuid> # use the UUID, NOT the session name
"""
import argparse
import json
import os
import re
import shlex
import subprocess
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 +97,137 @@ 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, state: str = "open") -> dict | None:
"""Return the first PR in the given state whose head branch matches, or None."""
result = subprocess.run(
["fgj", "--hostname", "codeberg.org", "pr", "list",
"--repo", REPO, "--state", state, "--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 _open_issue_prs() -> list[dict]:
"""Return all open PRs with issue-{N}-fix branches, oldest-first."""
result = subprocess.run(
["fgj", "--hostname", "codeberg.org", "pr", "list",
"--repo", REPO, "--state", "open", "--json"],
capture_output=True, text=True,
)
if result.returncode != 0 or not result.stdout.strip():
return []
prs = json.loads(result.stdout)
issue_prs = []
for pr in prs:
head = pr.get("head", {})
ref = head.get("ref") or head.get("label", "").split(":")[-1]
if re.match(r"^issue-\d+-fix$", ref or ""):
issue_prs.append(pr)
issue_prs.sort(key=lambda p: p["number"])
return issue_prs
def _latest_ci_run_for_pr(pr_number: int) -> dict | None:
"""Return the latest CI run triggered by a pull_request event for the given PR number."""
data = _tea_get(f"repos/{REPO}/actions/runs?event=pull_request&limit=50")
runs = (data or {}).get("workflow_runs", [])
for run in runs:
try:
payload = json.loads(run.get("event_payload", "{}"))
if payload.get("pull_request", {}).get("number") == pr_number:
return run
except (json.JSONDecodeError, AttributeError):
pass
return None
def _merge_pr(pr_number: int) -> None:
"""Squash-merge a PR via fgj."""
_fgj("pr", "merge", str(pr_number), "--repo", REPO, "--merge-method", "squash")
# ── state file ────────────────────────────────────────────────────────────────
@@ -137,35 +240,63 @@ def _read_state() -> dict | None:
return None
def _write_state(pid: int, issue: int | None, kind: str) -> None:
STATE_FILE.write_text(
json.dumps(
{
"pid": pid,
"issue": issue,
"started_at": datetime.now(timezone.utc).isoformat(),
"type": kind,
},
indent=2,
)
)
def _write_state(pid: int | None, issue: int | None, kind: str, issue_title: str | None = None, session_name: str | None = None, ci_run_id: int | None = None) -> None:
data: dict = {
"pid": pid,
"issue": issue,
"started_at": datetime.now(timezone.utc).isoformat(),
"type": kind,
}
if issue_title is not None:
data["issue_title"] = issue_title
if session_name is not None:
data["session_name"] = session_name
if ci_run_id is not None:
data["ci_run_id_at_start"] = ci_run_id
STATE_FILE.write_text(json.dumps(data, indent=2))
STATE_FILE.chmod(0o600)
def _clear_state() -> None:
STATE_FILE.unlink(missing_ok=True)
def _find_session_uuid(session_name: str) -> str | None:
"""Return the Claude session UUID for *session_name*, or None if not found.
Claude stores session metadata in JSONL files; the first entry with
type=="agent-name" contains both the human-readable name and the UUID
needed for ``claude --resume <uuid>``.
"""
if not CLAUDE_PROJECTS_DIR.exists():
return None
for jsonl in CLAUDE_PROJECTS_DIR.glob("*.jsonl"):
try:
with jsonl.open() as fh:
for line in fh:
line = line.strip()
if not line:
continue
d = json.loads(line)
if d.get("type") == "agent-name" and d.get("agentName") == session_name:
return d.get("sessionId")
except Exception:
continue
return None
# ── agent launcher ────────────────────────────────────────────────────────────
def _start_agent(prompt: str, session_name: str) -> int:
"""Start Claude Code as a detached background process and return its PID."""
log_dir = Path.home() / ".sharedinbox-agent-logs"
log_dir.mkdir(exist_ok=True)
log_dir.mkdir(mode=0o700, exist_ok=True)
log_dir.chmod(0o700) # fix permissions if dir already existed with wrong mode
ts = datetime.now().strftime("%Y%m%dT%H%M%S")
log_file = log_dir / f"{session_name}-{ts}.log"
log_fh = open(log_file, "w")
log_fh = open(log_file, "w", opener=lambda p, f: os.open(p, f, 0o600))
proc = subprocess.Popen(
[
"claude",
@@ -183,8 +314,8 @@ def _start_agent(prompt: str, session_name: str) -> int:
proc.stdin.write(b"\n")
proc.stdin.close()
print(f"[agent_loop] Started agent pid={proc.pid}, log={log_file}")
print(f"[agent_loop] Resume: claude --resume {shlex.quote(session_name)}")
print(f"Started agent pid={proc.pid}, log={log_file}")
print(f" Resume: run 'scripts/agent_loop.py list' to get the UUID-based resume command")
return proc.pid
@@ -211,6 +342,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 +374,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,53 +435,254 @@ 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
session_name = state.get("session_name")
uuid = _find_session_uuid(session_name) if session_name else None
if uuid:
resume_cmd = f"claude --resume {shlex.quote(uuid)}"
elif session_name:
resume_cmd = f"claude --resume <uuid> # run: scripts/agent_loop.py list"
else:
resume_cmd = ""
git_info = _git_summary()
parts = [
f"Agent pid={pid!r} ({kind}, {issue_ref}) still running ({age/60:.0f} min). Waiting.",
]
if resume_cmd:
parts.append(f" Resume: {resume_cmd}")
if git_info:
parts.append(f" Commit: {git_info}")
print("\n".join(parts))
return 0
# Agent not running (or no state) — 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 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. "
"Do NOT reference any issue numbers in commit messages "
"(no 'closes #N', 'fixes #N', or similar) — auto-closing the wrong "
"issue via a commit message would be a bug. "
"Verify locally with 'task check' before pushing. "
"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 {_ci_run_url(pr_run['id'])} 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
# No open PR — check if it was already merged.
merged_pr = _find_pr_for_branch(branch, state="closed")
if merged_pr and merged_pr.get("merged"):
print(f"PR for branch {branch!r} was already merged — closing issue #{pending_issue}.")
_close_issue(pending_issue)
return 0
# No open or merged PR — the agent may not have created one, or it was
# closed without merging (the bug this block was added to catch).
print(
f"[agent_loop] Agent pid={pid!r} ({kind}, issue #{issue}) "
f"still running ({age/60:.0f} min). Waiting."
f"No open or merged PR found for branch {branch!r} "
f"(issue #{pending_issue}) — setting to State/Question."
)
_set_labels(pending_issue, add=[LABEL_QUESTION], remove=[LABEL_IN_PROGRESS])
_comment_issue(
pending_issue,
f"Agent finished but no open or merged PR was found for branch `{branch}`. "
"Please investigate and resume manually.",
)
return 0
# Agent not running (or no state) — clean up stale state.
if state:
_clear_state()
# ── 2b. Catch-up: scan open issue-N-fix PRs orphaned by a cleared state ─────
# This handles PRs whose CI has passed but were never merged because the
# state file was cleared (loop restart, killed agent, manual intervention).
open_prs = _open_issue_prs()
for pr in open_prs:
pr_number = pr["number"]
pr_url = f"{REPO_URL}/pulls/{pr_number}"
head = pr.get("head", {})
branch = head.get("ref") or head.get("label", "").split(":")[-1]
m = re.match(r"^issue-(\d+)-fix$", branch or "")
issue_num = int(m.group(1)) if m else None
pr_run = _latest_ci_run_for_pr(pr_number)
# ── 2. Check CI ───────────────────────────────────────────────────────────
if pr_run and pr_run.get("status") == "running":
print(f"Catch-up: CI {_ci_run_url(pr_run['id'])} on PR #{pr_number} still running. Waiting.")
_write_state(None, issue_num, "pending-ci")
return 0
if pr_run and pr_run.get("status") in ("failure", "error"):
print(f"Catch-up: CI {_ci_run_url(pr_run['id'])} on PR #{pr_number} failed — skipping.")
continue
if pr_run and pr_run.get("status") == "success":
print(f"Catch-up: CI passed on PR #{pr_number} ({pr_url}) — merging.")
try:
_merge_pr(pr_number)
except RuntimeError as e:
print(f"Catch-up: merge of PR #{pr_number} failed: {e} — skipping.")
continue
# Verify the merge actually happened; fgj can exit 0 without merging
# (e.g. branch-protection rules not satisfied).
if _find_pr_for_branch(branch):
print(
f"Catch-up: PR #{pr_number} is still open after merge attempt "
"— skipping to avoid infinite retry."
)
if issue_num:
_set_labels(issue_num, add=[LABEL_QUESTION], remove=[LABEL_IN_PROGRESS])
_comment_issue(
issue_num,
f"Automatic merge of PR #{pr_number} failed (PR is still open "
"after the merge command). Please merge manually.",
)
continue
if issue_num:
_close_issue(issue_num)
print(f"Merged PR #{pr_number} and closed issue #{issue_num}.")
else:
print(f"Merged PR #{pr_number}.")
return 0
# ── 3. Global CI check (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']}. "
"Fetch the CI logs using the task ci-logs command or the Codeberg API. "
"Identify the failure, fix it, commit, and push. "
"Verify locally with 'task check' before pushing. "
"Do NOT push to main. "
"Do NOT reference any issue numbers in commit messages "
"(no 'closes #N', 'fixes #N', or similar) — this is a CI fix, "
"not an issue fix, and auto-closing the wrong issue would be a bug. "
"Do NOT close any issues. "
"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",
ci_run_id=run["id"] if run else None)
return 0
# CI is ok (or no run) — find a Ready issue.
# CI is ok (or no run).
if pending_issue:
latest_run_id = run["id"] if run else None
if ci_run_id_at_start is not None and latest_run_id == ci_run_id_at_start:
# CI run hasn't changed since the agent was launched → agent pushed nothing
# (likely crashed or hit a rate limit).
print(
f"No new CI run since agent started for {_issue_url(pending_issue)} "
f"(run id {latest_run_id}) — agent did nothing. Setting to State/Question."
)
_set_labels(pending_issue, add=[LABEL_QUESTION], remove=[LABEL_IN_PROGRESS])
_comment_issue(
pending_issue,
"The agent exited without pushing any changes (no new CI run was triggered). "
"This usually means the agent hit a rate limit or crashed at startup. "
"The issue has been set to State/Question — please review the agent log and retry.",
)
return 0
_close_issue(pending_issue)
ci_run_part = f" {_ci_run_url(run['id'])}" if run else ""
print(f"CI passed{ci_run_part} — closed {_issue_url(pending_issue)}.")
return 0
# Find a Ready issue.
issues = _ready_issues()
if not issues:
print("[agent_loop] No issues with State/Ready. Nothing to do.")
print("No issues with State/Ready. Nothing to do.")
return 0
issue = issues[0]
@@ -288,7 +690,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 +713,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())
+8 -1
View File
@@ -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
+51 -53
View File
@@ -6,71 +6,49 @@ import os
import sys
import time
import requests
from google.auth.transport.requests import AuthorizedSession
from google.oauth2 import service_account
PACKAGE_NAME = "de.sharedinbox.mua"
AAB_PATH = "build/app/outputs/bundle/release/app-release.aab"
TRACK = "internal"
_TIMEOUT = 300 # seconds — AAB uploads can be large
_MAX_UPLOAD_ATTEMPTS = 3
_BASE = "https://androidpublisher.googleapis.com/androidpublisher/v3/applications"
_UPLOAD_BASE = "https://androidpublisher.googleapis.com/upload/androidpublisher/v3/applications"
_MAX_UPLOAD_ATTEMPTS = 3
def _make_session(config_json: str) -> AuthorizedSession:
creds = service_account.Credentials.from_service_account_info(
json.loads(config_json),
scopes=["https://www.googleapis.com/auth/androidpublisher"],
)
return AuthorizedSession(creds)
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)
def _upload_aab_resumable(session, package, edit_id, aab_path):
"""Upload AAB using the Google resumable upload protocol."""
file_size = os.path.getsize(aab_path)
init_url = f"{_UPLOAD_BASE}/{package}/edits/{edit_id}/bundles"
# Step 1: initiate the resumable upload session
init_resp = session.post(
f"{_UPLOAD_BASE}/{PACKAGE_NAME}/edits/{edit_id}/bundles",
init_url,
params={"uploadType": "resumable"},
headers={
"X-Upload-Content-Type": "application/octet-stream",
"X-Upload-Content-Length": str(file_size),
"Content-Length": "0",
},
json={},
timeout=30,
timeout=60,
)
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:
upload_resp = session.put(
upload_url,
data=data,
headers={
"Content-Type": "application/octet-stream",
"Content-Length": str(file_size),
},
timeout=_TIMEOUT,
)
upload_resp.raise_for_status()
return upload_resp.json()["versionCode"]
except requests.HTTPError 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…")
time.sleep(delay)
raise RuntimeError(
f"AAB upload failed after {_MAX_UPLOAD_ATTEMPTS} attempts"
) from last_exc
# Step 2: upload the file in a single PUT to the session URI
with open(aab_path, "rb") as f:
upload_resp = session.put(
upload_url,
data=f,
headers={
"Content-Type": "application/octet-stream",
"Content-Length": str(file_size),
},
timeout=600,
)
upload_resp.raise_for_status()
return upload_resp.json()
def main():
@@ -83,25 +61,45 @@ def main():
print(f"Error: AAB not found at {AAB_PATH}", file=sys.stderr)
sys.exit(1)
session = _make_session(config_json)
edit_resp = session.post(
f"{_BASE}/{PACKAGE_NAME}/edits",
json={},
timeout=30,
creds = service_account.Credentials.from_service_account_info(
json.loads(config_json),
scopes=["https://www.googleapis.com/auth/androidpublisher"],
)
session = AuthorizedSession(creds)
edit_resp = session.post(f"{_BASE}/{PACKAGE_NAME}/edits", json={}, timeout=30)
edit_resp.raise_for_status()
edit_id = edit_resp.json()["id"]
version_code = _upload_aab(session, edit_id)
last_exc = None
bundle = None
for attempt in range(_MAX_UPLOAD_ATTEMPTS):
try:
bundle = _upload_aab_resumable(session, PACKAGE_NAME, edit_id, AAB_PATH)
break
except Exception as exc:
last_exc = exc
if attempt < _MAX_UPLOAD_ATTEMPTS - 1:
delay = 10 * (2 ** attempt)
print(
f"Upload attempt {attempt + 1} failed ({type(exc).__name__}: {exc}), "
f"retrying in {delay}s…"
)
time.sleep(delay)
if bundle is None:
raise RuntimeError(
f"AAB upload failed after {_MAX_UPLOAD_ATTEMPTS} attempts"
) from last_exc
version_code = bundle["versionCode"]
print(f"Uploaded AAB, version code: {version_code}")
tracks_resp = session.put(
track_resp = session.put(
f"{_BASE}/{PACKAGE_NAME}/edits/{edit_id}/tracks/{TRACK}",
json={"releases": [{"versionCodes": [version_code], "status": "completed"}]},
timeout=30,
)
tracks_resp.raise_for_status()
track_resp.raise_for_status()
commit_resp = session.post(
f"{_BASE}/{PACKAGE_NAME}/edits/{edit_id}:commit",
+11 -3
View File
@@ -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()]
+54
View File
@@ -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)"
+102
View File
@@ -0,0 +1,102 @@
#!/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
MAX_PROBE_ATTEMPTS=5
PROBE_DELAY=30
for attempt in $(seq 1 $MAX_PROBE_ATTEMPTS); do
echo "Probing $host:$port (attempt $attempt/$MAX_PROBE_ATTEMPTS)..."
if nc -zw 5 "$host" "$port" 2>/dev/null; then
echo "Found active server on $host:$port"
break
fi
if [ "$attempt" -eq "$MAX_PROBE_ATTEMPTS" ]; then
echo "Warning: No Dagger server responded on $host:$port after $MAX_PROBE_ATTEMPTS attempts"
echo "Remote engine unavailable — CI will use the local Dagger engine."
exit 0
fi
echo "Dagger server not responding, waiting ${PROBE_DELAY}s before retry..."
sleep $PROBE_DELAY
done
# 2a. Try plain TCP connection first (works when server is a plain TCP proxy, no TLS)
echo "Trying plain TCP Dagger connection at tcp://$host:$port..."
if _DAGGER_RUNNER_HOST="tcp://$host:$port" \
_EXPERIMENTAL_DAGGER_RUNNER_HOST="tcp://$host:$port" \
timeout 8 dagger version >/dev/null 2>&1; then
echo "Plain TCP Dagger connection succeeded — no TLS stunnel needed."
if [ -n "${GITHUB_ENV:-}" ]; then
echo "_EXPERIMENTAL_DAGGER_RUNNER_HOST=tcp://$host:$port" >> "$GITHUB_ENV"
echo "_DAGGER_RUNNER_HOST=tcp://$host:$port" >> "$GITHUB_ENV"
else
export _EXPERIMENTAL_DAGGER_RUNNER_HOST="tcp://$host:$port"
export _DAGGER_RUNNER_HOST="tcp://$host:$port"
echo "Dagger configured at tcp://$host:$port (plain TCP)"
fi
exit 0
fi
echo "Plain TCP connection not available; trying TLS stunnel..."
# 2b. Setup TLS credentials (passed as env vars from secrets)
mkdir -p /tmp/dagger-tls
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
+467 -3
View File
@@ -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,455 @@ 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 _open_pr(self, branch: str = "issue-10-fix") -> dict:
return {"number": 5, "head": {"ref": branch}, "created_at": "2026-01-01T00:00:00+00:00"}
def _find_pr_open(self, branch, state="open"):
if state == "open":
return self._open_pr(branch)
return None
def test_closes_issue_when_ci_passes_after_agent_finishes(self):
"""After issue agent finishes, loop merges the PR and closes the issue once CI is green."""
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
patch("agent_loop._find_pr_for_branch", side_effect=self._find_pr_open), \
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 1, "status": "success"}), \
patch("agent_loop._merge_pr") as mock_merge, \
patch("agent_loop._close_issue") as mock_close, \
patch("agent_loop._clear_state"):
result = agent_loop._run_loop()
self.assertEqual(result, 0)
mock_merge.assert_called_once_with(5)
mock_close.assert_called_once_with(10)
def test_ci_passed_output_includes_ci_run_url(self):
"""'CI passed' line includes the CI run URL when a run is available."""
buf = io.StringIO()
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
patch("agent_loop._find_pr_for_branch", side_effect=self._find_pr_open), \
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 4145144, "status": "success"}), \
patch("agent_loop._merge_pr"), \
patch("agent_loop._close_issue"), \
patch("agent_loop._clear_state"), \
contextlib.redirect_stdout(buf):
agent_loop._run_loop()
output = buf.getvalue()
self.assertIn("https://codeberg.org/guettli/sharedinbox/actions/runs/4145144", output)
self.assertIn("https://codeberg.org/guettli/sharedinbox/issues/10", output)
def test_already_merged_pr_closes_issue_without_ci_url(self):
"""When the PR was already merged, the issue is closed and no CI run URL appears."""
def find_pr(branch, state="open"):
if state == "closed":
return {"number": 5, "merged": True}
return None
buf = io.StringIO()
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
patch("agent_loop._find_pr_for_branch", side_effect=find_pr), \
patch("agent_loop._close_issue") as mock_close, \
patch("agent_loop._clear_state"), \
contextlib.redirect_stdout(buf):
result = agent_loop._run_loop()
output = buf.getvalue()
self.assertEqual(result, 0)
mock_close.assert_called_once_with(10)
self.assertIn("already merged", output)
self.assertNotIn("/actions/runs/", output)
def test_no_pr_found_sets_question_label(self):
"""When no open or merged PR exists for the pending branch, set State/Question."""
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
patch("agent_loop._find_pr_for_branch", return_value=None), \
patch("agent_loop._set_labels") as mock_labels, \
patch("agent_loop._comment_issue") as mock_comment, \
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_not_called()
mock_labels.assert_called_once_with(
10,
add=[agent_loop.LABEL_QUESTION],
remove=[agent_loop.LABEL_IN_PROGRESS],
)
mock_comment.assert_called_once()
self.assertIn("issue-10-fix", mock_comment.call_args[0][1])
def test_does_not_close_issue_when_ci_fails(self):
"""After issue agent finishes, loop must NOT close the issue if CI failed on PR branch."""
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
patch("agent_loop._find_pr_for_branch", side_effect=self._find_pr_open), \
patch("agent_loop._latest_ci_run_for_branch", 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 on PR branch 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._find_pr_for_branch", side_effect=self._find_pr_open), \
patch("agent_loop._latest_ci_run_for_branch", 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 on PR branch 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._find_pr_for_branch", side_effect=self._find_pr_open), \
patch("agent_loop._latest_ci_run_for_branch", 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 on PR branch, the pending issue is closed."""
with patch("agent_loop._read_state", return_value=self._dead_state(10, "ci-fix")), \
patch("agent_loop._find_pr_for_branch", side_effect=self._find_pr_open), \
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 1, "status": "success"}), \
patch("agent_loop._merge_pr") as mock_merge, \
patch("agent_loop._close_issue") as mock_close, \
patch("agent_loop._clear_state"):
result = agent_loop._run_loop()
self.assertEqual(result, 0)
mock_merge.assert_called_once_with(5)
mock_close.assert_called_once_with(10)
def test_no_pending_issue_ci_fix_without_issue(self):
"""ci-fix for a manual push (no pending issue) does not try to close anything."""
with patch("agent_loop._read_state", return_value={
"pid": 999999999, "issue": None, "started_at": "2026-01-01T00:00:00+00:00",
"type": "ci-fix",
}), \
patch("agent_loop._latest_ci_run", return_value={"id": 1, "status": "success"}), \
patch("agent_loop._close_issue") as mock_close, \
patch("agent_loop._ready_issues", return_value=[]), \
patch("agent_loop._clear_state"):
result = agent_loop._run_loop()
self.assertEqual(result, 0)
mock_close.assert_not_called()
class TestOutputFormat(unittest.TestCase):
"""Verify output format: no [agent_loop] prefix, URLs in output."""
def test_output_starts_with_header(self):
buf = io.StringIO()
with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._latest_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[]), \
contextlib.redirect_stdout(buf):
agent_loop._run_loop()
first_line = buf.getvalue().splitlines()[0]
self.assertTrue(first_line.startswith("---------------------- Starting "),
f"Unexpected first line: {first_line!r}")
def test_no_agent_loop_prefix_in_output(self):
buf = io.StringIO()
with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._latest_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[]), \
contextlib.redirect_stdout(buf):
agent_loop._run_loop()
self.assertNotIn("[agent_loop]", buf.getvalue())
def test_ci_run_output_contains_url(self):
run = {"id": 4145144, "status": "running"}
buf = io.StringIO()
with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._latest_ci_run", return_value=run), \
contextlib.redirect_stdout(buf):
agent_loop._run_loop()
self.assertIn("https://codeberg.org/guettli/sharedinbox/actions/runs/4145144",
buf.getvalue())
def test_issue_output_contains_url_and_title(self):
issue = {"number": 128, "title": "Fix something", "body": "", "labels": []}
buf = io.StringIO()
with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._latest_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[issue]), \
patch("agent_loop._set_labels"), \
patch("agent_loop._start_agent", return_value=99), \
patch("agent_loop._write_state"), \
contextlib.redirect_stdout(buf):
agent_loop._run_loop()
output = buf.getvalue()
self.assertIn("https://codeberg.org/guettli/sharedinbox/issues/128", output)
self.assertIn("Fix something", output)
class TestLatestCiRunForBranch(unittest.TestCase):
"""Tests for _latest_ci_run_for_branch — Forgejo API field mapping."""
def _make_pr_run(self, branch: str, status: str = "success") -> dict:
payload = json.dumps({"pull_request": {"head": {"ref": branch}}})
return {"event": "pull_request", "event_payload": payload, "status": status, "id": 1}
def _make_push_run(self, prettyref: str, status: str = "success") -> dict:
return {"event": "push", "prettyref": prettyref, "status": status, "id": 2}
def _mock_tea_runs(self, runs):
with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}) as m:
yield m
def test_pr_event_matches_via_event_payload(self):
run = self._make_pr_run("issue-166-fix")
with patch("agent_loop._tea_get", return_value={"workflow_runs": [run]}):
result = agent_loop._latest_ci_run_for_branch("issue-166-fix")
self.assertIsNotNone(result)
self.assertEqual(result["id"], 1)
def test_pr_event_does_not_match_wrong_branch(self):
run = self._make_pr_run("issue-99-fix")
with patch("agent_loop._tea_get", return_value={"workflow_runs": [run]}):
result = agent_loop._latest_ci_run_for_branch("issue-166-fix")
self.assertIsNone(result)
def test_push_event_matches_via_prettyref(self):
run = self._make_push_run("issue-166-fix")
with patch("agent_loop._tea_get", return_value={"workflow_runs": [run]}):
result = agent_loop._latest_ci_run_for_branch("issue-166-fix")
self.assertIsNotNone(result)
self.assertEqual(result["id"], 2)
def test_push_event_prettyref_pr_number_does_not_match_branch(self):
# Forgejo sets prettyref="#169" for PR runs — must not match branch name.
run = {"event": "push", "prettyref": "#169", "status": "success", "id": 3}
with patch("agent_loop._tea_get", return_value={"workflow_runs": [run]}):
result = agent_loop._latest_ci_run_for_branch("issue-166-fix")
self.assertIsNone(result)
def test_head_branch_field_absent_still_works(self):
# Regression: the old code used run.get("head_branch") which is absent in Forgejo.
run = self._make_pr_run("issue-166-fix")
self.assertNotIn("head_branch", run)
with patch("agent_loop._tea_get", return_value={"workflow_runs": [run]}):
result = agent_loop._latest_ci_run_for_branch("issue-166-fix")
self.assertIsNotNone(result)
def test_returns_none_when_no_runs(self):
with patch("agent_loop._tea_get", return_value={"workflow_runs": []}):
result = agent_loop._latest_ci_run_for_branch("issue-166-fix")
self.assertIsNone(result)
def test_returns_first_matching_run(self):
runs = [
self._make_pr_run("issue-166-fix", status="success"),
self._make_pr_run("issue-166-fix", status="failure"),
]
runs[0]["id"] = 10
runs[1]["id"] = 11
with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}):
result = agent_loop._latest_ci_run_for_branch("issue-166-fix")
self.assertEqual(result["id"], 10)
class TestFindSessionUuid(unittest.TestCase):
"""Tests for _find_session_uuid()."""
def _write_jsonl(self, directory: Path, filename: str, entries: list) -> Path:
path = directory / filename
with path.open("w") as fh:
for entry in entries:
fh.write(json.dumps(entry) + "\n")
return path
def test_returns_uuid_for_matching_session_name(self):
with tempfile.TemporaryDirectory() as tmpdir:
projects_dir = Path(tmpdir)
self._write_jsonl(projects_dir, "abc123.jsonl", [
{"type": "agent-name", "agentName": "issue-91", "sessionId": "uuid-abc-123"},
])
orig = agent_loop.CLAUDE_PROJECTS_DIR
agent_loop.CLAUDE_PROJECTS_DIR = projects_dir
try:
result = agent_loop._find_session_uuid("issue-91")
finally:
agent_loop.CLAUDE_PROJECTS_DIR = orig
self.assertEqual(result, "uuid-abc-123")
def test_returns_none_when_name_does_not_match(self):
with tempfile.TemporaryDirectory() as tmpdir:
projects_dir = Path(tmpdir)
self._write_jsonl(projects_dir, "abc123.jsonl", [
{"type": "agent-name", "agentName": "issue-99", "sessionId": "uuid-abc-123"},
])
orig = agent_loop.CLAUDE_PROJECTS_DIR
agent_loop.CLAUDE_PROJECTS_DIR = projects_dir
try:
result = agent_loop._find_session_uuid("issue-91")
finally:
agent_loop.CLAUDE_PROJECTS_DIR = orig
self.assertIsNone(result)
def test_returns_none_when_directory_missing(self):
orig = agent_loop.CLAUDE_PROJECTS_DIR
agent_loop.CLAUDE_PROJECTS_DIR = Path("/nonexistent/path/that/does/not/exist")
try:
result = agent_loop._find_session_uuid("issue-91")
finally:
agent_loop.CLAUDE_PROJECTS_DIR = orig
self.assertIsNone(result)
def test_returns_none_when_no_agent_name_entry(self):
with tempfile.TemporaryDirectory() as tmpdir:
projects_dir = Path(tmpdir)
self._write_jsonl(projects_dir, "abc123.jsonl", [
{"type": "message", "content": "hello"},
])
orig = agent_loop.CLAUDE_PROJECTS_DIR
agent_loop.CLAUDE_PROJECTS_DIR = projects_dir
try:
result = agent_loop._find_session_uuid("issue-91")
finally:
agent_loop.CLAUDE_PROJECTS_DIR = orig
self.assertIsNone(result)
def test_scans_multiple_files_to_find_match(self):
with tempfile.TemporaryDirectory() as tmpdir:
projects_dir = Path(tmpdir)
self._write_jsonl(projects_dir, "aaa.jsonl", [
{"type": "agent-name", "agentName": "issue-10", "sessionId": "uuid-10"},
])
self._write_jsonl(projects_dir, "bbb.jsonl", [
{"type": "agent-name", "agentName": "issue-91", "sessionId": "uuid-91"},
])
orig = agent_loop.CLAUDE_PROJECTS_DIR
agent_loop.CLAUDE_PROJECTS_DIR = projects_dir
try:
result = agent_loop._find_session_uuid("issue-91")
finally:
agent_loop.CLAUDE_PROJECTS_DIR = orig
self.assertEqual(result, "uuid-91")
class TestRunLoopResumeCommand(unittest.TestCase):
"""Tests that _run_loop() shows a UUID-based resume command when agent is running."""
def _alive_state(self, session_name="issue-91"):
return {
"pid": os.getpid(), # own PID is always alive
"issue": 91,
"started_at": "2026-05-23T12:00:00+00:00",
"type": "issue",
"session_name": session_name,
}
def test_resume_shows_uuid_when_found(self):
buf = io.StringIO()
fake_uuid = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
with patch("agent_loop._read_state", return_value=self._alive_state()), \
patch("agent_loop._agent_alive", return_value=True), \
patch("agent_loop._agent_age_seconds", return_value=600), \
patch("agent_loop._find_session_uuid", return_value=fake_uuid), \
patch("agent_loop._git_summary", return_value=""), \
contextlib.redirect_stdout(buf):
agent_loop._run_loop()
output = buf.getvalue()
self.assertIn(f"claude --resume {fake_uuid}", output)
def test_resume_shows_list_hint_when_uuid_not_found(self):
buf = io.StringIO()
with patch("agent_loop._read_state", return_value=self._alive_state()), \
patch("agent_loop._agent_alive", return_value=True), \
patch("agent_loop._agent_age_seconds", return_value=600), \
patch("agent_loop._find_session_uuid", return_value=None), \
patch("agent_loop._git_summary", return_value=""), \
contextlib.redirect_stdout(buf):
agent_loop._run_loop()
output = buf.getvalue()
self.assertIn("scripts/agent_loop.py list", output)
# Must NOT show the session name as a valid resume argument.
self.assertNotIn("claude --resume issue-91", output)
def test_resume_not_shown_when_no_session_name(self):
state = self._alive_state()
del state["session_name"]
buf = io.StringIO()
with patch("agent_loop._read_state", return_value=state), \
patch("agent_loop._agent_alive", return_value=True), \
patch("agent_loop._agent_age_seconds", return_value=600), \
patch("agent_loop._find_session_uuid", return_value=None), \
patch("agent_loop._git_summary", return_value=""), \
contextlib.redirect_stdout(buf):
agent_loop._run_loop()
output = buf.getvalue()
self.assertNotIn("Resume:", output)
if __name__ == "__main__":
unittest.main()
+200
View File
@@ -0,0 +1,200 @@
#!/usr/bin/env python3
"""Tests for deploy_playstore.py."""
import os
import sys
import unittest
from pathlib import Path
from unittest.mock import MagicMock, call, patch
sys.path.insert(0, str(Path(__file__).parent))
import deploy_playstore
def _make_session(
edit_id="edit-42",
version_code=7,
upload_side_effects=None,
):
"""Return a mock AuthorizedSession with sensible defaults."""
session = MagicMock()
# POST /edits → create edit
edit_resp = MagicMock()
edit_resp.json.return_value = {"id": edit_id}
session.post.return_value = edit_resp
# POST resumable-init → Location header
init_resp = MagicMock()
init_resp.headers = {"Location": "https://upload.example.com/session"}
# PUT upload → bundle JSON
upload_resp = MagicMock()
upload_resp.json.return_value = {"versionCode": version_code}
if upload_side_effects is not None:
# Use side_effect list: first call is edit create, rest are upload inits
# We override the PUT side effects via _upload_aab_resumable mock instead
pass
return session, init_resp, upload_resp
class TestMainEnvChecks(unittest.TestCase):
def test_missing_env_exits(self):
with patch.dict(os.environ, {}, clear=True):
with self.assertRaises(SystemExit) as ctx:
deploy_playstore.main()
self.assertEqual(ctx.exception.code, 1)
def test_missing_aab_exits(self):
fake_config = '{"type": "service_account"}'
with patch.dict(os.environ, {"PLAY_STORE_CONFIG_JSON": fake_config}):
with patch("deploy_playstore.os.path.exists", return_value=False):
with self.assertRaises(SystemExit) as ctx:
deploy_playstore.main()
self.assertEqual(ctx.exception.code, 1)
class TestMainHappyPath(unittest.TestCase):
def _run_main(self, fake_config='{"type":"service_account"}'):
mock_session = MagicMock()
# POST for edit create and commit
post_responses = [
MagicMock(**{"json.return_value": {"id": "edit-42"}}), # create edit
MagicMock(), # commit
]
mock_session.post.side_effect = post_responses
# PUT for track update
mock_session.put.return_value = MagicMock()
with patch.dict(os.environ, {"PLAY_STORE_CONFIG_JSON": fake_config}):
with patch("deploy_playstore.os.path.exists", return_value=True):
with patch("deploy_playstore.service_account.Credentials.from_service_account_info"):
with patch("deploy_playstore.AuthorizedSession", return_value=mock_session):
with patch(
"deploy_playstore._upload_aab_resumable",
return_value={"versionCode": 7},
):
deploy_playstore.main()
return mock_session
def test_creates_edit(self):
session = self._run_main()
create_call = session.post.call_args_list[0]
self.assertIn("/edits", create_call[0][0])
def test_commits_edit(self):
session = self._run_main()
commit_call = session.post.call_args_list[1]
self.assertIn(":commit", commit_call[0][0])
def test_updates_track(self):
session = self._run_main()
track_call = session.put.call_args_list[0]
self.assertIn("/tracks/", track_call[0][0])
class TestUploadRetry(unittest.TestCase):
def _run_main(self, upload_side_effects, sleep_mock=None):
mock_session = MagicMock()
post_responses = [
MagicMock(**{"json.return_value": {"id": "edit-1"}}),
MagicMock(),
]
mock_session.post.side_effect = post_responses
mock_session.put.return_value = MagicMock()
patches = [
patch.dict(os.environ, {"PLAY_STORE_CONFIG_JSON": '{"type":"service_account"}'}),
patch("deploy_playstore.os.path.exists", return_value=True),
patch("deploy_playstore.service_account.Credentials.from_service_account_info"),
patch("deploy_playstore.AuthorizedSession", return_value=mock_session),
patch("deploy_playstore._upload_aab_resumable", side_effect=upload_side_effects),
patch("deploy_playstore.time.sleep"),
]
for p in patches:
p.start()
try:
deploy_playstore.main()
finally:
for p in patches:
p.stop()
def test_succeeds_on_first_attempt(self):
with patch("deploy_playstore._upload_aab_resumable", return_value={"versionCode": 5}) as mock_upload:
with patch.dict(os.environ, {"PLAY_STORE_CONFIG_JSON": '{"type":"service_account"}'}):
with patch("deploy_playstore.os.path.exists", return_value=True):
with patch("deploy_playstore.service_account.Credentials.from_service_account_info"):
mock_session = MagicMock()
mock_session.post.side_effect = [
MagicMock(**{"json.return_value": {"id": "e1"}}),
MagicMock(),
]
mock_session.put.return_value = MagicMock()
with patch("deploy_playstore.AuthorizedSession", return_value=mock_session):
deploy_playstore.main()
mock_upload.assert_called_once()
def test_retries_once_on_error_then_succeeds(self):
self._run_main([ValueError("transient"), {"versionCode": 9}])
def test_raises_after_all_attempts_exhausted(self):
with self.assertRaises(RuntimeError) as ctx:
self._run_main([ValueError("err"), ValueError("err"), ValueError("err")])
self.assertIn(str(deploy_playstore._MAX_UPLOAD_ATTEMPTS), str(ctx.exception))
def test_backoff_delays_are_10s_then_20s(self):
mock_session = MagicMock()
mock_session.post.side_effect = [
MagicMock(**{"json.return_value": {"id": "e1"}}),
MagicMock(),
]
mock_session.put.return_value = MagicMock()
with patch.dict(os.environ, {"PLAY_STORE_CONFIG_JSON": '{"type":"service_account"}'}):
with patch("deploy_playstore.os.path.exists", return_value=True):
with patch("deploy_playstore.service_account.Credentials.from_service_account_info"):
with patch("deploy_playstore.AuthorizedSession", return_value=mock_session):
with patch(
"deploy_playstore._upload_aab_resumable",
side_effect=[ValueError("e"), ValueError("e"), {"versionCode": 3}],
):
with patch("deploy_playstore.time.sleep") as mock_sleep:
deploy_playstore.main()
mock_sleep.assert_has_calls([call(10), call(20)])
class TestUploadAabResumable(unittest.TestCase):
def test_initiates_and_uploads(self):
mock_session = MagicMock()
init_resp = MagicMock()
init_resp.headers = {"Location": "https://upload.example.com/sess"}
upload_resp = MagicMock()
upload_resp.json.return_value = {"versionCode": 42}
mock_session.post.return_value = init_resp
mock_session.put.return_value = upload_resp
import tempfile
with tempfile.NamedTemporaryFile(delete=False) as f:
f.write(b"fake-aab-content")
aab_path = f.name
try:
result = deploy_playstore._upload_aab_resumable(
mock_session, "com.example.app", "edit-1", aab_path
)
finally:
os.unlink(aab_path)
self.assertEqual(result["versionCode"], 42)
mock_session.post.assert_called_once()
mock_session.put.assert_called_once()
put_call = mock_session.put.call_args
self.assertEqual(put_call[0][0], "https://upload.example.com/sess")
if __name__ == "__main__":
unittest.main()
+89
View File
@@ -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
+5 -10
View File
@@ -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
View File
@@ -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
+3 -9
View File
@@ -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 && {
+67
View File
@@ -1,6 +1,8 @@
import 'dart:async';
import 'package:flutter/services.dart' show MissingPluginException;
import 'package:mockito/annotations.dart';
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/mailbox.dart';
import 'package:sharedinbox/core/repositories/account_repository.dart';
@@ -30,6 +32,40 @@ void main() {
// This is hard to test without real loops, but we can verify it doesn't crash.
manager.syncNow('unknown');
});
// Regression test for issue #200: when flutter_secure_storage throws
// MissingPluginException (channel unavailable on the device), the IMAP sync
// loop must stop permanently instead of retrying indefinitely with backoff.
test(
'MissingPluginException from secure storage stops IMAP sync loop permanently',
() async {
final syncLog = FakeSyncLogRepository();
final m = AccountSyncManager(
_AccountRepositoryWithMissingPlugin(),
FakeMailboxRepositoryWithInbox(),
FakeEmailRepository(),
syncLog: syncLog,
);
m.start();
// Allow the first sync cycle to run and fail.
await Future<void>.delayed(const Duration(milliseconds: 100));
expect(syncLog.logs, hasLength(1));
expect(syncLog.logs.first.success, isFalse);
// Kicking the loop should have no effect once it has stopped permanently.
m.syncNow('1');
await Future<void>.delayed(const Duration(milliseconds: 100));
// Before the fix: kick triggers a retry → 2 log entries.
// After the fix: loop is permanently stopped → still exactly 1 entry.
expect(syncLog.logs, hasLength(1));
m.dispose();
});
}
class FakeEmailRepository implements EmailRepository {
@@ -187,3 +223,34 @@ class FakeMailboxRepositoryWithInbox implements MailboxRepository {
@override
Future<void> clearForResync(String accountId) async {}
}
class _AccountRepositoryWithMissingPlugin implements AccountRepository {
static const _account = Account(
id: '1',
displayName: 'Test',
email: 'test@example.com',
);
@override
Stream<List<Account>> observeAccounts() => Stream.value([_account]);
@override
Future<Account?> getAccount(String id) async => _account;
@override
Future<String> getPassword(String accountId) => Future.error(
MissingPluginException(
'No implementation found for method read on channel '
'plugins.it.nomads.com/flutter_secure_storage',
),
);
@override
Future<void> addAccount(Account account, String password) async {}
@override
Future<void> updateAccount(Account account, {String? password}) async {}
@override
Future<void> removeAccount(String id) async {}
}
+20
View File
@@ -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);
});
}
+156
View File
@@ -0,0 +1,156 @@
import 'dart:async';
import 'dart:io';
import 'package:fake_async/fake_async.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
import 'package:sharedinbox/data/db/database.dart';
// Fake PathProviderPlatform that always throws PlatformException(channel-error)
// to simulate the Pigeon channel not being ready at startup (issue #166).
class _UnavailablePathProvider extends Fake
with MockPlatformInterfaceMixin
implements PathProviderPlatform {
@override
Future<String?> getApplicationSupportPath() async {
throw PlatformException(
code: 'channel-error',
message: 'Simulated: path_provider channel not ready',
);
}
}
// Fake PathProviderPlatform that fails the first [failCount] calls, then
// returns a fixed path. Used to exercise the retry loop in
// _resolveDatabasePath() without waiting for real timers.
class _SucceedAfterNPathProvider extends Fake
with MockPlatformInterfaceMixin
implements PathProviderPlatform {
_SucceedAfterNPathProvider({required this.failCount});
final int failCount;
int _callCount = 0;
@override
Future<String?> getApplicationSupportPath() async {
_callCount++;
if (_callCount <= failCount) {
throw PlatformException(
code: 'channel-error',
message: 'Simulated: path_provider channel not ready',
);
}
return '/tmp/test_app_support';
}
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
// Regression test for https://codeberg.org/guettli/sharedinbox/issues/166:
// On some slow Android devices the path_provider Pigeon channel is not ready
// when initDatabasePath() runs before runApp(). initDatabasePath() must
// absorb the PlatformException and let the app start; _resolveDatabasePath()
// then retries with back-off on first DB access.
test(
'initDatabasePath completes without throwing when path_provider is unavailable',
() async {
final prev = PathProviderPlatform.instance;
PathProviderPlatform.instance = _UnavailablePathProvider();
addTearDown(() => PathProviderPlatform.instance = prev);
// Must not throw — the exception is swallowed so the app can continue.
await expectLater(initDatabasePath(), completes);
},
);
// Tests for _resolveDatabasePath() — the lazy retry path called on first DB
// access when initDatabasePath() already failed. fake_async lets us advance
// the back-off timers without waiting real-world milliseconds.
test(
'_resolveDatabasePath retries and eventually succeeds after transient failures',
() {
resetDatabasePathForTesting();
final prev = PathProviderPlatform.instance;
// Fail 3 times, succeed on the 4th call. The delays in
// _resolveDatabasePath are [200, 500, 1000, 2000, 4000] ms, so three
// failures cost 200+500+1000 = 1700 ms before the fourth attempt.
PathProviderPlatform.instance = _SucceedAfterNPathProvider(failCount: 3);
addTearDown(() {
PathProviderPlatform.instance = prev;
resetDatabasePathForTesting();
});
fakeAsync((fake) {
String? result;
unawaited(resolveDatabasePathForTesting().then((r) => result = r));
// Advance fake time through the three back-off delays.
fake.elapse(const Duration(milliseconds: 200 + 500 + 1000 + 1));
expect(result, isNotNull);
expect(result, endsWith('sharedinbox.db'));
});
},
);
test(
'_resolveDatabasePath throws PlatformException after exhausting all retries',
() {
resetDatabasePathForTesting();
final prev = PathProviderPlatform.instance;
PathProviderPlatform.instance = _UnavailablePathProvider();
addTearDown(() {
PathProviderPlatform.instance = prev;
resetDatabasePathForTesting();
});
fakeAsync((fake) {
Object? caughtError;
unawaited(
resolveDatabasePathForTesting().catchError((Object e) {
caughtError = e;
return ''; // ignored; satisfies the Future<String> return type
}),
);
// Advance past all five back-off delays: 200+500+1000+2000+4000 ms.
fake.elapse(
const Duration(milliseconds: 200 + 500 + 1000 + 2000 + 4000 + 1),
);
expect(caughtError, isA<PlatformException>());
expect(
(caughtError! as PlatformException).message,
contains('cannot open database'),
);
});
},
// The Android fallback runs only on Android, so on the host machine the
// exception is still thrown after all retries. Skip on Android to avoid
// depending on /data/user/0/... being absent in the test environment.
skip: Platform.isAndroid,
);
// Regression test for issue #192: _androidFallbackPath must return null when
// the process cmdline does not look like an Android package name (e.g. on
// the host test machine where the process is the Dart executable).
test(
'_androidFallbackPath returns null when process name is not a package name',
() async {
// On non-Android platforms the host process cmdline is a file-system path
// (starts with '/'), which the fallback correctly rejects. On Android
// the process IS named after the package — the fallback is free to
// succeed or return null depending on the device state; we do not assert
// here so as not to constrain Android behaviour.
if (!Platform.isAndroid) {
final result = await androidFallbackPathForTesting();
expect(result, isNull);
}
},
);
}
+26
View File
@@ -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);
});
}
+4
View File
@@ -151,6 +151,10 @@ void main() {
expect(clipboardText, contains('Dark Mode'));
expect(clipboardText, contains('IMAP Accounts'));
expect(clipboardText, contains('JMAP Accounts'));
expect(
clipboardText,
contains('[sharedinbox.de](https://sharedinbox.de)'),
);
});
testWidgets('AboutScreen create-issue button opens Codeberg URL', (
+1
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/misc.dart' show Override;
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
+89 -2
View File
@@ -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'),
);
},
);
}
+1 -1
View File
@@ -3,7 +3,7 @@ import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/misc.dart' show Override;
import 'package:flutter_test/flutter_test.dart';
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
@@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/misc.dart' show Override;
import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/core/models/email.dart';
+13 -3
View File
@@ -6,6 +6,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/misc.dart' show Override;
import 'package:go_router/go_router.dart';
import 'package:sharedinbox/core/models/account.dart';
@@ -19,6 +20,7 @@ import 'package:sharedinbox/core/repositories/email_repository.dart';
import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
import 'package:sharedinbox/core/repositories/search_history_repository.dart';
import 'package:sharedinbox/core/repositories/share_key_repository.dart';
import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
import 'package:sharedinbox/core/services/account_discovery_service.dart';
import 'package:sharedinbox/core/services/connection_test_service.dart';
import 'package:sharedinbox/core/services/managesieve_probe_service.dart';
@@ -473,10 +475,18 @@ Widget buildApp({
);
return ProviderScope(
// Always neutralise the ManageSieve probe so widget tests never open a
// real socket. Tests that need to assert on probe behaviour should supply
// their own override before this default in [overrides].
// Defaults come first so tests can override them via [overrides].
//
// syncHealthProvider and syncLogRepositoryProvider are backed by Drift
// StreamQueries. When a StreamProvider that wraps a Drift query is disposed,
// Drift schedules a Timer.run() for cache debouncing. Flutter's test
// framework then fails the test with "A Timer is still pending". Replacing
// these with simple synchronous streams avoids the pending-timer assertion.
overrides: [
syncHealthProvider.overrideWith((ref, _) => Stream.value(null)),
syncLogRepositoryProvider.overrideWithValue(
const NoOpSyncLogRepository(),
),
...overrides,
manageSieveProbeServiceProvider.overrideWith(
(ref) => _NoOpManageSieveProbeService(),
+22
View File
@@ -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.
+99
View File
@@ -0,0 +1,99 @@
# Hugo PaperMod
**A fast, clean, and responsive theme for [Hugo](https://gohugo.io/).**
[![hugo-papermod](https://img.shields.io/badge/Hugo--Themes-@PaperMod-blue)](https://themes.gohugo.io/themes/hugo-papermod/)
[![Minimum Hugo Version](https://img.shields.io/static/v1?label=Hugo&message=v0.146.0%2B&color=blue&logo=hugo)](https://github.com/gohugoio/hugo/releases/tag/v0.146.0)
[![Discord](https://img.shields.io/discord/971046860317921340?label=Discord&logo=discord)](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&amp;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 📈
[![Stargazers over time](https://starchart.cc/adityatelange/hugo-PaperMod.svg?background=%23ffffff00&axis=%23858585&line=%236b63ff)](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
*/
+3
View File
@@ -0,0 +1,3 @@
module github.com/adityatelange/hugo-PaperMod
go 1.16
+28
View File
@@ -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: "نسخ الكود"
+39
View File
@@ -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: "скапіявана!"
+16
View File
@@ -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: "Преводи"
+33
View File
@@ -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: "কপি হয়েছে!"
+33
View File
@@ -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!"
+25
View File
@@ -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: "لەبەر گیرایەوە!"
+33
View File
@@ -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!"
+28
View File
@@ -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!"
+33
View File
@@ -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!"
+33
View File
@@ -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: "αντιγράφηκε!"
+33
View File
@@ -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!"
+25
View File
@@ -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!"
+33
View File
@@ -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!"
+28
View File
@@ -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: "کپی شد!"
+33
View File
@@ -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!"
+33
View File
@@ -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é !"
+33
View File
@@ -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