"body":"Depends on: *Replace Codeberg dependency with local Forgejo instance*\r\n\r\n## Problem\r\n\r\nWith agents operating against a local Forgejo instance, the code is no longer visible on any public forge. For adoption (stars, forks, contributors — goals listed in VISION.md) the repo needs a public presence. Codeberg and GitHub serve different audiences: Codeberg for the FOSS community, GitHub for wider reach.\r\n\r\n## Solution\r\n\r\nAfter every successful merge to the primary branch, push to configured mirror remotes. Mirrors are read-only — agents never read from them. Pushes are fire-and-forget: failures are logged but never block the pipeline.\r\n\r\n## Scope\r\n\r\n### 1. Project TOML configuration\r\n\r\nAdd a `[mirrors]` section:\r\n\r\n```toml\r\nname = \"harb\"\r\nrepo = \"johba/harb\"\r\nforge_url = \"http://localhost:3000\"\r\nprimary_branch = \"master\"\r\n\r\n[mirrors]\r\ngithub = \"git@github.com:johba/harb.git\"\r\ncodeberg = \"git@codeberg.org:johba/harb.git\"\r\n```\r\n\r\nValues are push URLs, not slugs — this keeps it explicit and avoids guessing SSH vs HTTPS. Any number of mirrors can be listed; the key (github, codeberg, etc.) is just a human-readable name used in log messages.\r\n\r\n### 2. `lib/load-project.sh` — parse mirrors\r\n\r\nExtend the TOML parser to export mirror URLs. Add to the Python block:\r\n\r\n```python\r\nmirrors = cfg.get('mirrors', {})\r\nfor name, url in mirrors.items():\r\n emit(f'MIRROR_{name.upper()}', url)\r\n```\r\n\r\nThis exports `MIRROR_GITHUB`, `MIRROR_CODEBERG`, etc. as env vars. Also emit a space-separated list for iteration:\r\n\r\n```python\r\nif mirrors:\r\n emit('MIRROR_NAMES', list(mirrors.keys()))\r\n emit('MIRROR_URLS', list(mirrors.values()))\r\n```\r\n\r\n### 3. `lib/mirrors.sh` — shared push helper\r\n\r\nNew file:\r\n\r\n```bash\r\n#!/usr/bin/env bash\r\n# mirrors.sh — Push primary branch + tags to configured mirror remotes.\r\n#\r\n# Usage: source lib/mirrors.sh; mirror_push\r\n# Requires: PROJECT_REPO_ROOT, PRIMARY_BRANCH, MIRROR_* vars from load-project.sh\r\n\r\nmirror_push() {\r\n [ -z \"${MIRROR_NAMES:-}\" ] && return 0\r\n\r\n local name url\r\n local i=0\r\n for name in $MIRROR_NAMES; do\r\n url=$(eval \"echo \\$MIRROR_$(echo \"$name\" | tr '[:lower:]' '[:upper:]')\")\r\n [ -z \"$url\" ] && continue\r\n\r\n # Ensure remote exists\r\n git -C \"$PROJECT_REPO_ROOT\" remote get-url \"$name\" &>/dev/null \\\r\n || git -C \"$PROJECT_REPO_ROOT\" remote add \"$name\" \"$url\"\r\n\r\n # Fire-and-forget push (background, no failure propagation)\r\n git -C \"$PROJECT_REPO_ROOT\" push \"$name\" \"$PRIMARY_BRANCH\" --tags 2>/dev/null &\r\n log \"mirror: pushed to ${name} (pid $!)\"\r\n done\r\n}\r\n```\r\n\r\nBackground pushes so the agent doesn't block on slow upstreams. SSH keys for GitHub/Codeberg are the user's responsibility (existing SSH agent or deploy keys).\r\n\r\n### 4. Call `mirror_push()` at the three merge sites\r\n\r\nThere are three places where PRs get merged:\r\n\r\n**`dev/phase-handler.sh` — `do_merge()`** (line ~193): the main dev-agent merge path. After the successful merge (HTTP 200/204 block), pull the merged primary branch locally and call `mirror_push()`.\r\n\r\n**`dev/dev-poll.sh` — `try_direct_merge()`** (line ~189): fast-path merge for approved + CI-green PRs that don't need a Claude session. Same insertion point after the success check.\r\n\r\n**`gardener/gardener-run.sh` — `_gardener_merge()`** (line ~293): gardener PR merge. Same pattern.\r\n\r\nAt each site, after the forge API confirms the merge:\r\n\r\n```bash\r\n# Pull merged primary branch and push to mirrors\r\ngit -C \"$REPO_ROOT\" fetch origin \"$PRIMARY_BRANCH\" 2>/dev/null || true\r\ngit -C \"$REPO_ROOT\" checkout \"$PRIMARY_BRANCH\" 2>/dev/null || true\r\ngit -C \"$REPO_ROOT\" pull --ff-only origin \"$PRIMARY_BRANCH\"2>/dev/null||true\r\nmirror_push\r\n```\r\n\r\nThefetch/pullisnecessarybecausethemergehappenedviatheforgeAPI,notlocally—thelocalclonen
},
{
"action":"add_label",
"issue":614,
"label":"backlog"
},
{
"action":"edit_body",
"issue":613,
"body":"Depends on: *Replace Codeberg dependency with local Forgejo instance*\r\n\r\n## Problem\r\n\r\nAll authentication tokens and database passwords sit in plaintext on disk in `.env` and `~/.netrc`. On a VPS this means anyone with disk access (compromised account, provider snapshot, backup leak) gets full control over the forge, CI, Matrix bot, and database.\r\n\r\nThe secrets in question:\r\n\r\n- `FORGE_TOKEN` (née `CODEBERG_TOKEN`) — full forge API access: create/delete issues, merge PRs, push code\r\n- `FORGE_REVIEW_TOKEN` (née `REVIEW_BOT_TOKEN`) — same, for the review bot account\r\n- `WOODPECKER_TOKEN` — trigger pipelines, read logs, retry builds\r\n- `WOODPECKER_DB_PASSWORD` — direct Postgres access to all CI state\r\n- `MATRIX_TOKEN` — send messages as the bot, read room history\r\n- Any project-specific secrets (e.g. `BASE_RPC_URL` for on-chain operations)\r\n\r\nMeanwhile the non-secret config (repo slugs, paths, branch names, server URLs, repo IDs) is harmless if leaked and already lives in plaintext in `projects/*.toml` where it belongs.\r\n\r\n## Solution\r\n\r\nUse SOPS with age encryption. Secrets go into `.env.enc` (encrypted, safe to commit). The age private key at `~/.config/sops/age/keys.txt` is the single file that must be protected — LUKS disk encryption on the VPS handles that layer.\r\n\r\n## Scope\r\n\r\n### 1. Secret loading in `lib/env.sh`\r\n\r\nReplace the `.env` source block (lines 10–16) with a two-tier loader:\r\n\r\n```bash\r\nif [ -f \"$FACTORY_ROOT/.env.enc\" ] && command -v sops &>/dev/null; then\r\n set -a\r\n eval \"$(sops -d --output-type dotenv \"$FACTORY_ROOT/.env.enc\" 2>/dev/null)\"\r\n set +a\r\nelif [ -f \"$FACTORY_ROOT/.env\" ]; then\r\n set -a\r\n source \"$FACTORY_ROOT/.env\"\r\n set +a\r\nfi\r\n```\r\n\r\nIf `.env.enc` exists and `sops` is available, decrypt and load. Otherwise fall back to plaintext `.env`. Existing deployments keep working unchanged.\r\n\r\n### 2. `disinto init` generates encrypted secrets\r\n\r\nAfter the Forgejo provisioning step generates tokens (from the Forgejo issue), store them encrypted instead of plaintext:\r\n\r\n- Check for `age-keygen` and `sops` in PATH\r\n- If no age key exists at `~/.config/sops/age/keys.txt`, generate one: `age-keygen -o ~/.config/sops/age/keys.txt 2>/dev/null`\r\n- Extract the public key: `age-keygen -y ~/.config/sops/age/keys.txt`\r\n- Create a `.sops.yaml` in the factory root that pins the age recipient:\r\n\r\n```yaml\r\ncreation_rules:\r\n - path_regex: \\.env\\.enc$\r\n age: \"age1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\"\r\n```\r\n\r\n-Writesecretstoatempfileandencrypt:`sops-e--input-typedotenv--output-typedotenv.env.tmp>.env.enc`\r\n-Removethetempfile\r\n-If`sops`isnotavailable,fallbacktowritingplaintext`.env`withawarning\r\n\r\n###3.Remove`~/.netrc`tokenstorage\r\n\r\n`bin/disinto`currentlywritesforgetokensto`~/.netrc`(the`write_netrc()`function,lines61–81).WithlocalForgejothetokensaregeneratedprogrammaticallyandgostraightinto`.env.enc`.The`~/.netrc`codepathandthefallbackreadin`lib/env.sh`line29shouldberemoved.GitcredentialaccesstothelocalForgejocanusethetokenintheURLoragitcredentialhelperinstead.\r\n\r\n###4.Preflightanddocumentation\r\n\r\n-Add`sops`and`age`totheoptional-toolscheckin`preflight_check()`—warnifmissing,don'thard-fail(plaintext`.env`stillworks)\r\n-Update`.env.example`todocumentwhichvarsaresecretsvs.config\r\n-Update`.gitignore`:add`.env.enc`assafe-to-commit(removefromignore),keep`.env`ignored\r\n-Update`BOOTSTRAP.md`withtheagekeysetupandSOPSworkflow\r\n\r\n###5.Secretrotationhelper\r\n\r\nAdda`disintosecrets`subcommand:\r\n\r\n-`disintosecretsedit`—runs`sops.env.enc`(opensin`$EDITOR`,re-encryptsonsave)\r\n-`disintosecretsshow`—runs`sops-d.env.enc`(printsdecrypted,fordebugging)\r\n-`disintosecretsmigrate`—readsexistingplaintext`.env`,encrypts