The v1.1 P0 sweep (commits7c20d5d,d530921) updated the 3 new shell helpers, the runbook, and 2 runbook lines to point at jc-capital. It missed 2 files: - docs/gitea-actions.md:95 (last touched at Phase 4 / INB-33, before the v1.0→v1.1 org migration; the v1.1 sweep was scoped to .md / .sh created or modified in v1.1) - scripts/setup_gitea_secrets.sh:4,14,18,28 (pre-existing at the v1.0 close; not in the v1.1 diff; filed as v1.1 P1-2 for v1.2 follow-up but v1.2 was a 0/1 disposition so the carry-forward is v1.3) This commit closes the cto-inboxos owner drift in both files using the same env-var-resolved default pattern as the v1.1 P0 sweep: - docs/gitea-actions.md:95 now uses ${GITEA_OWNER:-jc-capital}/inboxos - scripts/setup_gitea_secrets.sh:4,14 use the same ${GITEA_OWNER:-jc-capital} pattern in the header - scripts/setup_gitea_secrets.sh:18 defaults GITEA_OWNER to jc-capital - scripts/setup_gitea_secrets.sh:28 uses ${GITEA_OWNER:-jc-capital} in the example Verified: grep -n cto-inboxos docs/gitea-actions.md scripts/setup_gitea_secrets.sh returns empty. The script is still chmod +x with set -euo pipefail (unchanged). This commit also closes v1.1 backlog item P1-2 (setup_gitea_secrets.sh owner drift, per CI-LOG.md:219-220). ---ci--- project: inboxos phase: 1 milestone: v1.3 status: execute tasks_completed: [T1.1] candidates_closed: [REQ-INB-28, REQ-INB-37] backlog_closures: v1_1_P1_2: REQ-INB-37 files_touched: - docs/gitea-actions.md (1 line, line 95) - scripts/setup_gitea_secrets.sh (4 lines, 4, 14, 18, 28) ---/ci---
12 KiB
InboxOS — Gitea Actions Setup
Owner: CTO Status: Complete (scaffolding) Source issue: INB-8
This document explains how Gitea Actions is wired up to run the InboxOS live and historical email pipelines. It is intentionally short — the workflows themselves are the source of truth.
1. Topology
┌──────────────┐ ┌─────────────────┐ ┌──────────────┐
│ Gitea │ <─── │ gitea-runner │ <─── │ Ollama │
│ (web + API) │ │ gitea-runner-2 │ │ (LLM) │
└──────────────┘ │ (act_runner ×2) │ └──────────────┘
▲ └─────────────────┘
│ poll IMAP │ run jobs
│ upload .eml │ (live + historical
│ │ in parallel)
│ ▼
│ ┌─────────────────┐
│ │ SeaweedFS │ raw .eml archive
│ │ (S3 API) │ + state checkpoints
│ └─────────────────┘
│ │
│ UPSERT │ UPSERT
▼ ▼
┌──────────────┐ ┌─────────────────┐
│ NocoDB │ │ Papra │
│ (DWH) │ │ (attachments) │
└──────────────┘ └─────────────────┘
All services run in the inboxos-net Docker network defined in
docker/docker-compose.yml. The runner can reach every other
service by its compose service name (gitea, nocodb,
seaweedfs-master, ollama, papra).
2. Provisioning the runner
-
Bring the stack up:
cp .env.example .env # fill in real secrets (see .env.example for the full list) docker compose -f docker/docker-compose.yml up -d -
Generate a runner registration token in Gitea:
- Log in to
https://gitea.cloudinit.devas an admin. - Go to Site Administration → Actions → Runners → Create new runner.
- Copy the registration token into
.envasGITEA_RUNNER_REGISTRATION_TOKEN.
- Log in to
-
Restart the runner so it picks up the new token:
docker compose -f docker/docker-compose.yml restart gitea-runner -
Verify in the Gitea UI under Actions → Runners. The runner named
inboxos-runner-1should appear with status Idle and the labelsself-hosted,linux,x64,gitea-runner:docker.
The runner's long-lived token is stored in the
gitea-runner-data named volume, so the registration token is
only needed for first-boot registration. Subsequent restarts
re-use the stored token automatically.
2a. Adding a second runner (INB-33)
A single runner is a point of contention: with
cancel-in-progress: false on the live pipeline, the
historical pipeline cannot start while a live run is in flight.
The fix is to register a second runner (inboxos-runner-2)
with the same label set so both pipelines can execute in
parallel on the same host.
-
Generate a fresh registration token from the same Gitea admin page (Site Administration → Actions → Runners → Create new runner). Tokens are single-use — runner #1's token cannot be reused.
-
Add it to
.envasGITEA_RUNNER_REGISTRATION_TOKEN_2. -
Bring up the second runner:
docker compose -f docker/docker-compose.yml up -d gitea-runner-2 -
Verify both runners are registered and online:
curl -u "$GITEA_USER:$GITEA_PASS" \ https://git.cloudinit.dev/api/v1/repos/${GITEA_OWNER:-jc-capital}/inboxos/actions/runnersExpected: 2 entries with
status: "online"and theself-hosted,linux,x64,gitea-runner:dockerlabel set. -
Confirm parallelism by dispatching
historical-pipeline.ymlwhile alive-pipeline.ymlrun is in progress. The historical job should start within ~30 s instead of sitting inqueued.
To scale further (N>2) we'd templatize this; for now the
explicit gitea-runner-2 service is the simplest, safest
two-runner topology.
3. Repository secrets
Both workflows consume Gitea Actions secrets on the repository
that hosts the pipeline code. In the Gitea web UI, go to the
repo → Settings → Actions → Secrets and add the values from
.env. The required secrets are listed in comments at the top
of each workflow file.
In addition, any workflow step that needs to call the Gitea REST
API (e.g. for repo management or secret rotation) must have
GITEA_INSTANCE_URL, GITEA_USER, and GITEA_PASS set as
secrets. See §5 for the
auth model.
Minimum set per workflow:
OPENCODE_BINandOPENCODE_PROMPT_DIRare still read bypipelines.common.LLMConfig.from_env(for the prompt directory) and by the historical workflow'sEnsure OpenCode is installedstep (for operator convenience). Neither pipeline shells out to the opencode CLI in production; theINBOXOS_LLM_BACKEND=ollamapath is the default and only path used by CI and runtime. Seedocs/extraction-contract.md §Legacy: opencode CLI backendfor the rationale.
| Secret | Live | Historical |
|---|---|---|
IMAP_HOST / IMAP_PORT / IMAP_USER / IMAP_PASSWORD / IMAP_USE_SSL / IMAP_MAILBOX |
✅ | — |
SEAWEEDFS_S3_ENDPOINT / SEAWEEDFS_S3_ACCESS_KEY / SEAWEEDFS_S3_SECRET_KEY / SEAWEEDFS_S3_BUCKET |
✅ | ✅ |
MBOX_BUCKET / MBOX_OBJECT_KEY |
— | ✅ (object key passed as workflow_dispatch input) |
PAPRA_BASE_URL / PAPRA_API_TOKEN / PAPRA_ORGANIZATION_ID |
— | ✅ |
NOCODB_BASE_URL / NOCODB_API_TOKEN / NOCODB_PROJECT_ID |
✅ | ✅ |
OPENCODE_BIN / OPENCODE_PROMPT_DIR |
✅ | ✅ |
OLLAMA_BASE_URL |
✅ | ✅ |
4. Workflows
live-pipeline.yml — net-new email processing
- Trigger:
cron(*/5 * * * *)plusworkflow_dispatch. - Concurrency:
live-pipelinegroup, no cancel (overlapping runs of the live pipeline must not trample each other's state). - Steps: checkout → install Python deps → run
python pipelines/live_pipeline.py→ upload log artifact. - Inputs (manual):
since_uid(override the high-water mark),max_emails(default 50).
The script is idempotent on Message_ID and on the IMAP UID
high-water mark. See pipelines/README.md
for the contract.
historical-pipeline.yml — backlog / rescan
- Trigger:
workflow_dispatchonly (heavy, manual). - Concurrency:
historical-pipelinegroup, no cancel. - Timeout: 12 hours.
- Inputs (manual):
mbox_object_key(required),start_offset,batch_size(default 100),limit(default 0 = unlimited).
The script streams the .mbox from SeaweedFS, uploads
attachments to Papra, and UPSERTs to NocoDB. It checkpoints to
state/historical-state.json every batch_size messages and on
exit, so a failed run can resume mid-file.
5. Authenticating to the Gitea REST API
Any code in this repo that needs to call the Gitea REST API
(repo create, secrets, runner registration lookups, etc.) must go
through pipelines.common.gitea_request with GiteaConfig.from_env().
The helper attaches HTTP Basic credentials from
GITEA_USER / GITEA_PASS to every call.
from common import GiteaConfig, gitea_request
cfg = GiteaConfig.from_env()
resp = gitea_request(cfg, "GET", "/api/v1/user")
resp.raise_for_status()
me = resp.json()
GITEA_INSTANCE_URL should point at the Gitea API base the
pipeline can reach:
- Inside the
gitea-runnercontainer:http://gitea:3000(compose service name oninboxos-net). - From the host / an external agent:
https://git.cloudinit.dev(the public Gitea hostname).
Why Basic Auth and not a personal access token? Gitea's REST
API does not return the plaintext of a freshly created access token
— the response only carries sha1 (a hash) and token_last_eight,
and the plaintext is shown exactly once in the web UI's "Generate
Token" modal. A headless agent can therefore create the token record
but cannot recover its value to put it in a GITEA_TOKEN env var.
Basic Auth with a long-lived service account password avoids that
gap and matches the air-gapped, self-hosted posture of the project.
5xx responses are retried (tenacity, max 5 attempts, exponential
backoff) and then surface as RuntimeError; 4xx responses are
returned to the caller untouched so the caller can decide how to
react. Offline tests live in
pipelines/_tests/test_gitea_request_offline.py and pin the
contract (10 tests covering env parsing, Basic Auth header, 4xx
passthrough, 5xx retry, and connection-error retry).
6. Prompt version control
Extraction prompts live in .opencode/commands/*.md in the same
repository. Both pipelines call pipelines.common.llm_run_command
(default backend: ollama) twice per email:
inboxos:classify— returns{"modules": ["finance", "crm"]}listing which module extractors should run.- For each module in that list,
inboxos:extract-<module>— returns a flat NocoDB-shaped record for that table.
llm_run_command is expected to return one parsed JSON object per
invocation. The exact field contracts are defined in the prompt
files themselves and are reviewed via PR. See
docs/extraction-contract.md for the full contract and the
§Legacy: opencode CLI backend subsection for the offline-test-only
INBOXOS_LLM_BACKEND=opencode path (broken on opencode 1.16.2;
not used in production or in CI).
To roll a prompt change:
- Open a PR with the modified
.md. - Run the historical pipeline against a small
.mboxslice using thelimitinput to validate. - Merge. The next live or historical run picks up the new prompt.
7. Observability
- Each run writes structured JSON logs to
pipelines/_artifacts/<pipeline>-run-<run-id>.log. The workflows upload these as build artifacts (live-pipeline-log,historical-pipeline-log). - Artifact upload is pinned to
actions/upload-artifact@v3in bothlive-pipeline.ymlandhistorical-pipeline.yml. v4 requires GitHub GHES-only endpoints that Gitea Actions does not implement and fails the run withGHESNotSupportedError(observed on live run #674 on 2026-06-07 and on the INB-43 historical probe run #860 / job 1023 on 2026-06-08). v3 talks the older v1 Gitea-compatible artifact API and works. Do not bump to v4+ on either workflow without verifying against the live Gitea instance first; if you need a v4-only feature, raise an issue and route through the CTO. - Gitea surfaces job step output in the Actions UI.
- NocoDB writes return the upserted record; the pipeline logs
{action: post|patch, id, modules}per email.
8. Rollback
| Failure | Recovery |
|---|---|
| Runner offline | docker compose restart gitea-runner (or gitea-runner-2); the other runner continues serving jobs. |
| Bad prompt merged | Revert the PR. The next run uses the reverted prompt; UPSERT semantics mean no data is lost, only overwritten. |
| SeaweedFS state file corrupted | Delete state/live-state.json or state/historical-state.json from the bucket; the next run starts fresh from UID 0 / offset 0 (re-runs are safe — UPSERT dedupes). |
| NocoDB schema broken | Fix the schema, then re-run a workflow. UPSERT keeps the existing Message_ID rows consistent. |
| Runner needs to re-register | Delete the runner's data volume (gitea-runner-data or gitea-runner-2-data), regenerate the registration token, set it in .env (GITEA_RUNNER_REGISTRATION_TOKEN / _2), restart the runner. |