docs(mirror): document push mirror setup, sync rules, CLI and events
- commit
- 4d330eac1258dffb161b8644be0c38b0f9667c10
- author
- brice · · 2026-10-20 06:02 UTC
- committer
- brice · · 2026-10-20 10:02 UTC
- parent
- 49d746d
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +91 -1
M README.md +1 -0
| @@ -132,6 +132,7 @@ Give the agent the [`skills/ermite`](skills/ermite/SKILL.md) skill so it knows t | |||
| 132 | 132 | - [Configuration](docs/configuration.md): environment variables | |
| 133 | 133 | - [CLI](docs/cli.md): config file, commands, JSON output | |
| 134 | 134 | - [Git over HTTP](docs/git-http.md): URLs and authentication | |
| 135 | + | - [Push mirror](docs/mirror.md): push repositories to a remote over SSH | |
| 135 | 136 | - [Tokens](docs/tokens.md): scopes, presets, branch-limited tokens | |
| 136 | 137 | - [Merge requests](docs/merge-requests.md) | |
| 137 | 138 | - [Issues](docs/issues.md) | |
| @@ -132,6 +132,7 @@ Give the agent the [`skills/ermite`](skills/ermite/SKILL.md) skill so it knows t | |||||
| 132 | - [Configuration](docs/configuration.md): environment variables | 132 | - [Configuration](docs/configuration.md): environment variables | ||
| 133 | - [CLI](docs/cli.md): config file, commands, JSON output | 133 | - [CLI](docs/cli.md): config file, commands, JSON output | ||
| 134 | - [Git over HTTP](docs/git-http.md): URLs and authentication | 134 | - [Git over HTTP](docs/git-http.md): URLs and authentication | ||
| 135 | + | - [Push mirror](docs/mirror.md): push repositories to a remote over SSH | |||
| 135 | - [Tokens](docs/tokens.md): scopes, presets, branch-limited tokens | 136 | - [Tokens](docs/tokens.md): scopes, presets, branch-limited tokens | ||
| 136 | - [Merge requests](docs/merge-requests.md) | 137 | - [Merge requests](docs/merge-requests.md) | ||
| 137 | - [Issues](docs/issues.md) | 138 | - [Issues](docs/issues.md) | ||
M docs/ci-events.md +9 -0
| @@ -62,3 +62,12 @@ ermite workflow run <file> --repo <repo> [--ref <branch|tag>] [-f name=value … | |||
| 62 | 62 | - Start on a push that creates or deletes a branch or tag (also a merge with `--delete-branch`). | |
| 63 | 63 | - No filters; `[skip ci]` does not apply. A push of more than three tags starts none. | |
| 64 | 64 | - Payload: `ref`, `ref_type`, `pusher_type`, `repository`, `sender`; `create` adds `master_branch` and `description`. | |
| 65 | + | ||
| 66 | + | ## Mirror events | |
| 67 | + | ||
| 68 | + | Not workflow triggers: `ermite watch` reports them when a [push mirror](mirror.md) changes health, once per incident. | |
| 69 | + | ||
| 70 | + | - `mirror.failed`: the mirror started failing or was paused. Payload: `repo`, `state` (`failing` or `paused`), `failure_class` (`diverged`, `auth_denied`, `remote_not_found`, `host_key`, `transient`, `internal`), `reason`. | |
| 71 | + | - `mirror.recovered`: a failing or paused mirror pushed successfully. Payload: `repo`. | |
| 72 | + | ||
| 73 | + | The payload carries neither the remote URL nor the error detail; `ermite mirror status` shows the reason. | |
| @@ -62,3 +62,12 @@ ermite workflow run <file> --repo <repo> [--ref <branch|tag>] [-f name=value … | |||||
| 62 | - Start on a push that creates or deletes a branch or tag (also a merge with `--delete-branch`). | 62 | - Start on a push that creates or deletes a branch or tag (also a merge with `--delete-branch`). | ||
| 63 | - No filters; `[skip ci]` does not apply. A push of more than three tags starts none. | 63 | - No filters; `[skip ci]` does not apply. A push of more than three tags starts none. | ||
| 64 | - Payload: `ref`, `ref_type`, `pusher_type`, `repository`, `sender`; `create` adds `master_branch` and `description`. | 64 | - Payload: `ref`, `ref_type`, `pusher_type`, `repository`, `sender`; `create` adds `master_branch` and `description`. | ||
| 65 | + | ||||
| 66 | + | ## Mirror events | |||
| 67 | + | ||||
| 68 | + | Not workflow triggers: `ermite watch` reports them when a [push mirror](mirror.md) changes health, once per incident. | |||
| 69 | + | ||||
| 70 | + | - `mirror.failed`: the mirror started failing or was paused. Payload: `repo`, `state` (`failing` or `paused`), `failure_class` (`diverged`, `auth_denied`, `remote_not_found`, `host_key`, `transient`, `internal`), `reason`. | |||
| 71 | + | - `mirror.recovered`: a failing or paused mirror pushed successfully. Payload: `repo`. | |||
| 72 | + | ||||
| 73 | + | The payload carries neither the remote URL nor the error detail; `ermite mirror status` shows the reason. | |||
M docs/cli.md +7 -1
| @@ -75,6 +75,10 @@ ermite secret set|list|delete … # CI secrets (needs ERMITE_SECRETS_KEY | |||
| 75 | 75 | ermite variable set|get|list|delete … | |
| 76 | 76 | ermite environment create|list|delete … --repo <repo> | |
| 77 | 77 | ||
| 78 | + | ermite mirror key show | rotate # SSH key to add to the remote (rotate: owner only) | |
| 79 | + | ermite mirror config [--enabled] [--url …{repo}…] [--cron …] [--all-branches] | config show # global policy, owner only | |
| 80 | + | ermite mirror add <repo> <url> [--cron …] [--all-branches] | remove <repo> | exclude <repo> | include <repo> | |
| 81 | + | ermite mirror status [repo] | sync <repo>|--all [--force] | |
| 78 | 82 | ermite web login # one-time sign-in link (60 s) | |
| 79 | 83 | ermite web logout --all # sign every browser out | |
| 80 | 84 | ermite web passkey list | remove <id> | |
| @@ -87,9 +91,11 @@ ermite completion bash|zsh|fish | |||
| 87 | 91 | ||
| 88 | 92 | Issues need a server with the `issues` feature and `ermite repo edit <repo> --issues owner` (or `--issues public`, which also opens a moderated form to visitors of a public repository); they are written with the `issue:write` scope. See [Issues](issues.md). | |
| 89 | 93 | ||
| 94 | + | Mirrors push repositories to a remote over SSH, never forcing. See [Push mirror](mirror.md). | |
| 95 | + | ||
| 90 | 96 | ## Output | |
| 91 | 97 | ||
| 92 | - | - `--json` prints stable field names (listed in each `--help`) on `repo list|show|create|edit`, `issue create|list|show|comment|close|reopen`, `token create|list`, `run list|view|cancel|rerun`, `runner list` and `version`. | |
| 98 | + | - `--json` prints stable field names (listed in each `--help`) on `repo list|show|create|edit`, `issue create|list|show|comment|close|reopen`, `token create|list`, `run list|view|cancel|rerun`, `runner list`, `mirror key|config|status` and `version`. | |
| 93 | 99 | - `watch --json` prints one object per line. `merge_request_mergeability` events carry `mergeability` (`state`, `behind`, `conflict_paths`…) and print as `repo!n mergeability: behind by 2`. | |
| 94 | 100 | - `mr merge --mode squash|merge` creates a commit signed by the server's own key (generated on first start); the output names its fingerprint, `ermite signing-key show` prints the key. Every mode is allowed by default; `repo edit --merge-modes fast-forward,squash` disables `merge`. | |
| 95 | 101 | - Errors are one line on stderr (`ermite: …`) with exit status 1. | |
| @@ -75,6 +75,10 @@ ermite secret set|list|delete … # CI secrets (needs ERMITE_SECRETS_KEY | |||||
| 75 | ermite variable set|get|list|delete … | 75 | ermite variable set|get|list|delete … | ||
| 76 | ermite environment create|list|delete … --repo <repo> | 76 | ermite environment create|list|delete … --repo <repo> | ||
| 77 | 77 | ||||
| 78 | + | ermite mirror key show | rotate # SSH key to add to the remote (rotate: owner only) | |||
| 79 | + | ermite mirror config [--enabled] [--url …{repo}…] [--cron …] [--all-branches] | config show # global policy, owner only | |||
| 80 | + | ermite mirror add <repo> <url> [--cron …] [--all-branches] | remove <repo> | exclude <repo> | include <repo> | |||
| 81 | + | ermite mirror status [repo] | sync <repo>|--all [--force] | |||
| 78 | ermite web login # one-time sign-in link (60 s) | 82 | ermite web login # one-time sign-in link (60 s) | ||
| 79 | ermite web logout --all # sign every browser out | 83 | ermite web logout --all # sign every browser out | ||
| 80 | ermite web passkey list | remove <id> | 84 | ermite web passkey list | remove <id> | ||
| @@ -87,9 +91,11 @@ ermite completion bash|zsh|fish | |||||
| 87 | 91 | ||||
| 88 | Issues need a server with the `issues` feature and `ermite repo edit <repo> --issues owner` (or `--issues public`, which also opens a moderated form to visitors of a public repository); they are written with the `issue:write` scope. See [Issues](issues.md). | 92 | Issues need a server with the `issues` feature and `ermite repo edit <repo> --issues owner` (or `--issues public`, which also opens a moderated form to visitors of a public repository); they are written with the `issue:write` scope. See [Issues](issues.md). | ||
| 89 | 93 | ||||
| 94 | + | Mirrors push repositories to a remote over SSH, never forcing. See [Push mirror](mirror.md). | |||
| 95 | + | ||||
| 90 | ## Output | 96 | ## Output | ||
| 91 | 97 | ||||
| 92 | - | - `--json` prints stable field names (listed in each `--help`) on `repo list|show|create|edit`, `issue create|list|show|comment|close|reopen`, `token create|list`, `run list|view|cancel|rerun`, `runner list` and `version`. | 98 | + | - `--json` prints stable field names (listed in each `--help`) on `repo list|show|create|edit`, `issue create|list|show|comment|close|reopen`, `token create|list`, `run list|view|cancel|rerun`, `runner list`, `mirror key|config|status` and `version`. |
| 93 | - `watch --json` prints one object per line. `merge_request_mergeability` events carry `mergeability` (`state`, `behind`, `conflict_paths`…) and print as `repo!n mergeability: behind by 2`. | 99 | - `watch --json` prints one object per line. `merge_request_mergeability` events carry `mergeability` (`state`, `behind`, `conflict_paths`…) and print as `repo!n mergeability: behind by 2`. | ||
| 94 | - `mr merge --mode squash|merge` creates a commit signed by the server's own key (generated on first start); the output names its fingerprint, `ermite signing-key show` prints the key. Every mode is allowed by default; `repo edit --merge-modes fast-forward,squash` disables `merge`. | 100 | - `mr merge --mode squash|merge` creates a commit signed by the server's own key (generated on first start); the output names its fingerprint, `ermite signing-key show` prints the key. Every mode is allowed by default; `repo edit --merge-modes fast-forward,squash` disables `merge`. | ||
| 95 | - Errors are one line on stderr (`ermite: …`) with exit status 1. | 101 | - Errors are one line on stderr (`ermite: …`) with exit status 1. | ||
A docs/mirror.md +74 -0
| @@ -0,0 +1,74 @@ | |||
| 1 | + | # Push mirror | |
| 2 | + | ||
| 3 | + | Ermite can push its repositories to a remote Git host over SSH (GitHub, another forge…), to keep a read-only copy there. The push never forces and never deletes anything on the remote. | |
| 4 | + | ||
| 5 | + | ## Set up | |
| 6 | + | ||
| 7 | + | 1. The server needs `ERMITE_SECRETS_KEY`; it seals the mirror SSH key. Without it, syncs fail and nothing is stored. | |
| 8 | + | 2. Add the server's public mirror key to the remote account or repository, as a key allowed to push: | |
| 9 | + | ||
| 10 | + | ```sh | |
| 11 | + | ermite mirror key show | gh ssh-key add - --title "ermite mirror" | |
| 12 | + | ``` | |
| 13 | + | ||
| 14 | + | `mirror key show` prints only the `authorized_keys` line. One key serves every mirror. | |
| 15 | + | 3. Set the global policy (owner only). The URL template holds `{repo}`, replaced by the repository name; the cron is five fields, in UTC: | |
| 16 | + | ||
| 17 | + | ```sh | |
| 18 | + | ermite mirror config --url 'git@github.com:acme/{repo}.git' --cron '*/15 * * * *' --enabled | |
| 19 | + | ermite mirror config show | |
| 20 | + | ``` | |
| 21 | + | ||
| 22 | + | `config` changes only the flags you pass. `--all-branches` mirrors every branch instead of the default branch. | |
| 23 | + | 4. Adjust single repositories: | |
| 24 | + | ||
| 25 | + | ```sh | |
| 26 | + | ermite mirror add <repo> <url> [--cron …] [--all-branches] # own URL (and schedule), even when the global policy is off | |
| 27 | + | ermite mirror exclude <repo> # leave the global policy | |
| 28 | + | ermite mirror include <repo> # back under it | |
| 29 | + | ermite mirror remove <repo> # drop the override | |
| 30 | + | ``` | |
| 31 | + | ||
| 32 | + | Without `--cron` or `--all-branches`, `add` inherits the global values. | |
| 33 | + | ||
| 34 | + | URLs are `git@host:path` or `ssh://[user@]host[:port]/path`, with no password. The cron may not fire more often than every 5 minutes. | |
| 35 | + | ||
| 36 | + | ## What is pushed | |
| 37 | + | ||
| 38 | + | - The default branch and all tags; every branch with `--all-branches`. | |
| 39 | + | - Never a force push, never a deletion: a branch or tag deleted here stays on the remote. | |
| 40 | + | - If the remote has commits ermite lacks (diverged), the mirror is **paused**. To fix it, fast-forward ermite with the remote's commits (push them here), then run `ermite mirror sync <repo> --force`. | |
| 41 | + | ||
| 42 | + | ## When it syncs | |
| 43 | + | ||
| 44 | + | - On the cron schedule. A push to ermite only marks the repository as changed; the next due cron tick syncs it. A tick with nothing new does nothing. | |
| 45 | + | - `ermite mirror sync <repo>` or `ermite mirror sync --all` (owner only) syncs now; a repository needs admin rights. | |
| 46 | + | - `--force` skips the no-op check and the 60 s throttle and lifts a pause. It never force-pushes. | |
| 47 | + | - Overlapping syncs of a repository coalesce; only one push runs at a time per repository. | |
| 48 | + | ||
| 49 | + | ## Limits against spam | |
| 50 | + | ||
| 51 | + | | Rule | Value | | |
| 52 | + | |---|---| | |
| 53 | + | | No-op | nothing changed since the last successful push: no connection | | |
| 54 | + | | Manual sync | one per repository per 60 s, unless `--force` | | |
| 55 | + | | Cron | at least 5 minutes between firings | | |
| 56 | + | | Backoff after a transient failure | 5 min, doubling, up to 6 h | | |
| 57 | + | | Paused | after 5 consecutive transient failures | | |
| 58 | + | | Paused at once | diverged remote, key refused, remote not found, host key changed | | |
| 59 | + | ||
| 60 | + | A paused mirror is not retried by the schedule. Changing the mirror configuration of a repository clears its failures. | |
| 61 | + | ||
| 62 | + | ## See what happens | |
| 63 | + | ||
| 64 | + | - `ermite mirror status [<repo>] [--json]`: state (`ok`, `failing`, `paused`, `disabled`), reason, last success, next run, failures. | |
| 65 | + | - A `git push` to a repository whose mirror is failing or paused prints a `remote:` warning, to people with admin rights. | |
| 66 | + | - The repository page shows a badge to owners and admins. | |
| 67 | + | - `ermite watch` receives `mirror.failed` once when a mirror starts failing, and `mirror.recovered` when it succeeds again. See [events](ci-events.md#mirror-events). | |
| 68 | + | ||
| 69 | + | ## Security | |
| 70 | + | ||
| 71 | + | - The private key is sealed at rest with `ERMITE_SECRETS_KEY`. It is never printed and never in logs; URLs are logged without credentials or user. | |
| 72 | + | - Host keys are trusted on first use, kept in `<data dir>/mirror_known_hosts`. If a remote's key changes, the mirror is paused (`host_key`). After checking the change is legitimate, remove that host's line from the file and run `ermite mirror sync <repo> --force`. | |
| 73 | + | - `ermite mirror key rotate` (owner only) makes a new key at once. Every mirror fails until the new key is added to the remotes. | |
| 74 | + | - The scheduler runs in the server process: run a single ermite instance per data directory. | |
| @@ -0,0 +1,74 @@ | |||||
| 1 | + | # Push mirror | |||
| 2 | + | ||||
| 3 | + | Ermite can push its repositories to a remote Git host over SSH (GitHub, another forge…), to keep a read-only copy there. The push never forces and never deletes anything on the remote. | |||
| 4 | + | ||||
| 5 | + | ## Set up | |||
| 6 | + | ||||
| 7 | + | 1. The server needs `ERMITE_SECRETS_KEY`; it seals the mirror SSH key. Without it, syncs fail and nothing is stored. | |||
| 8 | + | 2. Add the server's public mirror key to the remote account or repository, as a key allowed to push: | |||
| 9 | + | ||||
| 10 | + | ```sh | |||
| 11 | + | ermite mirror key show | gh ssh-key add - --title "ermite mirror" | |||
| 12 | + | ``` | |||
| 13 | + | ||||
| 14 | + | `mirror key show` prints only the `authorized_keys` line. One key serves every mirror. | |||
| 15 | + | 3. Set the global policy (owner only). The URL template holds `{repo}`, replaced by the repository name; the cron is five fields, in UTC: | |||
| 16 | + | ||||
| 17 | + | ```sh | |||
| 18 | + | ermite mirror config --url 'git@github.com:acme/{repo}.git' --cron '*/15 * * * *' --enabled | |||
| 19 | + | ermite mirror config show | |||
| 20 | + | ``` | |||
| 21 | + | ||||
| 22 | + | `config` changes only the flags you pass. `--all-branches` mirrors every branch instead of the default branch. | |||
| 23 | + | 4. Adjust single repositories: | |||
| 24 | + | ||||
| 25 | + | ```sh | |||
| 26 | + | ermite mirror add <repo> <url> [--cron …] [--all-branches] # own URL (and schedule), even when the global policy is off | |||
| 27 | + | ermite mirror exclude <repo> # leave the global policy | |||
| 28 | + | ermite mirror include <repo> # back under it | |||
| 29 | + | ermite mirror remove <repo> # drop the override | |||
| 30 | + | ``` | |||
| 31 | + | ||||
| 32 | + | Without `--cron` or `--all-branches`, `add` inherits the global values. | |||
| 33 | + | ||||
| 34 | + | URLs are `git@host:path` or `ssh://[user@]host[:port]/path`, with no password. The cron may not fire more often than every 5 minutes. | |||
| 35 | + | ||||
| 36 | + | ## What is pushed | |||
| 37 | + | ||||
| 38 | + | - The default branch and all tags; every branch with `--all-branches`. | |||
| 39 | + | - Never a force push, never a deletion: a branch or tag deleted here stays on the remote. | |||
| 40 | + | - If the remote has commits ermite lacks (diverged), the mirror is **paused**. To fix it, fast-forward ermite with the remote's commits (push them here), then run `ermite mirror sync <repo> --force`. | |||
| 41 | + | ||||
| 42 | + | ## When it syncs | |||
| 43 | + | ||||
| 44 | + | - On the cron schedule. A push to ermite only marks the repository as changed; the next due cron tick syncs it. A tick with nothing new does nothing. | |||
| 45 | + | - `ermite mirror sync <repo>` or `ermite mirror sync --all` (owner only) syncs now; a repository needs admin rights. | |||
| 46 | + | - `--force` skips the no-op check and the 60 s throttle and lifts a pause. It never force-pushes. | |||
| 47 | + | - Overlapping syncs of a repository coalesce; only one push runs at a time per repository. | |||
| 48 | + | ||||
| 49 | + | ## Limits against spam | |||
| 50 | + | ||||
| 51 | + | | Rule | Value | | |||
| 52 | + | |---|---| | |||
| 53 | + | | No-op | nothing changed since the last successful push: no connection | | |||
| 54 | + | | Manual sync | one per repository per 60 s, unless `--force` | | |||
| 55 | + | | Cron | at least 5 minutes between firings | | |||
| 56 | + | | Backoff after a transient failure | 5 min, doubling, up to 6 h | | |||
| 57 | + | | Paused | after 5 consecutive transient failures | | |||
| 58 | + | | Paused at once | diverged remote, key refused, remote not found, host key changed | | |||
| 59 | + | ||||
| 60 | + | A paused mirror is not retried by the schedule. Changing the mirror configuration of a repository clears its failures. | |||
| 61 | + | ||||
| 62 | + | ## See what happens | |||
| 63 | + | ||||
| 64 | + | - `ermite mirror status [<repo>] [--json]`: state (`ok`, `failing`, `paused`, `disabled`), reason, last success, next run, failures. | |||
| 65 | + | - A `git push` to a repository whose mirror is failing or paused prints a `remote:` warning, to people with admin rights. | |||
| 66 | + | - The repository page shows a badge to owners and admins. | |||
| 67 | + | - `ermite watch` receives `mirror.failed` once when a mirror starts failing, and `mirror.recovered` when it succeeds again. See [events](ci-events.md#mirror-events). | |||
| 68 | + | ||||
| 69 | + | ## Security | |||
| 70 | + | ||||
| 71 | + | - The private key is sealed at rest with `ERMITE_SECRETS_KEY`. It is never printed and never in logs; URLs are logged without credentials or user. | |||
| 72 | + | - Host keys are trusted on first use, kept in `<data dir>/mirror_known_hosts`. If a remote's key changes, the mirror is paused (`host_key`). After checking the change is legitimate, remove that host's line from the file and run `ermite mirror sync <repo> --force`. | |||
| 73 | + | - `ermite mirror key rotate` (owner only) makes a new key at once. Every mirror fails until the new key is added to the remotes. | |||
| 74 | + | - The scheduler runs in the server process: run a single ermite instance per data directory. | |||