detached cdeae08

docs(readme): rewrite README around the key workflows (#70)

commit
cdeae086b498d9b38a532f0db549f47b6b26f43e
author
brice · · 2026-10-18 17:01 UTC
committer
bnema · · 2026-10-18 17:01 UTC
parent
7fabed9
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
12 files · +649 -181
M README.md +89 -181
@@ -1,234 +1,142 @@
1-# ermite
1+<h1 align="center">ermite</h1>
2+
3+<p align="center">
4+ <a href="LICENSE"><img src="https://img.shields.io/badge/license-AGPL--3.0-blue?style=flat-square" alt="License: AGPL-3.0"></a>
5+ <a href="https://github.com/bnema/ermite/commits/main"><img src="https://badgen.net/github/last-commit/bnema/ermite/main?icon=github" alt="Last commit"></a>
6+ <a href="https://github.com/bnema/ermite/stargazers"><img src="https://badgen.net/github/stars/bnema/ermite?icon=github" alt="GitHub stars"></a>
7+</p>
8+
9+<p align="center">A single-user Git forge in one Go binary.</p>
210
311 > Read-only mirror. Development happens at git.bnema.dev; issues and pull requests are closed here.
412
5-A single-user Git forge in one Go binary. ermite hosts bare repositories, serves clone and push over HTTPS, reviews branches as merge requests, and shows everything in a web interface with live updates.
13+---
614
7-Status: early development. `ermite serve` serves Git over HTTP (clone, fetch, push), the API, `GET /-/health`, and the web: repositories, an expandable file tree with a repository-wide fuzzy file finder, files, commits, refs and merge requests. Visitors read public repositories; the owner signs in with a one-time link from the CLI to see private ones and to review and merge.
15+## Features
816
9-## Run
17+- **GitHub Actions, as is**: your `.github/workflows` run unchanged, marketplace actions included
18+- **Isolated runner**: [ermite-runner](https://github.com/bnema/ermite-runner) runs each job in a Firecracker microVM when KVM is available, else in a container
19+- **CLI-first**: repositories, merge requests, reviews and CI all work from the `ermite` CLI, for you and for AI agents
20+- **Safe for agents**: an agent token can only push its own branches; you review and merge
21+- Git over HTTPS, web interface with live updates
1022
11-```sh
12-export ERMITE_DATA_DIR=/var/lib/ermite
13-export ERMITE_PUBLIC_URL=https://git.example.com
14-export ERMITE_ADMIN_TOKEN=$(head -c 32 /dev/urandom | base64)
15-ermite serve
16-```
23+Status: early development.
1724
18-| Variable | Default | Notes |
19-|---|---|---|
20-| `ERMITE_DATA_DIR` | required | absolute path of the data root |
21-| `ERMITE_PUBLIC_URL` | required | `http(s)` base URL, no path |
22-| `ERMITE_ADMIN_TOKEN` | required | at least 32 characters |
23-| `ERMITE_ADDR` | `:7341` | listen address |
24-| `ERMITE_TRUSTED_PROXIES` | empty | comma-separated CIDRs |
25-| `ERMITE_GIT` | `git` | git 2.45 or newer |
26-| `ERMITE_GIT_READER` | `ermite-git` | `ermite-git` reads refs, trees, blobs and history with the [ermite-git](https://github.com/bnema/ermite-git) library where it is faster than the `git` binary and falls back to `git` elsewhere and for repositories it does not read; `cli` uses the `git` binary for every read (the escape hatch if a read looks wrong: set it and restart) |
27-| `ERMITE_SOURCE_URL` | `$ERMITE_PUBLIC_URL/ermite` | link to the source (AGPL) |
28-| `ERMITE_LOG_LEVEL` | `info` | `trace`, `debug`, `info`, `warn`, `error` |
29-| `ERMITE_LOG_FORMAT` | `json` | `json` or `console` |
30-| `ERMITE_COMMITTER_NAME` | `ermite` | committer of squash merges |
31-| `ERMITE_COMMITTER_EMAIL` | `ermite@<public host>` | committer email of squash merges |
32-| `ERMITE_OWNER` | `owner` | owner login: shown in the web header when signed in, named in new passkeys, and the one CI shows in `github.repository` (`<owner>/<repo>`) and `github.repository_owner`; follows the rules of repository names |
33-| `ERMITE_CI_LOG_RETENTION` | `90d` | how long CI runs, jobs, checks and logs are kept: `Nd` (days) or a Go duration such as `720h`, at least one hour |
34-| `ERMITE_SECRETS_KEY` | empty | 32 random bytes in base64 (`head -c 32 /dev/urandom \| base64`) that encrypt CI secrets at rest (AES-256-GCM); keep it in the deployment secret store like `ERMITE_ADMIN_TOKEN`. Without it `ermite secret set` fails and jobs receive no secrets (variables and environments still work). The server refuses to start with another key than the one the stored secrets were sealed with; losing the key loses every secret (set them again), a backup without the key reveals none |
35-| `ERMITE_CI_ARTIFACTS` | `1` | `0` turns the CI artifacts off: no artifact service, and artifact actions fail as on a GitHub Enterprise Server without artifacts v4 |
36-| `ERMITE_CI_ARTIFACT_RETENTION` | `90d` | default and longest retention of an artifact (`retention-days`), at least one day: `Nd` or a Go duration |
37-| `ERMITE_CI_ARTIFACT_QUOTA` | `10GiB` | space of the artifacts of one repository, uploads in progress included: bytes, or a number with `KiB`, `MiB`, `GiB`, `TiB` |
38-| `ERMITE_CI_ARTIFACT_MAX` | `5GiB` | size of one artifact, same syntax |
39-| `ERMITE_CI_POLL_TIMEOUT` | `50s` | how long the server holds a runner's `FetchJob` call when no job is queued, from `1s` to `55s`; set it below the response timeout of a reverse proxy in front of ermite (a reverse proxy may cut a response that has no headers after 30 s, so use `25s` behind such a proxy) |
40-
41-`ermite version` prints the version (and the server's when a server URL is configured, see Remote CLI).
42-
43-## Remote CLI
44-
45-The same binary drives a running server over its API. It reads `ERMITE_URL` (the server address, `http://` or `https://`) and `ERMITE_TOKEN` (a bearer token; optional for public reads). A token sent over plain `http://` to a host that is not loopback triggers a warning on every call.
46-
47-Each setting comes from the environment or from a config file, the environment first:
48-
49-| Setting | Environment | Config file key |
50-|---|---|---|
51-| server address | `ERMITE_URL` | `url` |
52-| bearer token (optional for public reads) | `ERMITE_TOKEN` | `token.cmd`, a command that prints it |
53-
54-The file is `~/.config/ermite/config` (`$XDG_CONFIG_HOME/ermite/config` when set; `~/Library/Application Support/ermite/config` on macOS and `%AppData%\ermite\config` on Windows; `ERMITE_CONFIG` names another file). `ermite config show` prints the path in use. It holds no secret: `token.cmd` is a command line that fetches the token from your secret manager (pass, gopass, op, bw, secret-tool, …).
55-
56-```ini
57-# ~/.config/ermite/config
58-url = https://git.example.com
59-token.cmd = pass show ermite/token
60-```
25+## GitHub Actions
6126
62-```sh
63-ermite config set url https://git.example.com
64-ermite config set token.cmd 'pass show ermite/token' # or: gopass show -o ermite/token, op read op://vault/ermite/token, secret-tool lookup service ermite
65-ermite config show [--json] # path and stored values; unset removes a key
66-```
27+Push a repository with a `.github/workflows` folder and its workflows run, the same YAML you use on GitHub:
6728
68-`token.cmd` runs with `sh -c` (`cmd /C` on Windows) only when a command needs the token, at most once per run, with your environment and, when standard input is a terminal, that terminal (so a pinentry or biometric prompt works; otherwise it reads nothing). On a timeout or when ermite is interrupted or terminated, the command and everything it started are killed (when standard input is a terminal, only the shell is, so that prompts keep working). It must print the token alone on one line, in under 4 KiB, within 60 seconds. It is not run when `ERMITE_TOKEN` is set, nor by `ermite version` or `ermite config`, and not when the config file, or the directory holding it, can be written by other users or the file belongs to another user (`ermite config show` and `set` warn about it). On Windows this check does not exist: keep the file in your own profile, the default place. It belongs to the server of the file: when `ERMITE_URL` names a different server than the file's `url`, it is not run and the call is anonymous (with a notice; set `ERMITE_TOKEN` to authenticate there). A failure is reported in one line without its output. Shell completion never runs it: it is anonymous unless `ERMITE_TOKEN` is set.
29+- Events: `push`, `pull_request`, `pull_request_target`, `workflow_dispatch`, `schedule`, `workflow_run`, `create`, `delete`
30+- Matrix, `needs`, `concurrency`, reusable workflows, job outputs and summaries
31+- Secrets, variables, environments and OIDC (`id-token: write`)
32+- `actions/checkout`, `actions/cache`, `setup-*`, `upload-artifact` / `download-artifact` v4
33+- Actions from github.com or from your own ermite repositories
34+- Each job is a check on the commit and the merge request; failing checks block the merge
6935
70-```sh
71-export ERMITE_URL=https://git.example.com ERMITE_TOKEN=ermite_...
72-ermite repo list | show <name> | create <name> [--description …] [--visibility public] | edit <name> [--description …] [--visibility …] [--default-branch …] | delete <name> [--yes]
73-ermite token create <label> [--preset cli|agent|ci|runner] [--scope repo:read …] [--repo name …] [--branch-prefix agent/x/ …] [--expires 30d] # prints the secret once
74-ermite token list | revoke <id>
75-# a token for an AI agent that cannot touch main:
76-ermite token create claude --preset agent --scope repo:read --repo myrepo --branch-prefix agent/claude/
77-ermite mr create <repo> --source <branch> --title … | list <repo> | show <repo> <n> [--diff]
78-ermite mr comment <repo> <n> -m … [--path … --line … --side old|new] | resolve <repo> <n> <comment>
79-ermite mr merge <repo> <n> --head <id> [--squash] [--delete-branch] | close <repo> <n>
80-ermite run list <repo> [--workflow …] [--branch …] [--status …] | view <repo> <id> [--attempt n] [--log [--job …]]
81-ermite run watch <repo> <id> [--log] | cancel <repo> <id> | rerun <repo> <id> [--failed]
82-ermite run artifacts <repo> <id> | download <repo> <id> [--name N]… [--dir D]
83-ermite runner list | remove <id>
84-ermite web login # prints a one-time sign-in link (60 s)
85-ermite web logout --all # signs every browser out
86-ermite web passkey list | remove <id> # the owner's passkeys; removing one signs every browser out
87-ermite signing-key show # public key and allowed_signers line of squash commits
88-ermite config show [--json] | set <url|token.cmd> <value> | unset <key> # the CLI config file
89-ermite version # client and server, warns about missing server features
90-ermite watch [repo] [--json] # follows events, reconnects and resumes
91-```
36+A feature ermite does not support fails with `not supported yet: <feature>`, never silently.
9237
93-An agent skill describing the CLI and the forge lives in [`skills/ermite`](skills/ermite/SKILL.md); link or copy that folder into your agent's skills directory.
38+### Runner
9439
95-`--json` on `repo list|show|create|edit`, `token create|list`, `run list|view|cancel|rerun`, `runner list` and `version` prints stable field names, listed in each command's `--help`; `watch --json` prints one object per line. Errors are one line on standard error (`ermite: …`) and exit status 1. `ermite completion bash|zsh|fish` prints a shell completion script that completes repository names from the server.
40+Jobs run on [ermite-runner](https://github.com/bnema/ermite-runner) hosts, one fresh sandbox per job:
9641
97-## Git over HTTP
42+- **microVM** (Firecracker) when `/dev/kvm` is available: each job gets its own kernel, destroyed at the end
43+- **container** with its own Docker daemon otherwise
9844
9945 ```sh
100-git clone http://git.example.com/<repo>.git # public repositories need no credentials
101-git push http://u:<token>@git.example.com/<repo>.git main
46+ermite token create runner-1 --preset runner # then register the runner with this token
47+ermite runner list
10248 ```
10349
104-The URL GitHub-shaped clients use, `http://git.example.com/<owner>/<repo>` (`<owner>` is `ERMITE_OWNER`, with or without `.git`), is served too; any other owner is a 404. The token goes as the password of Basic with any user (`x-access-token`), or as `Authorization: Bearer <token>` or `token <token>`.
105-
106-Use the admin token (`ERMITE_ADMIN_TOKEN`) or a stored token as the password; the username is ignored. Store it in a Git credential helper rather than in the URL. Pushing to a new name creates a private repository. Repository names are lowercase, `[a-z0-9._-]`. TLS is terminated by your proxy; list its network in `ERMITE_TRUSTED_PROXIES` so that failed-login limits see the real client address (`X-Forwarded-For`).
107-
108-## Branch-limited tokens
50+CI turns on as soon as a runner is registered. Details: [docs/ci.md](docs/ci.md).
10951
110-For an AI agent that should propose changes, not publish them: `--branch-prefix agent/claude/` (repeatable, up to 8, each ending with `/`) limits a token to the branches under its prefixes.
111-
112-- It can push, force-push and delete `refs/heads/agent/claude/…`. Any other ref (`main`, other branches, tags, notes, anything outside `refs/heads/`) refuses the whole push, and the message names the ref.
113-- It cannot create a repository (push to a new name, or `repo create`), delete one, or change its settings (default branch, visibility, description, merge modes, failing-checks rule): those decide what happens to branches outside its prefixes. It never has the rights of the owner (it cannot open web sessions even with every scope), and can only create tokens whose prefixes lie within its own.
114-- It opens, comments on and closes merge requests as usual; with `mr:merge` it can merge only into a branch under its prefixes, and delete only a source branch under them. You review and merge the agent's request.
115-- CI: it reads runs, jobs, logs and checks as any token that can read the repository. With `ci:write` it can cancel and re-run only the runs of branches under its prefixes, never those of `main`, other branches or tags. It never manages runners (`runner list|remove`, registration), even with `runner:register`: a runner runs every branch of every repository.
116-- `ermite token list` shows a `branches` column (`all` when unlimited); `--json` has `branch_prefixes`.
117-- This build uses storage schema 3 (2 added the branch prefixes of tokens; 3 the jobs of reusable workflows and the head commit of the runs of merge requests). Once it has started on your data, an older ermite refuses to start on it (it would read limited tokens as unrestricted, run the jobs of a called workflow as the caller's, and attach the checks of merge request runs to the wrong commit): to go back, restore the backup taken before the upgrade.
118-
119-## Merge requests
120-
121-A merge request proposes a branch for its target (the default branch unless set). The merge is refused when the branch moved since the reviewed head (`--head`) or when the target is not an ancestor of it. Fast-forward moves the target; squash writes one commit signed by the server's SSH key (`ermite signing-key show` prints the `allowed_signers` line for `git verify-commit`). `ermite repo edit --merge-modes` limits the modes per repository. `--delete-branch` (a checkbox on the web) deletes the source branch after the merge, unless it is the default branch or another open request uses it. Tokens need `mr:write` to open and comment, and `mr:merge` to merge (and delete the merged branch).
122-
123-## CI
124-
125-ermite runs the `.github/workflows/*.yml` workflows of a repository on `push` (a branch or a tag, and the merge of a merge request), on `pull_request` and `pull_request_target` (the merge requests, below), `workflow_dispatch`, `schedule`, `workflow_run`, `create` and `delete` (below) and shows each job as a check on the commit, the branch list and the merge request. CI is on once a runner is registered; there is no switch. Before that a push makes no run and no check.
126-
127-Runs execute on [ermite-runner](https://github.com/bnema/ermite-runner) hosts, which call ermite over its API. Create a token for the runner host with `ermite token create <label> --preset runner` (the `runner:register` scope, which also gives access to the code and the secrets of every repository: give it only to runners you operate), then register the runner with it. `ermite runner list` shows the runners and whether they are online; `ermite runner remove <id>` stops one. A job goes to a runner that has every label of its `runs-on`; a job no runner takes within 24 hours fails.
128-
129-Every job gets a **repository token** (`github.token`, `ermitegh_…`) when a runner takes it: it reads the repository of the run, even a private one, over Git and the REST subset below, writes nothing, and ends with the job (completion, cancel, lost runner) or five minutes after the job's deadline. It is not a stored token: `ermite token list` never shows it and no scope manages it.
130-
131-**OIDC.** ermite is an OpenID Connect provider for its jobs, as GitHub is: a job of a trusted run whose `permissions:` give `id-token: write` can ask for a signed ID token (`core.getIDToken()`, `aws-actions/configure-aws-credentials`, `hashicorp/vault-action` with `method: jwt`) and exchange it for short-lived credentials with a relying party that trusts ermite, instead of storing a long-lived secret. The issuer is `<ERMITE_PUBLIC_URL>/-/oidc`; its discovery document is `/-/oidc/.well-known/openid-configuration` and its keys `/-/oidc/.well-known/jwks`, both anonymous and cacheable. The runner sets `ACTIONS_ID_TOKEN_REQUEST_URL` (`/-/oidc/token?api-version=2.0`) and `ACTIONS_ID_TOKEN_REQUEST_TOKEN` (an `ermiteoidc_…` secret, masked, valid only while the job runs and only at that endpoint) for run steps, JavaScript and Docker actions. Tokens are RS256, valid five minutes (`nbf` ten minutes before `iat`), with GitHub's claims (`sub`, `repository`, `ref`, `sha`, `workflow_ref`, `job_workflow_ref`, `run_id`, `run_attempt`, `event_name`, `environment`…) and `runner_environment: self-hosted`. `sub` is `repo:<owner>/<repo>:environment:<name>` for a job with an environment, else `repo:<owner>/<repo>:pull_request` for `pull_request` and `pull_request_target`, else `repo:<owner>/<repo>:ref:<ref>`; the default `aud` is `<ERMITE_PUBLIC_URL>/<owner>` and `getIDToken(audience)` picks another. Untrusted runs (an agent's push or merge request), jobs without `id-token: write` and servers without `ERMITE_SECRETS_KEY` get no variables, so the action fails as it does on GitHub without the permission. The signing key (RSA 3072) is generated at the first start and stored sealed with `ERMITE_SECRETS_KEY`; `ermite oidc rotate-key` (owner rights) replaces it, and the previous key stays in the JWKS for 24 hours. A relying party must reach the issuer to fetch the keys: AWS, Azure and GCP need it on the public Internet; a server reachable only on a private network works with a relying party on that network, such as Vault:
52+## Run
13253
13354 ```sh
134-vault auth enable jwt
135-vault write auth/jwt/config oidc_discovery_url="https://git.example.com/-/oidc" bound_issuer="https://git.example.com/-/oidc"
136-vault write auth/jwt/role/deploy role_type=jwt user_claim=sub bound_audiences="https://git.example.com/owner" \
137- bound_claims_type=glob bound_claims='{"sub":"repo:owner/proj:ref:refs/heads/main"}' policies=deploy ttl=10m
55+docker build -t ermite .
56+token=$(head -c 32 /dev/urandom | base64) # keep it: it is the root credential
57+docker run -d -p 127.0.0.1:7341:7341 -v ermite-data:/data \
58+ -e ERMITE_PUBLIC_URL=http://localhost:7341 -e ERMITE_ADMIN_TOKEN="$token" ermite
13859 ```
13960
140-An action of `ERMITE_OWNER/<repo>` that exists on this server wins over the same name on github.com. It is resolved **on behalf of whoever pushed the run**: ermite records the pusher with the run (the admin token, the owner's session, or the stored token by its id) and, at every answer, asks whether that pusher can read the action's repository, with the same rule as everywhere else: a public repository, or `repo:read` on a repository of the token's list. The token is looked up again each time, so a token revoked or expired since reads nothing for the run. A repository the pusher cannot read answers like one that does not exist (`repository not found`), whatever the ref, so it does not reveal itself. A job can therefore use an action from another private repository only when its pusher could read it. The runner gets an **archive token** for each such action: it is bound to that repository and that commit and downloads that one archive (`/-/github/api/repos/<owner>/<repo>/tarball/<sha>`) and nothing else. Git over HTTP, the repository endpoint and the rest of the API see it as a caller who can read nothing, not even a public repository. It goes with its job, and with the repository it reads if that is deleted. At most eight archives are written at once for one client address (`429` beyond: a runner host runs several jobs, and all come from one address) and sixteen on the server (`503`); both answers carry `Retry-After: 5`. A client that stops reading is cut after two minutes without progress.
141-
142-**`pull_request`.** A merge request is a pull request for the workflows. ermite keeps `refs/pull/<n>/head` at the head of its source branch and, while that head merges cleanly into the target, `refs/pull/<n>/merge` at a test merge commit (parents: the target tip, then the head; message `Merge <head> into <base>`). Both are served to `git fetch`, never listed as branches or tags, and refused on every push (for any token, a job's included). The activities are `opened` (the request is created), `synchronize` (its source branch is pushed; each push runs for the commit it pushed, with the rights of whoever pushed it) and `closed` (closed or merged, with `merged` true or false; it uses the last test merge commit, or the head when there is none, and is never computed again, even when the source branch is gone); `reopened` is accepted in `types:` but merge requests cannot be reopened. Without `types:` a workflow gets `opened`, `synchronize` and `reopened`. The run has `github.ref` `refs/pull/<n>/merge`, `github.sha` the merge commit (the workflow file is read from it, so `actions/checkout` gets the merged tree), `github.head_ref` and `github.base_ref` set, and a payload shaped like GitHub's `pull_request` webhook with the fields ermite has. `branches` and `paths` (and their `-ignore` forms) apply to the target branch and to the files changed between the merge base and the head. A head that conflicts with the target makes no run for that update. The checks of the run are on the head commit. The run is trusted (secrets, write token) only when whoever caused the event is the owner **and** the head and the base it runs were pushed by the owner: its workflow comes from the merge commit, so from both branches. Each is checked against the record of who set the branch to exactly that commit, taken when the event happens (see `schedule`); a branch with no record, or whose record names another commit, is untrusted. The owner opening, pushing on or closing the request of a branch an agent pushed (or one targeting an agent's branch) gets an untrusted run. A closing runs the last test merge commit, whose head and base are checked; when it is the merge of an older head than the closing's, it is untrusted. The closing a merge makes has the merger's trust when the merger set the target to the merge result and the head is the one merged, even after the source branch is deleted. A job token starts no run.
61+Without Docker, set `ERMITE_DATA_DIR`, `ERMITE_PUBLIC_URL` and `ERMITE_ADMIN_TOKEN`, then run `ermite serve`. Requires git 2.45 or newer. Put a TLS proxy in front of it.
14362
144-**`pull_request_target`.** The same activities, `types:` (default `opened`, `synchronize`, `reopened`), `branches` and `paths` filters and payload as `pull_request`, with `github.event_name` `pull_request_target`, but the workflow is read from the **default branch** and runs on it: `github.ref` is `refs/heads/<default>` and `github.sha` its tip as it was when the activity happened, whatever the head contains (a head that edits the workflow does not change what runs). It also runs when the head conflicts with the target. Its checks are on the head commit, like those of `pull_request`, so they show on the merge request and not on the default branch. The run is trusted (secrets, write token) only when **both** whoever caused the activity is the owner **and** the default branch tip was pushed by the owner (see `schedule`); the head's pusher does not matter, since none of its files run. This is stricter than GitHub, which gives the secrets to every pull request of the repository: here an agent that opens or pushes a merge request gets the run without secrets and with a read-only token. Re-runs and `workflow_run` keep or lower the trust as for any run. Its cache is read-only (the runner sets `ACTIONS_CACHE_MODE=read`, so `actions/cache` skips the save) and restores only the default branch's entries, never the merge ref's, which the `pull_request` runs of the request save from the head's code. Every other run restores from its own ref, then the target branch for a `pull_request`, then the default branch, and saves under its own ref. An entry keeps the trust of the run that saved it: a trusted run restores only entries trusted runs saved (the owner pushing on top of an agent's branch or merge request never restores what the agent's runs cached), an untrusted run restores both, and an untrusted run on the default branch (an agent's `workflow_run` or dispatch on it) saves nothing. Entries saved before ermite sent the cache scope (ermite-runner then used the run's ref whatever its trust) are not told apart: clear the runners' caches once after upgrading.
63+All settings: [docs/configuration.md](docs/configuration.md).
14564
146-> **Warning** (GitHub's, and it applies here): running untrusted code on `pull_request_target` can leak the secrets and the write token, and poison caches. A trusted run that checks out the head (`actions/checkout` with `ref: ${{ github.event.pull_request.head.sha }}`, `git fetch` of `refs/pull/<n>/head`) and then builds, tests or installs it runs the head's code with them: a "pwn request". Use `pull_request` when the workflow builds the request's code; with `pull_request_target`, treat the checked-out head as data only and never execute it (build scripts, `npm install`, configuration files and tools it brings all count). See GitHub's [Securely using pull_request_target](https://docs.github.com/en/actions/reference/security/securely-using-pull_request_target) and [Preventing pwn requests](https://securitylab.github.com/research/github-actions-preventing-pwn-requests).
65+## Use
14766
148-**`workflow_dispatch`.** `ermite workflow run <file> --repo <repo> [--ref <branch or tag>] [-f name=value …]` starts a run of a workflow that declares `workflow_dispatch` (the API is `RunService.DispatchWorkflow`, feature `ci.dispatch`; it needs `ci:write`). The file is its path or its name under `.github/workflows`, and it is read at the ref (the default branch when `--ref` is left out). The inputs are checked against the workflow: an undeclared input, a missing `required` input without a default, a `boolean` other than `true` or `false`, a `number` that is not a decimal number, a `choice` outside its `options`, and an `environment` that is not a valid environment name are refused; a `choice` without default takes its first option. A workflow has at most 25 inputs and their values at most 65,535 characters. In the run, `github.event.inputs` holds the values as strings and the `inputs` context as their declared types (`boolean`, `number`, else string). The actor is the caller. The run is trusted (secrets, write token) only when **both** the caller is the owner **and** the ref it runs on was moved to its current commit by the owner (see `schedule`): the owner dispatching an agent's branch gets an untrusted run, like the owner re-running an agent's run, and a ref with no record (pushed before ermite recorded pushers) is untrusted too. This holds for a tag as for a branch, and for a job token as for the owner. A token limited to branch prefixes can dispatch only on a branch under them and its run is never trusted. A job can dispatch with its repository token when its `permissions:` give `actions: write` (GitHub's exception to "events made with `GITHUB_TOKEN` start no run"), even without `contents: read`; a token that does not hold the permission is refused.
67+Point the CLI at the server:
14968
150-**`schedule`.** `on.schedule[].cron` (five fields) is read at the wall clock of the entry's `timezone`, an IANA time zone name such as `America/New_York` (UTC without one; the time zone database is built into ermite). A name that is not one makes the workflow invalid: its push records a failed run with the error. On a daylight saving spring-forward day, an entry whose time is skipped runs at the next valid time (a 02:30 entry runs at 03:00), as GitHub documents; on a fall-back day, a time that happens twice runs once, at its first occurrence (GitHub does not document it; this is cron's behaviour). The entries are read on the **default branch** only. Every minute the scheduler starts the workflows whose cron matches that minute, on the latest commit of the default branch; the payload is `{"schedule": "<cron>"}`. A minute is handled once: ticks missed while ermite was down or busy are not made up, and a workflow with several matching entries starts one run. The actor is **whoever last moved the default branch to its current commit** (a push or a merge), and the run is trusted only if that was the owner. ermite records who moves each branch and tag **before** it moves (in the pre-receive decision of a push, and in the merge under its lock; a record that cannot be written refuses the push), together with the commit it is for: the record counts only while the ref still points to that commit, so a late write cannot leave the owner's record on a tip an agent moved there (owner A, agent B, agent A). A tip with no record, a record for another commit (a push refused after its record was written) or an unreadable record runs untrusted, without secrets. If the owner and a token push the very same commit at the same time, the owner's record may remain: the content is the same and the owner pushed it. At most 64 repositories are looked at per second, in rotation, so a pass over more takes several seconds and never starves one. The schedules of a repository are read again when its default branch moves, and go with the repository.
151-
152-**`workflow_run`.** A workflow that lists `workflow_run: workflows: [<name>]` runs when a run of a workflow with that `name` is `requested` (created) or `completed` (`types:` picks one; `in_progress` never happens). `branches` and `branches-ignore` apply to the head branch of the triggering run (the source branch of a merge request). The workflow is read from the **default branch** and runs on its latest commit; the payload has `workflow_run` (`id`, `name`, `head_branch`, `head_sha`, `event`, `status`, `conclusion`, `run_number`, `run_attempt`, `path`, `actor`) and `workflow`. The run inherits the actor and the trust of the triggering run and never gains any: a workflow_run of an untrusted run has no secrets. A chain stops after three levels of `workflow_run`.
153-
154-**`create` and `delete`.** A push (or a merge that deletes its source branch) that creates or deletes a branch or a tag starts the workflows that declare `create` or `delete`, besides its `push` workflows. A `create` run is on the created ref: `github.ref` is it, `github.sha` its commit, and the workflow is read from that commit. A `delete` run is on the **default branch**, its latest commit, and its workflow is read from there: the deleted ref is gone. The payload has `ref` (the short name), `ref_type` (`branch` or `tag`), `pusher_type` (`user`), `repository` and `sender`; `create` adds `master_branch` (the default branch) and `description` (`null`). There are no filters, and `[skip ci]` does not apply. A push that creates (or deletes) more than three tags starts no `create` (or `delete`) run for them, as on GitHub. A `create` run is trusted only when the pusher is the owner **and** the record of the created ref names the commit it runs (see `schedule`); a `delete` run only when the deleter is the owner **and** the default branch tip was pushed by the owner, as for `pull_request_target` (the deleted ref's record went with it). A job token starts neither.
155-
156-The event of a run is `push`, `pull_request`, `pull_request_target`, `workflow_dispatch`, `schedule`, `workflow_run`, `create` or `delete`: `ermite run list` and `view` and the Actions pages show it.
157-
158-The Actions that run here call a small part of GitHub's REST API, served at `github.api_url` (`/-/github/api`): `GET /repos/{owner}/{repo}`, `/repos/{owner}/{repo}/hash-algorithm`, `/repos/{owner}/{repo}/tarball[/{ref}]` (streamed with 200, not redirected; a `HEAD` answers the headers without building the archive) and `GET /repos/{owner}/{repo}/pulls/{number}/files` (what `dorny/paths-filter` reads on a `pull_request`: up to 3000 files, `page` and `per_page` of at most 100, 30 by default, with the `Link` header; a job token needs `pull-requests: read`, else `403 Resource not accessible by integration`). The owner in these paths and in the Git URLs is matched without regard to case, as on GitHub. Any other path is GitHub's `404 {"message":"Not Found",…}`, so an action that needs more fails as on a GitHub instance without that feature.
159-
160-Actions from github.com are resolved with `git ls-remote https://github.com/<owner>/<repo>` (no credentials, 20 s, 16 MiB of output, at most four at once, 60 s for a whole call). ermite hands git only the proxy settings of its own environment (`HTTPS_PROXY`, `HTTP_PROXY`, `NO_PROXY`, `ALL_PROXY` and their lower-case forms), so a server that reaches the Internet through a proxy sets them for `ermite serve`; nothing else of its environment, no credential, reaches git.
161-
162-`ermite run list|view|watch|cancel|rerun` and the `Actions` tab of a repository read and control the runs; `cancel` and `rerun` need the `ci:write` scope. With `failing_checks_block` on (the default, `ermite repo edit --failing-checks-block=false`), a merge is refused while a check of the head failed or has not finished. `concurrency:` is supported at the workflow level (evaluated when the run starts) and at the job level (evaluated when the job is planned, once per matrix leg): a group is per repository and compared without regard to case, `queue: single` (the default) lets one run or job go and keeps one waiting, a newer waiting entry cancels the older one (`Canceling since a higher priority waiting request for '<group>' exists`), and `cancel-in-progress` (a boolean or an expression) also cancels the running one. With `queue: max`, up to 100 entries wait and run first in, first out; one more is cancelled (`Canceling since the concurrency group '<group>' already has 100 waiting requests`, ermite's wording). In a group that mixes both (GitHub does not say), ermite applies the rule of the entry that starts waiting: a `single` entry cancels every waiting entry, `max` ones included, and a `max` entry joins the end of the line. A job held back by a group shows `pending` in the API, the web pages and the CLI. A group that evaluates to nothing fails the job. `queue: max` with `cancel-in-progress: true` written as such makes the workflow invalid (its push records a failed run with the error, as GitHub's validation does); when either is an expression that evaluates to that pair, the job fails with the same message. The waiting line is stored with its head also in the field older ermite versions read, so a downgrade keeps the first waiting entry and cancels the others. Reusable workflows (`jobs.<id>.uses`) run: `./.github/workflows/x.yml` (the caller's repository, at the commit of the run), `owner/repo/.github/workflows/x.yml@ref` of a repository of this server when `owner` is `ERMITE_OWNER` and the actor of the run can read it (else the call fails as for a repository that does not exist), and otherwise a public repository on github.com, whose ref is resolved like the ref of an action and whose file is downloaded at that commit (at most 1 MiB). ermite expands the call when the job is planned: the called workflow must declare `on: workflow_call`, `with:` is checked against its inputs (types, `required`, defaults), `secrets:` passes the listed secrets, renamed as written, or all of them with `secrets: inherit` (a secret that is not passed is not there), and its `outputs` are available to the later jobs of the caller as `needs.<job>.outputs`. The jobs of the called workflow are named `<calling job> / <job>` and keyed `<calling job id>/<job id>`, wait for the `needs` of the calling job, and are repeated for each leg of its `strategy`; `permissions` can only be lowered by the called workflow. A call may nest four levels deep. `concurrency:` on the calling job and the workflow-level `concurrency:` of the called workflow (evaluated with the caller's `github` and the call's `inputs`) apply to the whole call, which holds the group until it ends, with `cancel-in-progress` as for any job. A workflow that uses another feature ermite does not support yet fails with `not supported yet: <feature>`, never silently. Logs are files under `$ERMITE_DATA_DIR/ci/logs`, and the job summaries (the Markdown a step writes to `GITHUB_STEP_SUMMARY`) under `$ERMITE_DATA_DIR/ci/summaries`; both go with their run (retention, repository deletion). Annotations (`::notice`, `::warning`, `::error`) are kept in the metadata store. The run page shows them in an Annotations box and the job page at their step; the summaries are rendered under the run with the README renderer (raw HTML dropped, `javascript:` links dropped). `ermite run view` prints an ANNOTATIONS section, and `ermite run view --summary` prints the summaries as Markdown.
163-
164-**Artifacts** (artifacts v4, `actions/upload-artifact` and `actions/download-artifact` v4 and later) are stored by ermite and served to the jobs by the runner, which translates the toolkit's protocol (ermite-api `JobArtifactService`, feature `ci.artifacts`). A job reaches the artifacts of its own run only, with its job token, while it runs; every job of the run, in any attempt, sees the finalized ones, and an artifact uploaded again by a re-run replaces the one of the earlier attempt. Contents are files under `$ERMITE_DATA_DIR/ci/artifacts`, named by ids (never by artifact name). Limits are checked before a byte is accepted: `ERMITE_CI_ARTIFACT_MAX` per artifact, `ERMITE_CI_ARTIFACT_QUOTA` per repository (uploads in progress included); `retention-days` is kept between one day and `ERMITE_CI_ARTIFACT_RETENTION`. A job has at most 32 unfinished uploads and a run at most 1000 artifacts. An upload not finished when its job ends is dropped. Artifacts go when they expire, with their run (retention) and with their repository. `ermite run artifacts <repo> <run>` lists them and `ermite run download <repo> <run> [--name N]… [--dir D]` downloads them (a zip is extracted into `D`, or `D/<name>` when there are several); the run page lists them with download links. They are never served anonymously, even for a public repository: listing and downloading need `repo:read`, deleting `ci:write`. Not covered: artifacts v3 (`@actions/artifact` v1), the REST endpoints of artifacts (`/actions/runs/{id}/artifacts`, `/actions/artifacts/{id}/zip`: `download-artifact` with `run-id` and `github-token` gets a 404).
165-
166-The cache (`actions/cache`, `setup-go`/`setup-node` `cache:`) is served by the runner and works.
167-
168-## Web sign-in
69+```sh
70+ermite config set url https://git.example.com
71+ermite config set token.cmd 'pass show ermite/token' # any command that prints the token
72+```
16973
170-Two ways in, both for the owner, both giving the same session cookie for 12 hours. Neither works over plain `http` except on localhost: the cookie is `Secure`.
74+Then:
17175
172-- **Passkey** (a phone, a laptop, a security key): open `/-/login` and press "Sign in with a passkey". Nothing to type, no terminal. A passkey is added once from `/-/passkeys` while signed in.
173-- **Link**: `ermite web login`, run with the admin token or a token with every scope, prints a link that works once within 60 seconds. Opening it signs the browser in (without scripts, click the button on the page) (never longer than the token that asked for the link). It is also how to sign in the first time, before any passkey exists.
76+```sh
77+ermite repo create myrepo
78+git push https://x:<token>@git.example.com/myrepo.git main # prefer a credential helper
79+ermite mr create myrepo --source feature --title "Add feature"
80+ermite web login # one-time link to sign in to the web interface
81+```
17482
175-Passkeys need `ERMITE_PUBLIC_URL` to be `https` (or `http://localhost`) with a **host name, not an IP address**: WebAuthn ties a passkey to that name, and a passkey registered for one name does not work on another. On `http://127.0.0.1:…` the server starts without passkeys (the log says `passkeys disabled`, the routes and links are absent, `ermite version` does not list `passkeys`). Up to 20 passkeys can be registered; each asks for user verification (a fingerprint, a face or a PIN), and the server stores only the public key. A security key that reports a signature counter is checked for cloning; synced passkeys (iCloud, Google, a password manager) report none, so they are not.
83+Pushing to a new name also creates a private repository.
17684
177-`ermite web passkey list` shows them, `ermite web passkey remove <id>` removes one. Removing a passkey, `ermite web logout --all`, or revoking a token with every scope ends every session, on every device. Browser writes require the session and an `Origin` equal to `ERMITE_PUBLIC_URL`. If you lose every device, `ermite web login` still works from a terminal.
85+## Agent workflow
17886
179-The server keeps its runtime files in `$ERMITE_DATA_DIR/run/` (the hook socket and scripts that git calls on each push); the hook socket path must stay under about 100 bytes.
87+You and the agent use the same CLI. The difference is the token: the agent's is limited to its own branches. It can never push `main`, tags or settings, and its CI runs get no secrets.
18088
181-## Run with Docker
89+**1. You give the agent a token** limited to `agent/claude/*`:
18290
18391 ```sh
184-docker build --build-arg VERSION="$(git describe --tags --always --dirty)" -t ermite .
185-token=$(head -c 32 /dev/urandom | base64) # keep it: it is the root credential
186-docker run -d -p 127.0.0.1:7341:7341 -v ermite-data:/data \
187- -e ERMITE_PUBLIC_URL=http://localhost:7341 -e ERMITE_ADMIN_TOKEN="$token" ermite
92+ermite token create claude --preset agent --scope repo:read --repo myrepo --branch-prefix agent/claude/ --expires 30d
18893 ```
18994
190-The image is alpine with git, runs as uid 10001 and keeps its data in the `/data` volume. Logs are JSON by default. The build fetches the public `bnema.dev/ermite-*` modules through their `bnema.dev` vanity paths. Behind a private route, `--build-arg GIT_REWRITE_BASE=<url>` rewrites the git host used for those modules.
191-
192-## Develop with compose
95+**2. The agent pushes and opens a merge request:**
19396
19497 ```sh
195-docker compose up
98+export ERMITE_URL=https://git.example.com ERMITE_TOKEN=<agent token>
99+git push origin agent/claude/fix-login
100+ermite mr create myrepo --source agent/claude/fix-login --title "Fix login redirect"
101+ermite run watch myrepo <run id> # follow its CI
196102 ```
197103
198-Serves on `http://localhost:7341` with the development admin token in `compose.yaml`. The source is mounted and [air](https://github.com/air-verse/air) (`go tool air`, `.air.toml`) rebuilds and restarts the server on each change to a `.go` or `.templ` file or an embedded web asset; the module and build caches and the data live in named volumes. `docker compose down -v` removes them. The dev container runs as root, which maps to your user under rootless Docker.
199-
200-## Build
104+**3. You review:**
201105
202106 ```sh
203-go build ./cmd/ermite
204-go test ./...
107+ermite mr show myrepo 1 --diff
108+ermite mr comment myrepo 1 -m "Handle the empty case" --path auth.go --line 42 --side new
205109 ```
206110
207-`go test ./...` includes the end-to-end tests of `internal/app`, which start a real server per test. Run them before opening a pull request: CI runs them on `main` only, after the merge.
208-
209-## Architecture
111+**4. The agent reads your comments and pushes again:**
210112
211-`cmd/ermite` (entry point, calls `app.Run`) → `internal/app` (CLI and composition root: builds the adapters and wires them to the use cases) → `internal/usecase` (use cases), `internal/ports` (interfaces and parameter types that cross layers), `internal/domain` (pure values and rules) and `internal/adapters` (Git, KV store, HTTP, API, web, client implementations of the ports).
212-
213-- `internal/usecase` imports only `internal/domain` and `internal/ports`.
214-- Adapters import `internal/domain`, `internal/ports` and the shared `internal/clientlimit`; never a use case, `internal/app` or another adapter.
215-- The web adapter, like the API and Git over HTTP, calls the use cases through `internal/ports` with the principal of the request (anonymous, or the owner with a valid session); it never imports the generated API code.
216-- `internal/ports` imports only `internal/domain`; `internal/domain` imports nothing from the module.
217-- `internal/config` (environment parsing) is used by `internal/app` only.
218-
219-`internal/boundary/boundary_test.go` enforces these import rules on production files; `hexcheck` (`.hexcheck.yaml`) checks the layering of types and behaviour. The release version is stamped with `-ldflags "-X bnema.dev/ermite/internal/app.stamped=<version>"`.
220-
221-## Develop
113+```sh
114+ermite watch myrepo --json # live events: pushes, merge requests, comments
115+ermite mr show myrepo 1 --since-last-read --mark-read
116+```
222117
223-Tools are pinned in `go.mod`:
118+**5. You merge** (or click Merge in the web interface):
224119
225120 ```sh
226-go tool buf lint
227-go tool buf generate # proto/ -> gen/
228-go generate ./... # mockery mocks of the ports
229-go tool hexcheck -fail-on warn ./...
121+ermite mr merge myrepo 1 --head <head id> --squash --delete-branch
230122 ```
231123
124+Give the agent the [`skills/ermite`](skills/ermite/SKILL.md) skill so it knows the CLI. Rules of agent tokens: [docs/tokens.md](docs/tokens.md).
125+
126+## Documentation
127+
128+- [Configuration](docs/configuration.md): environment variables
129+- [CLI](docs/cli.md): config file, commands, JSON output
130+- [Git over HTTP](docs/git-http.md): URLs and authentication
131+- [Tokens](docs/tokens.md): scopes, presets, branch-limited tokens
132+- [Merge requests](docs/merge-requests.md)
133+- [Web sign-in](docs/web-sign-in.md): passkeys and one-time links
134+- [CI](docs/ci.md): runners, trust, concurrency, reusable workflows
135+ - [CI events](docs/ci-events.md)
136+ - [OIDC](docs/ci-oidc.md)
137+ - [Artifacts](docs/ci-artifacts.md)
138+- [Development](docs/development.md): build, tests, architecture
139+
232140 ## License
233141
234142 AGPL-3.0-or-later. See [LICENSE](LICENSE).
@@ -1,234 +1,142 @@
1-# ermite1+<h1 align="center">ermite</h1>
2+
3+<p align="center">
4+ <a href="LICENSE"><img src="https://img.shields.io/badge/license-AGPL--3.0-blue?style=flat-square" alt="License: AGPL-3.0"></a>
5+ <a href="https://github.com/bnema/ermite/commits/main"><img src="https://badgen.net/github/last-commit/bnema/ermite/main?icon=github" alt="Last commit"></a>
6+ <a href="https://github.com/bnema/ermite/stargazers"><img src="https://badgen.net/github/stars/bnema/ermite?icon=github" alt="GitHub stars"></a>
7+</p>
8+
9+<p align="center">A single-user Git forge in one Go binary.</p>
2 10
3 > Read-only mirror. Development happens at git.bnema.dev; issues and pull requests are closed here.11 > Read-only mirror. Development happens at git.bnema.dev; issues and pull requests are closed here.
4 12
5-A single-user Git forge in one Go binary. ermite hosts bare repositories, serves clone and push over HTTPS, reviews branches as merge requests, and shows everything in a web interface with live updates.13+---
6 14
7-Status: early development. `ermite serve` serves Git over HTTP (clone, fetch, push), the API, `GET /-/health`, and the web: repositories, an expandable file tree with a repository-wide fuzzy file finder, files, commits, refs and merge requests. Visitors read public repositories; the owner signs in with a one-time link from the CLI to see private ones and to review and merge.15+## Features
8 16
9-## Run17+- **GitHub Actions, as is**: your `.github/workflows` run unchanged, marketplace actions included
18+- **Isolated runner**: [ermite-runner](https://github.com/bnema/ermite-runner) runs each job in a Firecracker microVM when KVM is available, else in a container
19+- **CLI-first**: repositories, merge requests, reviews and CI all work from the `ermite` CLI, for you and for AI agents
20+- **Safe for agents**: an agent token can only push its own branches; you review and merge
21+- Git over HTTPS, web interface with live updates
10 22
11-```sh23+Status: early development.
12-export ERMITE_DATA_DIR=/var/lib/ermite
13-export ERMITE_PUBLIC_URL=https://git.example.com
14-export ERMITE_ADMIN_TOKEN=$(head -c 32 /dev/urandom | base64)
15-ermite serve
16-```
17 24
18-| Variable | Default | Notes |25+## GitHub Actions
19-|---|---|---|
20-| `ERMITE_DATA_DIR` | required | absolute path of the data root |
21-| `ERMITE_PUBLIC_URL` | required | `http(s)` base URL, no path |
22-| `ERMITE_ADMIN_TOKEN` | required | at least 32 characters |
23-| `ERMITE_ADDR` | `:7341` | listen address |
24-| `ERMITE_TRUSTED_PROXIES` | empty | comma-separated CIDRs |
25-| `ERMITE_GIT` | `git` | git 2.45 or newer |
26-| `ERMITE_GIT_READER` | `ermite-git` | `ermite-git` reads refs, trees, blobs and history with the [ermite-git](https://github.com/bnema/ermite-git) library where it is faster than the `git` binary and falls back to `git` elsewhere and for repositories it does not read; `cli` uses the `git` binary for every read (the escape hatch if a read looks wrong: set it and restart) |
27-| `ERMITE_SOURCE_URL` | `$ERMITE_PUBLIC_URL/ermite` | link to the source (AGPL) |
28-| `ERMITE_LOG_LEVEL` | `info` | `trace`, `debug`, `info`, `warn`, `error` |
29-| `ERMITE_LOG_FORMAT` | `json` | `json` or `console` |
30-| `ERMITE_COMMITTER_NAME` | `ermite` | committer of squash merges |
31-| `ERMITE_COMMITTER_EMAIL` | `ermite@<public host>` | committer email of squash merges |
32-| `ERMITE_OWNER` | `owner` | owner login: shown in the web header when signed in, named in new passkeys, and the one CI shows in `github.repository` (`<owner>/<repo>`) and `github.repository_owner`; follows the rules of repository names |
33-| `ERMITE_CI_LOG_RETENTION` | `90d` | how long CI runs, jobs, checks and logs are kept: `Nd` (days) or a Go duration such as `720h`, at least one hour |
34-| `ERMITE_SECRETS_KEY` | empty | 32 random bytes in base64 (`head -c 32 /dev/urandom \| base64`) that encrypt CI secrets at rest (AES-256-GCM); keep it in the deployment secret store like `ERMITE_ADMIN_TOKEN`. Without it `ermite secret set` fails and jobs receive no secrets (variables and environments still work). The server refuses to start with another key than the one the stored secrets were sealed with; losing the key loses every secret (set them again), a backup without the key reveals none |
35-| `ERMITE_CI_ARTIFACTS` | `1` | `0` turns the CI artifacts off: no artifact service, and artifact actions fail as on a GitHub Enterprise Server without artifacts v4 |
36-| `ERMITE_CI_ARTIFACT_RETENTION` | `90d` | default and longest retention of an artifact (`retention-days`), at least one day: `Nd` or a Go duration |
37-| `ERMITE_CI_ARTIFACT_QUOTA` | `10GiB` | space of the artifacts of one repository, uploads in progress included: bytes, or a number with `KiB`, `MiB`, `GiB`, `TiB` |
38-| `ERMITE_CI_ARTIFACT_MAX` | `5GiB` | size of one artifact, same syntax |
39-| `ERMITE_CI_POLL_TIMEOUT` | `50s` | how long the server holds a runner's `FetchJob` call when no job is queued, from `1s` to `55s`; set it below the response timeout of a reverse proxy in front of ermite (a reverse proxy may cut a response that has no headers after 30 s, so use `25s` behind such a proxy) |
40-
41-`ermite version` prints the version (and the server's when a server URL is configured, see Remote CLI).
42-
43-## Remote CLI
44-
45-The same binary drives a running server over its API. It reads `ERMITE_URL` (the server address, `http://` or `https://`) and `ERMITE_TOKEN` (a bearer token; optional for public reads). A token sent over plain `http://` to a host that is not loopback triggers a warning on every call.
46-
47-Each setting comes from the environment or from a config file, the environment first:
48-
49-| Setting | Environment | Config file key |
50-|---|---|---|
51-| server address | `ERMITE_URL` | `url` |
52-| bearer token (optional for public reads) | `ERMITE_TOKEN` | `token.cmd`, a command that prints it |
53-
54-The file is `~/.config/ermite/config` (`$XDG_CONFIG_HOME/ermite/config` when set; `~/Library/Application Support/ermite/config` on macOS and `%AppData%\ermite\config` on Windows; `ERMITE_CONFIG` names another file). `ermite config show` prints the path in use. It holds no secret: `token.cmd` is a command line that fetches the token from your secret manager (pass, gopass, op, bw, secret-tool, …).
55-
56-```ini
57-# ~/.config/ermite/config
58-url = https://git.example.com
59-token.cmd = pass show ermite/token
60-```
61 26
62-```sh27+Push a repository with a `.github/workflows` folder and its workflows run, the same YAML you use on GitHub:
63-ermite config set url https://git.example.com
64-ermite config set token.cmd 'pass show ermite/token' # or: gopass show -o ermite/token, op read op://vault/ermite/token, secret-tool lookup service ermite
65-ermite config show [--json] # path and stored values; unset removes a key
66-```
67 28
68-`token.cmd` runs with `sh -c` (`cmd /C` on Windows) only when a command needs the token, at most once per run, with your environment and, when standard input is a terminal, that terminal (so a pinentry or biometric prompt works; otherwise it reads nothing). On a timeout or when ermite is interrupted or terminated, the command and everything it started are killed (when standard input is a terminal, only the shell is, so that prompts keep working). It must print the token alone on one line, in under 4 KiB, within 60 seconds. It is not run when `ERMITE_TOKEN` is set, nor by `ermite version` or `ermite config`, and not when the config file, or the directory holding it, can be written by other users or the file belongs to another user (`ermite config show` and `set` warn about it). On Windows this check does not exist: keep the file in your own profile, the default place. It belongs to the server of the file: when `ERMITE_URL` names a different server than the file's `url`, it is not run and the call is anonymous (with a notice; set `ERMITE_TOKEN` to authenticate there). A failure is reported in one line without its output. Shell completion never runs it: it is anonymous unless `ERMITE_TOKEN` is set.29+- Events: `push`, `pull_request`, `pull_request_target`, `workflow_dispatch`, `schedule`, `workflow_run`, `create`, `delete`
30+- Matrix, `needs`, `concurrency`, reusable workflows, job outputs and summaries
31+- Secrets, variables, environments and OIDC (`id-token: write`)
32+- `actions/checkout`, `actions/cache`, `setup-*`, `upload-artifact` / `download-artifact` v4
33+- Actions from github.com or from your own ermite repositories
34+- Each job is a check on the commit and the merge request; failing checks block the merge
69 35
70-```sh36+A feature ermite does not support fails with `not supported yet: <feature>`, never silently.
71-export ERMITE_URL=https://git.example.com ERMITE_TOKEN=ermite_...
72-ermite repo list | show <name> | create <name> [--description …] [--visibility public] | edit <name> [--description …] [--visibility …] [--default-branch …] | delete <name> [--yes]
73-ermite token create <label> [--preset cli|agent|ci|runner] [--scope repo:read …] [--repo name …] [--branch-prefix agent/x/ …] [--expires 30d] # prints the secret once
74-ermite token list | revoke <id>
75-# a token for an AI agent that cannot touch main:
76-ermite token create claude --preset agent --scope repo:read --repo myrepo --branch-prefix agent/claude/
77-ermite mr create <repo> --source <branch> --title … | list <repo> | show <repo> <n> [--diff]
78-ermite mr comment <repo> <n> -m … [--path … --line … --side old|new] | resolve <repo> <n> <comment>
79-ermite mr merge <repo> <n> --head <id> [--squash] [--delete-branch] | close <repo> <n>
80-ermite run list <repo> [--workflow …] [--branch …] [--status …] | view <repo> <id> [--attempt n] [--log [--job …]]
81-ermite run watch <repo> <id> [--log] | cancel <repo> <id> | rerun <repo> <id> [--failed]
82-ermite run artifacts <repo> <id> | download <repo> <id> [--name N]… [--dir D]
83-ermite runner list | remove <id>
84-ermite web login # prints a one-time sign-in link (60 s)
85-ermite web logout --all # signs every browser out
86-ermite web passkey list | remove <id> # the owner's passkeys; removing one signs every browser out
87-ermite signing-key show # public key and allowed_signers line of squash commits
88-ermite config show [--json] | set <url|token.cmd> <value> | unset <key> # the CLI config file
89-ermite version # client and server, warns about missing server features
90-ermite watch [repo] [--json] # follows events, reconnects and resumes
91-```
92 37
93-An agent skill describing the CLI and the forge lives in [`skills/ermite`](skills/ermite/SKILL.md); link or copy that folder into your agent's skills directory.38+### Runner
94 39
95-`--json` on `repo list|show|create|edit`, `token create|list`, `run list|view|cancel|rerun`, `runner list` and `version` prints stable field names, listed in each command's `--help`; `watch --json` prints one object per line. Errors are one line on standard error (`ermite: …`) and exit status 1. `ermite completion bash|zsh|fish` prints a shell completion script that completes repository names from the server.40+Jobs run on [ermite-runner](https://github.com/bnema/ermite-runner) hosts, one fresh sandbox per job:
96 41
97-## Git over HTTP42+- **microVM** (Firecracker) when `/dev/kvm` is available: each job gets its own kernel, destroyed at the end
43+- **container** with its own Docker daemon otherwise
98 44
99 ```sh45 ```sh
100-git clone http://git.example.com/<repo>.git # public repositories need no credentials46+ermite token create runner-1 --preset runner # then register the runner with this token
101-git push http://u:<token>@git.example.com/<repo>.git main47+ermite runner list
102 ```48 ```
103 49
104-The URL GitHub-shaped clients use, `http://git.example.com/<owner>/<repo>` (`<owner>` is `ERMITE_OWNER`, with or without `.git`), is served too; any other owner is a 404. The token goes as the password of Basic with any user (`x-access-token`), or as `Authorization: Bearer <token>` or `token <token>`.50+CI turns on as soon as a runner is registered. Details: [docs/ci.md](docs/ci.md).
105-
106-Use the admin token (`ERMITE_ADMIN_TOKEN`) or a stored token as the password; the username is ignored. Store it in a Git credential helper rather than in the URL. Pushing to a new name creates a private repository. Repository names are lowercase, `[a-z0-9._-]`. TLS is terminated by your proxy; list its network in `ERMITE_TRUSTED_PROXIES` so that failed-login limits see the real client address (`X-Forwarded-For`).
107-
108-## Branch-limited tokens
109 51
110-For an AI agent that should propose changes, not publish them: `--branch-prefix agent/claude/` (repeatable, up to 8, each ending with `/`) limits a token to the branches under its prefixes.52+## Run
111-
112-- It can push, force-push and delete `refs/heads/agent/claude/…`. Any other ref (`main`, other branches, tags, notes, anything outside `refs/heads/`) refuses the whole push, and the message names the ref.
113-- It cannot create a repository (push to a new name, or `repo create`), delete one, or change its settings (default branch, visibility, description, merge modes, failing-checks rule): those decide what happens to branches outside its prefixes. It never has the rights of the owner (it cannot open web sessions even with every scope), and can only create tokens whose prefixes lie within its own.
114-- It opens, comments on and closes merge requests as usual; with `mr:merge` it can merge only into a branch under its prefixes, and delete only a source branch under them. You review and merge the agent's request.
115-- CI: it reads runs, jobs, logs and checks as any token that can read the repository. With `ci:write` it can cancel and re-run only the runs of branches under its prefixes, never those of `main`, other branches or tags. It never manages runners (`runner list|remove`, registration), even with `runner:register`: a runner runs every branch of every repository.
116-- `ermite token list` shows a `branches` column (`all` when unlimited); `--json` has `branch_prefixes`.
117-- This build uses storage schema 3 (2 added the branch prefixes of tokens; 3 the jobs of reusable workflows and the head commit of the runs of merge requests). Once it has started on your data, an older ermite refuses to start on it (it would read limited tokens as unrestricted, run the jobs of a called workflow as the caller's, and attach the checks of merge request runs to the wrong commit): to go back, restore the backup taken before the upgrade.
118-
119-## Merge requests
120-
121-A merge request proposes a branch for its target (the default branch unless set). The merge is refused when the branch moved since the reviewed head (`--head`) or when the target is not an ancestor of it. Fast-forward moves the target; squash writes one commit signed by the server's SSH key (`ermite signing-key show` prints the `allowed_signers` line for `git verify-commit`). `ermite repo edit --merge-modes` limits the modes per repository. `--delete-branch` (a checkbox on the web) deletes the source branch after the merge, unless it is the default branch or another open request uses it. Tokens need `mr:write` to open and comment, and `mr:merge` to merge (and delete the merged branch).
122-
123-## CI
124-
125-ermite runs the `.github/workflows/*.yml` workflows of a repository on `push` (a branch or a tag, and the merge of a merge request), on `pull_request` and `pull_request_target` (the merge requests, below), `workflow_dispatch`, `schedule`, `workflow_run`, `create` and `delete` (below) and shows each job as a check on the commit, the branch list and the merge request. CI is on once a runner is registered; there is no switch. Before that a push makes no run and no check.
126-
127-Runs execute on [ermite-runner](https://github.com/bnema/ermite-runner) hosts, which call ermite over its API. Create a token for the runner host with `ermite token create <label> --preset runner` (the `runner:register` scope, which also gives access to the code and the secrets of every repository: give it only to runners you operate), then register the runner with it. `ermite runner list` shows the runners and whether they are online; `ermite runner remove <id>` stops one. A job goes to a runner that has every label of its `runs-on`; a job no runner takes within 24 hours fails.
128-
129-Every job gets a **repository token** (`github.token`, `ermitegh_…`) when a runner takes it: it reads the repository of the run, even a private one, over Git and the REST subset below, writes nothing, and ends with the job (completion, cancel, lost runner) or five minutes after the job's deadline. It is not a stored token: `ermite token list` never shows it and no scope manages it.
130-
131-**OIDC.** ermite is an OpenID Connect provider for its jobs, as GitHub is: a job of a trusted run whose `permissions:` give `id-token: write` can ask for a signed ID token (`core.getIDToken()`, `aws-actions/configure-aws-credentials`, `hashicorp/vault-action` with `method: jwt`) and exchange it for short-lived credentials with a relying party that trusts ermite, instead of storing a long-lived secret. The issuer is `<ERMITE_PUBLIC_URL>/-/oidc`; its discovery document is `/-/oidc/.well-known/openid-configuration` and its keys `/-/oidc/.well-known/jwks`, both anonymous and cacheable. The runner sets `ACTIONS_ID_TOKEN_REQUEST_URL` (`/-/oidc/token?api-version=2.0`) and `ACTIONS_ID_TOKEN_REQUEST_TOKEN` (an `ermiteoidc_…` secret, masked, valid only while the job runs and only at that endpoint) for run steps, JavaScript and Docker actions. Tokens are RS256, valid five minutes (`nbf` ten minutes before `iat`), with GitHub's claims (`sub`, `repository`, `ref`, `sha`, `workflow_ref`, `job_workflow_ref`, `run_id`, `run_attempt`, `event_name`, `environment`…) and `runner_environment: self-hosted`. `sub` is `repo:<owner>/<repo>:environment:<name>` for a job with an environment, else `repo:<owner>/<repo>:pull_request` for `pull_request` and `pull_request_target`, else `repo:<owner>/<repo>:ref:<ref>`; the default `aud` is `<ERMITE_PUBLIC_URL>/<owner>` and `getIDToken(audience)` picks another. Untrusted runs (an agent's push or merge request), jobs without `id-token: write` and servers without `ERMITE_SECRETS_KEY` get no variables, so the action fails as it does on GitHub without the permission. The signing key (RSA 3072) is generated at the first start and stored sealed with `ERMITE_SECRETS_KEY`; `ermite oidc rotate-key` (owner rights) replaces it, and the previous key stays in the JWKS for 24 hours. A relying party must reach the issuer to fetch the keys: AWS, Azure and GCP need it on the public Internet; a server reachable only on a private network works with a relying party on that network, such as Vault:
132 53
133 ```sh54 ```sh
134-vault auth enable jwt55+docker build -t ermite .
135-vault write auth/jwt/config oidc_discovery_url="https://git.example.com/-/oidc" bound_issuer="https://git.example.com/-/oidc"56+token=$(head -c 32 /dev/urandom | base64) # keep it: it is the root credential
136-vault write auth/jwt/role/deploy role_type=jwt user_claim=sub bound_audiences="https://git.example.com/owner" \57+docker run -d -p 127.0.0.1:7341:7341 -v ermite-data:/data \
137- bound_claims_type=glob bound_claims='{"sub":"repo:owner/proj:ref:refs/heads/main"}' policies=deploy ttl=10m58+ -e ERMITE_PUBLIC_URL=http://localhost:7341 -e ERMITE_ADMIN_TOKEN="$token" ermite
138 ```59 ```
139 60
140-An action of `ERMITE_OWNER/<repo>` that exists on this server wins over the same name on github.com. It is resolved **on behalf of whoever pushed the run**: ermite records the pusher with the run (the admin token, the owner's session, or the stored token by its id) and, at every answer, asks whether that pusher can read the action's repository, with the same rule as everywhere else: a public repository, or `repo:read` on a repository of the token's list. The token is looked up again each time, so a token revoked or expired since reads nothing for the run. A repository the pusher cannot read answers like one that does not exist (`repository not found`), whatever the ref, so it does not reveal itself. A job can therefore use an action from another private repository only when its pusher could read it. The runner gets an **archive token** for each such action: it is bound to that repository and that commit and downloads that one archive (`/-/github/api/repos/<owner>/<repo>/tarball/<sha>`) and nothing else. Git over HTTP, the repository endpoint and the rest of the API see it as a caller who can read nothing, not even a public repository. It goes with its job, and with the repository it reads if that is deleted. At most eight archives are written at once for one client address (`429` beyond: a runner host runs several jobs, and all come from one address) and sixteen on the server (`503`); both answers carry `Retry-After: 5`. A client that stops reading is cut after two minutes without progress.61+Without Docker, set `ERMITE_DATA_DIR`, `ERMITE_PUBLIC_URL` and `ERMITE_ADMIN_TOKEN`, then run `ermite serve`. Requires git 2.45 or newer. Put a TLS proxy in front of it.
141-
142-**`pull_request`.** A merge request is a pull request for the workflows. ermite keeps `refs/pull/<n>/head` at the head of its source branch and, while that head merges cleanly into the target, `refs/pull/<n>/merge` at a test merge commit (parents: the target tip, then the head; message `Merge <head> into <base>`). Both are served to `git fetch`, never listed as branches or tags, and refused on every push (for any token, a job's included). The activities are `opened` (the request is created), `synchronize` (its source branch is pushed; each push runs for the commit it pushed, with the rights of whoever pushed it) and `closed` (closed or merged, with `merged` true or false; it uses the last test merge commit, or the head when there is none, and is never computed again, even when the source branch is gone); `reopened` is accepted in `types:` but merge requests cannot be reopened. Without `types:` a workflow gets `opened`, `synchronize` and `reopened`. The run has `github.ref` `refs/pull/<n>/merge`, `github.sha` the merge commit (the workflow file is read from it, so `actions/checkout` gets the merged tree), `github.head_ref` and `github.base_ref` set, and a payload shaped like GitHub's `pull_request` webhook with the fields ermite has. `branches` and `paths` (and their `-ignore` forms) apply to the target branch and to the files changed between the merge base and the head. A head that conflicts with the target makes no run for that update. The checks of the run are on the head commit. The run is trusted (secrets, write token) only when whoever caused the event is the owner **and** the head and the base it runs were pushed by the owner: its workflow comes from the merge commit, so from both branches. Each is checked against the record of who set the branch to exactly that commit, taken when the event happens (see `schedule`); a branch with no record, or whose record names another commit, is untrusted. The owner opening, pushing on or closing the request of a branch an agent pushed (or one targeting an agent's branch) gets an untrusted run. A closing runs the last test merge commit, whose head and base are checked; when it is the merge of an older head than the closing's, it is untrusted. The closing a merge makes has the merger's trust when the merger set the target to the merge result and the head is the one merged, even after the source branch is deleted. A job token starts no run.
143 62
144-**`pull_request_target`.** The same activities, `types:` (default `opened`, `synchronize`, `reopened`), `branches` and `paths` filters and payload as `pull_request`, with `github.event_name` `pull_request_target`, but the workflow is read from the **default branch** and runs on it: `github.ref` is `refs/heads/<default>` and `github.sha` its tip as it was when the activity happened, whatever the head contains (a head that edits the workflow does not change what runs). It also runs when the head conflicts with the target. Its checks are on the head commit, like those of `pull_request`, so they show on the merge request and not on the default branch. The run is trusted (secrets, write token) only when **both** whoever caused the activity is the owner **and** the default branch tip was pushed by the owner (see `schedule`); the head's pusher does not matter, since none of its files run. This is stricter than GitHub, which gives the secrets to every pull request of the repository: here an agent that opens or pushes a merge request gets the run without secrets and with a read-only token. Re-runs and `workflow_run` keep or lower the trust as for any run. Its cache is read-only (the runner sets `ACTIONS_CACHE_MODE=read`, so `actions/cache` skips the save) and restores only the default branch's entries, never the merge ref's, which the `pull_request` runs of the request save from the head's code. Every other run restores from its own ref, then the target branch for a `pull_request`, then the default branch, and saves under its own ref. An entry keeps the trust of the run that saved it: a trusted run restores only entries trusted runs saved (the owner pushing on top of an agent's branch or merge request never restores what the agent's runs cached), an untrusted run restores both, and an untrusted run on the default branch (an agent's `workflow_run` or dispatch on it) saves nothing. Entries saved before ermite sent the cache scope (ermite-runner then used the run's ref whatever its trust) are not told apart: clear the runners' caches once after upgrading.63+All settings: [docs/configuration.md](docs/configuration.md).
145 64
146-> **Warning** (GitHub's, and it applies here): running untrusted code on `pull_request_target` can leak the secrets and the write token, and poison caches. A trusted run that checks out the head (`actions/checkout` with `ref: ${{ github.event.pull_request.head.sha }}`, `git fetch` of `refs/pull/<n>/head`) and then builds, tests or installs it runs the head's code with them: a "pwn request". Use `pull_request` when the workflow builds the request's code; with `pull_request_target`, treat the checked-out head as data only and never execute it (build scripts, `npm install`, configuration files and tools it brings all count). See GitHub's [Securely using pull_request_target](https://docs.github.com/en/actions/reference/security/securely-using-pull_request_target) and [Preventing pwn requests](https://securitylab.github.com/research/github-actions-preventing-pwn-requests).65+## Use
147 66
148-**`workflow_dispatch`.** `ermite workflow run <file> --repo <repo> [--ref <branch or tag>] [-f name=value …]` starts a run of a workflow that declares `workflow_dispatch` (the API is `RunService.DispatchWorkflow`, feature `ci.dispatch`; it needs `ci:write`). The file is its path or its name under `.github/workflows`, and it is read at the ref (the default branch when `--ref` is left out). The inputs are checked against the workflow: an undeclared input, a missing `required` input without a default, a `boolean` other than `true` or `false`, a `number` that is not a decimal number, a `choice` outside its `options`, and an `environment` that is not a valid environment name are refused; a `choice` without default takes its first option. A workflow has at most 25 inputs and their values at most 65,535 characters. In the run, `github.event.inputs` holds the values as strings and the `inputs` context as their declared types (`boolean`, `number`, else string). The actor is the caller. The run is trusted (secrets, write token) only when **both** the caller is the owner **and** the ref it runs on was moved to its current commit by the owner (see `schedule`): the owner dispatching an agent's branch gets an untrusted run, like the owner re-running an agent's run, and a ref with no record (pushed before ermite recorded pushers) is untrusted too. This holds for a tag as for a branch, and for a job token as for the owner. A token limited to branch prefixes can dispatch only on a branch under them and its run is never trusted. A job can dispatch with its repository token when its `permissions:` give `actions: write` (GitHub's exception to "events made with `GITHUB_TOKEN` start no run"), even without `contents: read`; a token that does not hold the permission is refused.67+Point the CLI at the server:
149 68
150-**`schedule`.** `on.schedule[].cron` (five fields) is read at the wall clock of the entry's `timezone`, an IANA time zone name such as `America/New_York` (UTC without one; the time zone database is built into ermite). A name that is not one makes the workflow invalid: its push records a failed run with the error. On a daylight saving spring-forward day, an entry whose time is skipped runs at the next valid time (a 02:30 entry runs at 03:00), as GitHub documents; on a fall-back day, a time that happens twice runs once, at its first occurrence (GitHub does not document it; this is cron's behaviour). The entries are read on the **default branch** only. Every minute the scheduler starts the workflows whose cron matches that minute, on the latest commit of the default branch; the payload is `{"schedule": "<cron>"}`. A minute is handled once: ticks missed while ermite was down or busy are not made up, and a workflow with several matching entries starts one run. The actor is **whoever last moved the default branch to its current commit** (a push or a merge), and the run is trusted only if that was the owner. ermite records who moves each branch and tag **before** it moves (in the pre-receive decision of a push, and in the merge under its lock; a record that cannot be written refuses the push), together with the commit it is for: the record counts only while the ref still points to that commit, so a late write cannot leave the owner's record on a tip an agent moved there (owner A, agent B, agent A). A tip with no record, a record for another commit (a push refused after its record was written) or an unreadable record runs untrusted, without secrets. If the owner and a token push the very same commit at the same time, the owner's record may remain: the content is the same and the owner pushed it. At most 64 repositories are looked at per second, in rotation, so a pass over more takes several seconds and never starves one. The schedules of a repository are read again when its default branch moves, and go with the repository.69+```sh
151-70+ermite config set url https://git.example.com
152-**`workflow_run`.** A workflow that lists `workflow_run: workflows: [<name>]` runs when a run of a workflow with that `name` is `requested` (created) or `completed` (`types:` picks one; `in_progress` never happens). `branches` and `branches-ignore` apply to the head branch of the triggering run (the source branch of a merge request). The workflow is read from the **default branch** and runs on its latest commit; the payload has `workflow_run` (`id`, `name`, `head_branch`, `head_sha`, `event`, `status`, `conclusion`, `run_number`, `run_attempt`, `path`, `actor`) and `workflow`. The run inherits the actor and the trust of the triggering run and never gains any: a workflow_run of an untrusted run has no secrets. A chain stops after three levels of `workflow_run`.71+ermite config set token.cmd 'pass show ermite/token' # any command that prints the token
153-72+```
154-**`create` and `delete`.** A push (or a merge that deletes its source branch) that creates or deletes a branch or a tag starts the workflows that declare `create` or `delete`, besides its `push` workflows. A `create` run is on the created ref: `github.ref` is it, `github.sha` its commit, and the workflow is read from that commit. A `delete` run is on the **default branch**, its latest commit, and its workflow is read from there: the deleted ref is gone. The payload has `ref` (the short name), `ref_type` (`branch` or `tag`), `pusher_type` (`user`), `repository` and `sender`; `create` adds `master_branch` (the default branch) and `description` (`null`). There are no filters, and `[skip ci]` does not apply. A push that creates (or deletes) more than three tags starts no `create` (or `delete`) run for them, as on GitHub. A `create` run is trusted only when the pusher is the owner **and** the record of the created ref names the commit it runs (see `schedule`); a `delete` run only when the deleter is the owner **and** the default branch tip was pushed by the owner, as for `pull_request_target` (the deleted ref's record went with it). A job token starts neither.
155-
156-The event of a run is `push`, `pull_request`, `pull_request_target`, `workflow_dispatch`, `schedule`, `workflow_run`, `create` or `delete`: `ermite run list` and `view` and the Actions pages show it.
157-
158-The Actions that run here call a small part of GitHub's REST API, served at `github.api_url` (`/-/github/api`): `GET /repos/{owner}/{repo}`, `/repos/{owner}/{repo}/hash-algorithm`, `/repos/{owner}/{repo}/tarball[/{ref}]` (streamed with 200, not redirected; a `HEAD` answers the headers without building the archive) and `GET /repos/{owner}/{repo}/pulls/{number}/files` (what `dorny/paths-filter` reads on a `pull_request`: up to 3000 files, `page` and `per_page` of at most 100, 30 by default, with the `Link` header; a job token needs `pull-requests: read`, else `403 Resource not accessible by integration`). The owner in these paths and in the Git URLs is matched without regard to case, as on GitHub. Any other path is GitHub's `404 {"message":"Not Found",…}`, so an action that needs more fails as on a GitHub instance without that feature.
159-
160-Actions from github.com are resolved with `git ls-remote https://github.com/<owner>/<repo>` (no credentials, 20 s, 16 MiB of output, at most four at once, 60 s for a whole call). ermite hands git only the proxy settings of its own environment (`HTTPS_PROXY`, `HTTP_PROXY`, `NO_PROXY`, `ALL_PROXY` and their lower-case forms), so a server that reaches the Internet through a proxy sets them for `ermite serve`; nothing else of its environment, no credential, reaches git.
161-
162-`ermite run list|view|watch|cancel|rerun` and the `Actions` tab of a repository read and control the runs; `cancel` and `rerun` need the `ci:write` scope. With `failing_checks_block` on (the default, `ermite repo edit --failing-checks-block=false`), a merge is refused while a check of the head failed or has not finished. `concurrency:` is supported at the workflow level (evaluated when the run starts) and at the job level (evaluated when the job is planned, once per matrix leg): a group is per repository and compared without regard to case, `queue: single` (the default) lets one run or job go and keeps one waiting, a newer waiting entry cancels the older one (`Canceling since a higher priority waiting request for '<group>' exists`), and `cancel-in-progress` (a boolean or an expression) also cancels the running one. With `queue: max`, up to 100 entries wait and run first in, first out; one more is cancelled (`Canceling since the concurrency group '<group>' already has 100 waiting requests`, ermite's wording). In a group that mixes both (GitHub does not say), ermite applies the rule of the entry that starts waiting: a `single` entry cancels every waiting entry, `max` ones included, and a `max` entry joins the end of the line. A job held back by a group shows `pending` in the API, the web pages and the CLI. A group that evaluates to nothing fails the job. `queue: max` with `cancel-in-progress: true` written as such makes the workflow invalid (its push records a failed run with the error, as GitHub's validation does); when either is an expression that evaluates to that pair, the job fails with the same message. The waiting line is stored with its head also in the field older ermite versions read, so a downgrade keeps the first waiting entry and cancels the others. Reusable workflows (`jobs.<id>.uses`) run: `./.github/workflows/x.yml` (the caller's repository, at the commit of the run), `owner/repo/.github/workflows/x.yml@ref` of a repository of this server when `owner` is `ERMITE_OWNER` and the actor of the run can read it (else the call fails as for a repository that does not exist), and otherwise a public repository on github.com, whose ref is resolved like the ref of an action and whose file is downloaded at that commit (at most 1 MiB). ermite expands the call when the job is planned: the called workflow must declare `on: workflow_call`, `with:` is checked against its inputs (types, `required`, defaults), `secrets:` passes the listed secrets, renamed as written, or all of them with `secrets: inherit` (a secret that is not passed is not there), and its `outputs` are available to the later jobs of the caller as `needs.<job>.outputs`. The jobs of the called workflow are named `<calling job> / <job>` and keyed `<calling job id>/<job id>`, wait for the `needs` of the calling job, and are repeated for each leg of its `strategy`; `permissions` can only be lowered by the called workflow. A call may nest four levels deep. `concurrency:` on the calling job and the workflow-level `concurrency:` of the called workflow (evaluated with the caller's `github` and the call's `inputs`) apply to the whole call, which holds the group until it ends, with `cancel-in-progress` as for any job. A workflow that uses another feature ermite does not support yet fails with `not supported yet: <feature>`, never silently. Logs are files under `$ERMITE_DATA_DIR/ci/logs`, and the job summaries (the Markdown a step writes to `GITHUB_STEP_SUMMARY`) under `$ERMITE_DATA_DIR/ci/summaries`; both go with their run (retention, repository deletion). Annotations (`::notice`, `::warning`, `::error`) are kept in the metadata store. The run page shows them in an Annotations box and the job page at their step; the summaries are rendered under the run with the README renderer (raw HTML dropped, `javascript:` links dropped). `ermite run view` prints an ANNOTATIONS section, and `ermite run view --summary` prints the summaries as Markdown.
163-
164-**Artifacts** (artifacts v4, `actions/upload-artifact` and `actions/download-artifact` v4 and later) are stored by ermite and served to the jobs by the runner, which translates the toolkit's protocol (ermite-api `JobArtifactService`, feature `ci.artifacts`). A job reaches the artifacts of its own run only, with its job token, while it runs; every job of the run, in any attempt, sees the finalized ones, and an artifact uploaded again by a re-run replaces the one of the earlier attempt. Contents are files under `$ERMITE_DATA_DIR/ci/artifacts`, named by ids (never by artifact name). Limits are checked before a byte is accepted: `ERMITE_CI_ARTIFACT_MAX` per artifact, `ERMITE_CI_ARTIFACT_QUOTA` per repository (uploads in progress included); `retention-days` is kept between one day and `ERMITE_CI_ARTIFACT_RETENTION`. A job has at most 32 unfinished uploads and a run at most 1000 artifacts. An upload not finished when its job ends is dropped. Artifacts go when they expire, with their run (retention) and with their repository. `ermite run artifacts <repo> <run>` lists them and `ermite run download <repo> <run> [--name N]… [--dir D]` downloads them (a zip is extracted into `D`, or `D/<name>` when there are several); the run page lists them with download links. They are never served anonymously, even for a public repository: listing and downloading need `repo:read`, deleting `ci:write`. Not covered: artifacts v3 (`@actions/artifact` v1), the REST endpoints of artifacts (`/actions/runs/{id}/artifacts`, `/actions/artifacts/{id}/zip`: `download-artifact` with `run-id` and `github-token` gets a 404).
165-
166-The cache (`actions/cache`, `setup-go`/`setup-node` `cache:`) is served by the runner and works.
167-
168-## Web sign-in
169 73
170-Two ways in, both for the owner, both giving the same session cookie for 12 hours. Neither works over plain `http` except on localhost: the cookie is `Secure`.74+Then:
171 75
172-- **Passkey** (a phone, a laptop, a security key): open `/-/login` and press "Sign in with a passkey". Nothing to type, no terminal. A passkey is added once from `/-/passkeys` while signed in.76+```sh
173-- **Link**: `ermite web login`, run with the admin token or a token with every scope, prints a link that works once within 60 seconds. Opening it signs the browser in (without scripts, click the button on the page) (never longer than the token that asked for the link). It is also how to sign in the first time, before any passkey exists.77+ermite repo create myrepo
78+git push https://x:<token>@git.example.com/myrepo.git main # prefer a credential helper
79+ermite mr create myrepo --source feature --title "Add feature"
80+ermite web login # one-time link to sign in to the web interface
81+```
174 82
175-Passkeys need `ERMITE_PUBLIC_URL` to be `https` (or `http://localhost`) with a **host name, not an IP address**: WebAuthn ties a passkey to that name, and a passkey registered for one name does not work on another. On `http://127.0.0.1:…` the server starts without passkeys (the log says `passkeys disabled`, the routes and links are absent, `ermite version` does not list `passkeys`). Up to 20 passkeys can be registered; each asks for user verification (a fingerprint, a face or a PIN), and the server stores only the public key. A security key that reports a signature counter is checked for cloning; synced passkeys (iCloud, Google, a password manager) report none, so they are not.83+Pushing to a new name also creates a private repository.
176 84
177-`ermite web passkey list` shows them, `ermite web passkey remove <id>` removes one. Removing a passkey, `ermite web logout --all`, or revoking a token with every scope ends every session, on every device. Browser writes require the session and an `Origin` equal to `ERMITE_PUBLIC_URL`. If you lose every device, `ermite web login` still works from a terminal.85+## Agent workflow
178 86
179-The server keeps its runtime files in `$ERMITE_DATA_DIR/run/` (the hook socket and scripts that git calls on each push); the hook socket path must stay under about 100 bytes.87+You and the agent use the same CLI. The difference is the token: the agent's is limited to its own branches. It can never push `main`, tags or settings, and its CI runs get no secrets.
180 88
181-## Run with Docker89+**1. You give the agent a token** limited to `agent/claude/*`:
182 90
183 ```sh91 ```sh
184-docker build --build-arg VERSION="$(git describe --tags --always --dirty)" -t ermite .92+ermite token create claude --preset agent --scope repo:read --repo myrepo --branch-prefix agent/claude/ --expires 30d
185-token=$(head -c 32 /dev/urandom | base64) # keep it: it is the root credential
186-docker run -d -p 127.0.0.1:7341:7341 -v ermite-data:/data \
187- -e ERMITE_PUBLIC_URL=http://localhost:7341 -e ERMITE_ADMIN_TOKEN="$token" ermite
188 ```93 ```
189 94
190-The image is alpine with git, runs as uid 10001 and keeps its data in the `/data` volume. Logs are JSON by default. The build fetches the public `bnema.dev/ermite-*` modules through their `bnema.dev` vanity paths. Behind a private route, `--build-arg GIT_REWRITE_BASE=<url>` rewrites the git host used for those modules.95+**2. The agent pushes and opens a merge request:**
191-
192-## Develop with compose
193 96
194 ```sh97 ```sh
195-docker compose up98+export ERMITE_URL=https://git.example.com ERMITE_TOKEN=<agent token>
99+git push origin agent/claude/fix-login
100+ermite mr create myrepo --source agent/claude/fix-login --title "Fix login redirect"
101+ermite run watch myrepo <run id> # follow its CI
196 ```102 ```
197 103
198-Serves on `http://localhost:7341` with the development admin token in `compose.yaml`. The source is mounted and [air](https://github.com/air-verse/air) (`go tool air`, `.air.toml`) rebuilds and restarts the server on each change to a `.go` or `.templ` file or an embedded web asset; the module and build caches and the data live in named volumes. `docker compose down -v` removes them. The dev container runs as root, which maps to your user under rootless Docker.104+**3. You review:**
199-
200-## Build
201 105
202 ```sh106 ```sh
203-go build ./cmd/ermite107+ermite mr show myrepo 1 --diff
204-go test ./...108+ermite mr comment myrepo 1 -m "Handle the empty case" --path auth.go --line 42 --side new
205 ```109 ```
206 110
207-`go test ./...` includes the end-to-end tests of `internal/app`, which start a real server per test. Run them before opening a pull request: CI runs them on `main` only, after the merge.111+**4. The agent reads your comments and pushes again:**
208-
209-## Architecture
210 112
211-`cmd/ermite` (entry point, calls `app.Run`) → `internal/app` (CLI and composition root: builds the adapters and wires them to the use cases) → `internal/usecase` (use cases), `internal/ports` (interfaces and parameter types that cross layers), `internal/domain` (pure values and rules) and `internal/adapters` (Git, KV store, HTTP, API, web, client implementations of the ports).113+```sh
212-114+ermite watch myrepo --json # live events: pushes, merge requests, comments
213-- `internal/usecase` imports only `internal/domain` and `internal/ports`.115+ermite mr show myrepo 1 --since-last-read --mark-read
214-- Adapters import `internal/domain`, `internal/ports` and the shared `internal/clientlimit`; never a use case, `internal/app` or another adapter.116+```
215-- The web adapter, like the API and Git over HTTP, calls the use cases through `internal/ports` with the principal of the request (anonymous, or the owner with a valid session); it never imports the generated API code.
216-- `internal/ports` imports only `internal/domain`; `internal/domain` imports nothing from the module.
217-- `internal/config` (environment parsing) is used by `internal/app` only.
218-
219-`internal/boundary/boundary_test.go` enforces these import rules on production files; `hexcheck` (`.hexcheck.yaml`) checks the layering of types and behaviour. The release version is stamped with `-ldflags "-X bnema.dev/ermite/internal/app.stamped=<version>"`.
220-
221-## Develop
222 117
223-Tools are pinned in `go.mod`:118+**5. You merge** (or click Merge in the web interface):
224 119
225 ```sh120 ```sh
226-go tool buf lint121+ermite mr merge myrepo 1 --head <head id> --squash --delete-branch
227-go tool buf generate # proto/ -> gen/
228-go generate ./... # mockery mocks of the ports
229-go tool hexcheck -fail-on warn ./...
230 ```122 ```
231 123
124+Give the agent the [`skills/ermite`](skills/ermite/SKILL.md) skill so it knows the CLI. Rules of agent tokens: [docs/tokens.md](docs/tokens.md).
125+
126+## Documentation
127+
128+- [Configuration](docs/configuration.md): environment variables
129+- [CLI](docs/cli.md): config file, commands, JSON output
130+- [Git over HTTP](docs/git-http.md): URLs and authentication
131+- [Tokens](docs/tokens.md): scopes, presets, branch-limited tokens
132+- [Merge requests](docs/merge-requests.md)
133+- [Web sign-in](docs/web-sign-in.md): passkeys and one-time links
134+- [CI](docs/ci.md): runners, trust, concurrency, reusable workflows
135+ - [CI events](docs/ci-events.md)
136+ - [OIDC](docs/ci-oidc.md)
137+ - [Artifacts](docs/ci-artifacts.md)
138+- [Development](docs/development.md): build, tests, architecture
139+
232 ## License140 ## License
233 141
234 AGPL-3.0-or-later. See [LICENSE](LICENSE).142 AGPL-3.0-or-later. See [LICENSE](LICENSE).
A docs/ci-artifacts.md +32 -0
@@ -0,0 +1,32 @@
1+# CI: artifacts
2+
3+`actions/upload-artifact` and `actions/download-artifact` v4 and later work. ermite stores the artifacts; the runner serves them to the jobs.
4+
5+```sh
6+ermite run artifacts <repo> <run>
7+ermite run download <repo> <run> [--name N]… [--dir D]
8+```
9+
10+The run page lists them with download links.
11+
12+## Rules
13+
14+- A job sees only the artifacts of its own run. A re-run that uploads again replaces the earlier one.
15+- Never served anonymously, even for a public repository: `repo:read` to list and download, `ci:write` to delete.
16+- Stored under `$ERMITE_DATA_DIR/ci/artifacts`. Removed when they expire, or with their run or repository.
17+- An upload not finished when its job ends is dropped.
18+
19+## Limits
20+
21+| Setting | Default |
22+|---|---|
23+| `ERMITE_CI_ARTIFACT_MAX` (one artifact) | `5GiB` |
24+| `ERMITE_CI_ARTIFACT_QUOTA` (per repository) | `10GiB` |
25+| `ERMITE_CI_ARTIFACT_RETENTION` (max `retention-days`) | `90d` |
26+
27+Also: 32 unfinished uploads per job, 1000 artifacts per run. `ERMITE_CI_ARTIFACTS=0` turns artifacts off.
28+
29+## Not supported
30+
31+- Artifacts v3 (`@actions/artifact` v1).
32+- The REST endpoints `/actions/runs/{id}/artifacts` and `/actions/artifacts/{id}/zip`: `download-artifact` with `run-id` and `github-token` gets a 404.
@@ -0,0 +1,32 @@
1+# CI: artifacts
2+
3+`actions/upload-artifact` and `actions/download-artifact` v4 and later work. ermite stores the artifacts; the runner serves them to the jobs.
4+
5+```sh
6+ermite run artifacts <repo> <run>
7+ermite run download <repo> <run> [--name N]… [--dir D]
8+```
9+
10+The run page lists them with download links.
11+
12+## Rules
13+
14+- A job sees only the artifacts of its own run. A re-run that uploads again replaces the earlier one.
15+- Never served anonymously, even for a public repository: `repo:read` to list and download, `ci:write` to delete.
16+- Stored under `$ERMITE_DATA_DIR/ci/artifacts`. Removed when they expire, or with their run or repository.
17+- An upload not finished when its job ends is dropped.
18+
19+## Limits
20+
21+| Setting | Default |
22+|---|---|
23+| `ERMITE_CI_ARTIFACT_MAX` (one artifact) | `5GiB` |
24+| `ERMITE_CI_ARTIFACT_QUOTA` (per repository) | `10GiB` |
25+| `ERMITE_CI_ARTIFACT_RETENTION` (max `retention-days`) | `90d` |
26+
27+Also: 32 unfinished uploads per job, 1000 artifacts per run. `ERMITE_CI_ARTIFACTS=0` turns artifacts off.
28+
29+## Not supported
30+
31+- Artifacts v3 (`@actions/artifact` v1).
32+- The REST endpoints `/actions/runs/{id}/artifacts` and `/actions/artifacts/{id}/zip`: `download-artifact` with `run-id` and `github-token` gets a 404.
A docs/ci-events.md +64 -0
@@ -0,0 +1,64 @@
1+# CI events
2+
3+| Event | Workflow read from | Runs on | Trusted when the owner caused it and… |
4+|---|---|---|---|
5+| `push` | pushed commit | pushed ref | pushed it |
6+| `pull_request` | test merge commit | `refs/pull/<n>/merge` | pushed both head and base |
7+| `pull_request_target` | default branch | default branch tip | pushed the default branch tip |
8+| `workflow_dispatch` | chosen ref | chosen ref | moved that ref to its commit |
9+| `schedule` | default branch | default branch tip | last moved the default branch |
10+| `workflow_run` | default branch | default branch tip | inherits the triggering run, never more |
11+| `create` | created ref | created ref | pushed it |
12+| `delete` | default branch | default branch tip | pushed the default branch tip |
13+
14+`ermite run list`, `run view` and the Actions pages show the event of each run.
15+
16+## `pull_request`
17+
18+A merge request is a pull request for workflows.
19+
20+- `refs/pull/<n>/head` follows the source branch. `refs/pull/<n>/merge` is a test merge with the target, while it merges cleanly. Both can be fetched, never pushed.
21+- Activities: `opened`, `synchronize` (a push on the source branch), `closed` (`merged` true or false). Default: `opened`, `synchronize`, `reopened`.
22+- `branches` filters the target; `paths` filters files changed between the merge base and the head.
23+- A head that conflicts with the target makes no run.
24+- Checks are on the head commit.
25+
26+## `pull_request_target`
27+
28+Same activities, filters and payload as `pull_request`, but the workflow comes from the **default branch** and runs on it. A head that edits the workflow changes nothing. Runs even on conflict.
29+
30+Stricter than GitHub: a merge request opened or pushed by an agent gets no secrets.
31+
32+Its cache is read-only and restores only default-branch entries.
33+
34+> **Warning:** never build, test or install the head's code in a trusted `pull_request_target` run (checking out `github.event.pull_request.head.sha` and running it). That leaks secrets: a "pwn request". Use `pull_request` to build a request's code. See GitHub's [Securely using pull_request_target](https://docs.github.com/en/actions/reference/security/securely-using-pull_request_target).
35+
36+## `workflow_dispatch`
37+
38+```sh
39+ermite workflow run <file> --repo <repo> [--ref <branch|tag>] [-f name=value …]
40+```
41+
42+- Needs `ci:write`. `<file>` is a path or a name under `.github/workflows`. `--ref` defaults to the default branch.
43+- Inputs are checked against the workflow (`required`, `boolean`, `number`, `choice`, `environment`). Up to 25 inputs.
44+- A branch-limited token can dispatch only on its branches, and never gets a trusted run.
45+- A job can dispatch with its own token when its `permissions:` give `actions: write`.
46+
47+## `schedule`
48+
49+- `cron` (five fields) with an optional IANA `timezone` (UTC by default). An invalid name fails the push's run.
50+- Read on the default branch only; runs on its latest commit.
51+- Missed minutes (server down) are not made up. Several matching entries start one run.
52+- DST: a skipped time runs at the next valid time; a repeated time runs once.
53+
54+## `workflow_run`
55+
56+- `workflows: [<name>]`, `types: requested | completed`. `branches` filters the head branch of the triggering run.
57+- Payload: `workflow_run` and `workflow`.
58+- Chains stop after three levels.
59+
60+## `create` and `delete`
61+
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.
64+- Payload: `ref`, `ref_type`, `pusher_type`, `repository`, `sender`; `create` adds `master_branch` and `description`.
@@ -0,0 +1,64 @@
1+# CI events
2+
3+| Event | Workflow read from | Runs on | Trusted when the owner caused it and… |
4+|---|---|---|---|
5+| `push` | pushed commit | pushed ref | pushed it |
6+| `pull_request` | test merge commit | `refs/pull/<n>/merge` | pushed both head and base |
7+| `pull_request_target` | default branch | default branch tip | pushed the default branch tip |
8+| `workflow_dispatch` | chosen ref | chosen ref | moved that ref to its commit |
9+| `schedule` | default branch | default branch tip | last moved the default branch |
10+| `workflow_run` | default branch | default branch tip | inherits the triggering run, never more |
11+| `create` | created ref | created ref | pushed it |
12+| `delete` | default branch | default branch tip | pushed the default branch tip |
13+
14+`ermite run list`, `run view` and the Actions pages show the event of each run.
15+
16+## `pull_request`
17+
18+A merge request is a pull request for workflows.
19+
20+- `refs/pull/<n>/head` follows the source branch. `refs/pull/<n>/merge` is a test merge with the target, while it merges cleanly. Both can be fetched, never pushed.
21+- Activities: `opened`, `synchronize` (a push on the source branch), `closed` (`merged` true or false). Default: `opened`, `synchronize`, `reopened`.
22+- `branches` filters the target; `paths` filters files changed between the merge base and the head.
23+- A head that conflicts with the target makes no run.
24+- Checks are on the head commit.
25+
26+## `pull_request_target`
27+
28+Same activities, filters and payload as `pull_request`, but the workflow comes from the **default branch** and runs on it. A head that edits the workflow changes nothing. Runs even on conflict.
29+
30+Stricter than GitHub: a merge request opened or pushed by an agent gets no secrets.
31+
32+Its cache is read-only and restores only default-branch entries.
33+
34+> **Warning:** never build, test or install the head's code in a trusted `pull_request_target` run (checking out `github.event.pull_request.head.sha` and running it). That leaks secrets: a "pwn request". Use `pull_request` to build a request's code. See GitHub's [Securely using pull_request_target](https://docs.github.com/en/actions/reference/security/securely-using-pull_request_target).
35+
36+## `workflow_dispatch`
37+
38+```sh
39+ermite workflow run <file> --repo <repo> [--ref <branch|tag>] [-f name=value …]
40+```
41+
42+- Needs `ci:write`. `<file>` is a path or a name under `.github/workflows`. `--ref` defaults to the default branch.
43+- Inputs are checked against the workflow (`required`, `boolean`, `number`, `choice`, `environment`). Up to 25 inputs.
44+- A branch-limited token can dispatch only on its branches, and never gets a trusted run.
45+- A job can dispatch with its own token when its `permissions:` give `actions: write`.
46+
47+## `schedule`
48+
49+- `cron` (five fields) with an optional IANA `timezone` (UTC by default). An invalid name fails the push's run.
50+- Read on the default branch only; runs on its latest commit.
51+- Missed minutes (server down) are not made up. Several matching entries start one run.
52+- DST: a skipped time runs at the next valid time; a repeated time runs once.
53+
54+## `workflow_run`
55+
56+- `workflows: [<name>]`, `types: requested | completed`. `branches` filters the head branch of the triggering run.
57+- Payload: `workflow_run` and `workflow`.
58+- Chains stop after three levels.
59+
60+## `create` and `delete`
61+
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.
64+- Payload: `ref`, `ref_type`, `pusher_type`, `repository`, `sender`; `create` adds `master_branch` and `description`.
A docs/ci-oidc.md +58 -0
@@ -0,0 +1,58 @@
1+# CI: OIDC
2+
3+ermite is an OpenID Connect provider for its jobs, like GitHub. A job exchanges a short-lived ID token for cloud or Vault credentials instead of storing a long-lived secret.
4+
5+```yaml
6+permissions:
7+ id-token: write
8+```
9+
10+Works with `core.getIDToken()`, `aws-actions/configure-aws-credentials`, `hashicorp/vault-action` (`method: jwt`).
11+
12+## Requirements
13+
14+- A **trusted** run (see [CI trust](ci.md#trust)). Agent runs get no token.
15+- `id-token: write` in `permissions:`.
16+- `ERMITE_SECRETS_KEY` set on the server.
17+
18+Otherwise the action fails as on GitHub without the permission.
19+
20+## Endpoints
21+
22+| | |
23+|---|---|
24+| Issuer | `<ERMITE_PUBLIC_URL>/-/oidc` |
25+| Discovery | `/-/oidc/.well-known/openid-configuration` |
26+| Keys | `/-/oidc/.well-known/jwks` |
27+
28+The relying party must reach the issuer. AWS, Azure and GCP need it on the public Internet; a private server works with a relying party on the same network, such as Vault.
29+
30+## Token
31+
32+- RS256, valid 5 minutes, GitHub's claims (`sub`, `repository`, `ref`, `sha`, `job_workflow_ref`, `environment`, …), `runner_environment: self-hosted`.
33+- `sub`:
34+ - `repo:<owner>/<repo>:environment:<name>` for a job with an environment
35+ - `repo:<owner>/<repo>:pull_request` for `pull_request` and `pull_request_target`
36+ - `repo:<owner>/<repo>:ref:<ref>` otherwise
37+- Default `aud`: `<ERMITE_PUBLIC_URL>/<owner>`. `getIDToken(audience)` picks another.
38+
39+## Key rotation
40+
41+```sh
42+ermite oidc rotate-key
43+```
44+
45+The previous key stays in the JWKS for 24 hours.
46+
47+## Vault example
48+
49+```sh
50+vault auth enable jwt
51+vault write auth/jwt/config \
52+ oidc_discovery_url="https://git.example.com/-/oidc" \
53+ bound_issuer="https://git.example.com/-/oidc"
54+vault write auth/jwt/role/deploy role_type=jwt user_claim=sub \
55+ bound_audiences="https://git.example.com/owner" \
56+ bound_claims_type=glob bound_claims='{"sub":"repo:owner/proj:ref:refs/heads/main"}' \
57+ policies=deploy ttl=10m
58+```
@@ -0,0 +1,58 @@
1+# CI: OIDC
2+
3+ermite is an OpenID Connect provider for its jobs, like GitHub. A job exchanges a short-lived ID token for cloud or Vault credentials instead of storing a long-lived secret.
4+
5+```yaml
6+permissions:
7+ id-token: write
8+```
9+
10+Works with `core.getIDToken()`, `aws-actions/configure-aws-credentials`, `hashicorp/vault-action` (`method: jwt`).
11+
12+## Requirements
13+
14+- A **trusted** run (see [CI trust](ci.md#trust)). Agent runs get no token.
15+- `id-token: write` in `permissions:`.
16+- `ERMITE_SECRETS_KEY` set on the server.
17+
18+Otherwise the action fails as on GitHub without the permission.
19+
20+## Endpoints
21+
22+| | |
23+|---|---|
24+| Issuer | `<ERMITE_PUBLIC_URL>/-/oidc` |
25+| Discovery | `/-/oidc/.well-known/openid-configuration` |
26+| Keys | `/-/oidc/.well-known/jwks` |
27+
28+The relying party must reach the issuer. AWS, Azure and GCP need it on the public Internet; a private server works with a relying party on the same network, such as Vault.
29+
30+## Token
31+
32+- RS256, valid 5 minutes, GitHub's claims (`sub`, `repository`, `ref`, `sha`, `job_workflow_ref`, `environment`, …), `runner_environment: self-hosted`.
33+- `sub`:
34+ - `repo:<owner>/<repo>:environment:<name>` for a job with an environment
35+ - `repo:<owner>/<repo>:pull_request` for `pull_request` and `pull_request_target`
36+ - `repo:<owner>/<repo>:ref:<ref>` otherwise
37+- Default `aud`: `<ERMITE_PUBLIC_URL>/<owner>`. `getIDToken(audience)` picks another.
38+
39+## Key rotation
40+
41+```sh
42+ermite oidc rotate-key
43+```
44+
45+The previous key stays in the JWKS for 24 hours.
46+
47+## Vault example
48+
49+```sh
50+vault auth enable jwt
51+vault write auth/jwt/config \
52+ oidc_discovery_url="https://git.example.com/-/oidc" \
53+ bound_issuer="https://git.example.com/-/oidc"
54+vault write auth/jwt/role/deploy role_type=jwt user_claim=sub \
55+ bound_audiences="https://git.example.com/owner" \
56+ bound_claims_type=glob bound_claims='{"sub":"repo:owner/proj:ref:refs/heads/main"}' \
57+ policies=deploy ttl=10m
58+```
A docs/ci.md +92 -0
@@ -0,0 +1,92 @@
1+# CI
2+
3+ermite runs the `.github/workflows/*.yml` of a repository, like GitHub Actions. Each job shows as a check on the commit, the branch list and the merge request.
4+
5+See also: [events](ci-events.md), [OIDC](ci-oidc.md), [artifacts](ci-artifacts.md).
6+
7+## Runners
8+
9+Jobs run on [ermite-runner](https://github.com/bnema/ermite-runner) hosts: a Firecracker microVM per job when KVM is available, else a container.
10+
11+```sh
12+ermite token create runner-1 --preset runner # register the runner with this token
13+ermite runner list # online or not
14+ermite runner remove <id>
15+```
16+
17+- CI is on once a runner is registered. Before that, pushes make no run.
18+- The `runner` preset can read the code and secrets of **every** repository. Use it only on runners you operate.
19+- A job goes to a runner that has every label of its `runs-on`. A job no runner takes within 24 hours fails.
20+
21+## Control runs
22+
23+```sh
24+ermite run list <repo>
25+ermite run view <repo> <id> [--log] [--summary]
26+ermite run watch <repo> <id>
27+ermite run cancel <repo> <id> # needs ci:write
28+ermite run rerun <repo> <id> [--failed]
29+```
30+
31+The **Actions** tab of a repository shows the same. With `failing_checks_block` on (default), a merge waits for every check to pass. Turn it off with `ermite repo edit <repo> --failing-checks-block=false`.
32+
33+## Trust
34+
35+A run is **trusted** (secrets, write token, OIDC) only when the owner caused it **and** the owner pushed the code it runs.
36+
37+- ermite records who moved each branch and tag to its current commit. A ref with no record, or a record for another commit, is untrusted.
38+- An agent's push or merge request gets an untrusted run: no secrets, read-only token.
39+- The owner re-running or dispatching an agent's branch still gets an untrusted run.
40+- A job token starts no run (except `workflow_dispatch` with `actions: write`).
41+
42+Per-event rules are in [ci-events.md](ci-events.md).
43+
44+## Job token
45+
46+Every job gets `github.token` (`ermitegh_…`). It reads the run's repository (even private) over Git and the REST subset below, writes nothing, and expires with the job. It is not listed in `ermite token list`.
47+
48+## Actions
49+
50+- `ERMITE_OWNER/<repo>` on this server wins over the same name on github.com. It resolves only if whoever pushed the run can read that repository; otherwise it answers `repository not found`.
51+- Other actions come from github.com (`git ls-remote`, no credentials). If the server needs a proxy, set `HTTPS_PROXY` / `NO_PROXY` for `ermite serve`.
52+- The cache (`actions/cache`, `setup-*` `cache:`) works. Trusted runs only restore entries saved by trusted runs.
53+
54+### REST API subset
55+
56+Served at `github.api_url` (`/-/github/api`):
57+
58+- `GET /repos/{owner}/{repo}`
59+- `GET /repos/{owner}/{repo}/hash-algorithm`
60+- `GET /repos/{owner}/{repo}/tarball[/{ref}]`
61+- `GET /repos/{owner}/{repo}/pulls/{number}/files` (for `dorny/paths-filter`; needs `pull-requests: read`)
62+
63+Any other path is GitHub's `404`, so an action that needs more fails as on a GitHub instance without that feature.
64+
65+## Concurrency
66+
67+`concurrency:` works at workflow and job level, per repository, case-insensitive.
68+
69+- `queue: single` (default): one runs, one waits; a newer waiting entry cancels the older.
70+- `queue: max`: up to 100 wait, first in first out.
71+- `cancel-in-progress` also cancels the running one. It cannot be combined with `queue: max`.
72+- A held job shows as `pending`.
73+
74+## Reusable workflows
75+
76+`jobs.<id>.uses` accepts:
77+
78+- `./.github/workflows/x.yml` (same repository, same commit);
79+- `owner/repo/.github/workflows/x.yml@ref` on this server, if the run's actor can read it;
80+- a public repository on github.com.
81+
82+`with:`, `secrets:` (or `secrets: inherit`) and `outputs` work as on GitHub. Called jobs are named `<caller> / <job>`. Up to four levels deep. `permissions` can only be lowered.
83+
84+## Logs, summaries, annotations
85+
86+- Logs: `$ERMITE_DATA_DIR/ci/logs`. Summaries (`GITHUB_STEP_SUMMARY`): `$ERMITE_DATA_DIR/ci/summaries`. Both are removed with their run.
87+- Annotations (`::notice`, `::warning`, `::error`) show on the run and job pages and in `ermite run view`.
88+- Retention: `ERMITE_CI_LOG_RETENTION` (default 90 days).
89+
90+## Unsupported features
91+
92+A workflow that uses a feature ermite does not support fails with `not supported yet: <feature>`, never silently.
@@ -0,0 +1,92 @@
1+# CI
2+
3+ermite runs the `.github/workflows/*.yml` of a repository, like GitHub Actions. Each job shows as a check on the commit, the branch list and the merge request.
4+
5+See also: [events](ci-events.md), [OIDC](ci-oidc.md), [artifacts](ci-artifacts.md).
6+
7+## Runners
8+
9+Jobs run on [ermite-runner](https://github.com/bnema/ermite-runner) hosts: a Firecracker microVM per job when KVM is available, else a container.
10+
11+```sh
12+ermite token create runner-1 --preset runner # register the runner with this token
13+ermite runner list # online or not
14+ermite runner remove <id>
15+```
16+
17+- CI is on once a runner is registered. Before that, pushes make no run.
18+- The `runner` preset can read the code and secrets of **every** repository. Use it only on runners you operate.
19+- A job goes to a runner that has every label of its `runs-on`. A job no runner takes within 24 hours fails.
20+
21+## Control runs
22+
23+```sh
24+ermite run list <repo>
25+ermite run view <repo> <id> [--log] [--summary]
26+ermite run watch <repo> <id>
27+ermite run cancel <repo> <id> # needs ci:write
28+ermite run rerun <repo> <id> [--failed]
29+```
30+
31+The **Actions** tab of a repository shows the same. With `failing_checks_block` on (default), a merge waits for every check to pass. Turn it off with `ermite repo edit <repo> --failing-checks-block=false`.
32+
33+## Trust
34+
35+A run is **trusted** (secrets, write token, OIDC) only when the owner caused it **and** the owner pushed the code it runs.
36+
37+- ermite records who moved each branch and tag to its current commit. A ref with no record, or a record for another commit, is untrusted.
38+- An agent's push or merge request gets an untrusted run: no secrets, read-only token.
39+- The owner re-running or dispatching an agent's branch still gets an untrusted run.
40+- A job token starts no run (except `workflow_dispatch` with `actions: write`).
41+
42+Per-event rules are in [ci-events.md](ci-events.md).
43+
44+## Job token
45+
46+Every job gets `github.token` (`ermitegh_…`). It reads the run's repository (even private) over Git and the REST subset below, writes nothing, and expires with the job. It is not listed in `ermite token list`.
47+
48+## Actions
49+
50+- `ERMITE_OWNER/<repo>` on this server wins over the same name on github.com. It resolves only if whoever pushed the run can read that repository; otherwise it answers `repository not found`.
51+- Other actions come from github.com (`git ls-remote`, no credentials). If the server needs a proxy, set `HTTPS_PROXY` / `NO_PROXY` for `ermite serve`.
52+- The cache (`actions/cache`, `setup-*` `cache:`) works. Trusted runs only restore entries saved by trusted runs.
53+
54+### REST API subset
55+
56+Served at `github.api_url` (`/-/github/api`):
57+
58+- `GET /repos/{owner}/{repo}`
59+- `GET /repos/{owner}/{repo}/hash-algorithm`
60+- `GET /repos/{owner}/{repo}/tarball[/{ref}]`
61+- `GET /repos/{owner}/{repo}/pulls/{number}/files` (for `dorny/paths-filter`; needs `pull-requests: read`)
62+
63+Any other path is GitHub's `404`, so an action that needs more fails as on a GitHub instance without that feature.
64+
65+## Concurrency
66+
67+`concurrency:` works at workflow and job level, per repository, case-insensitive.
68+
69+- `queue: single` (default): one runs, one waits; a newer waiting entry cancels the older.
70+- `queue: max`: up to 100 wait, first in first out.
71+- `cancel-in-progress` also cancels the running one. It cannot be combined with `queue: max`.
72+- A held job shows as `pending`.
73+
74+## Reusable workflows
75+
76+`jobs.<id>.uses` accepts:
77+
78+- `./.github/workflows/x.yml` (same repository, same commit);
79+- `owner/repo/.github/workflows/x.yml@ref` on this server, if the run's actor can read it;
80+- a public repository on github.com.
81+
82+`with:`, `secrets:` (or `secrets: inherit`) and `outputs` work as on GitHub. Called jobs are named `<caller> / <job>`. Up to four levels deep. `permissions` can only be lowered.
83+
84+## Logs, summaries, annotations
85+
86+- Logs: `$ERMITE_DATA_DIR/ci/logs`. Summaries (`GITHUB_STEP_SUMMARY`): `$ERMITE_DATA_DIR/ci/summaries`. Both are removed with their run.
87+- Annotations (`::notice`, `::warning`, `::error`) show on the run and job pages and in `ermite run view`.
88+- Retention: `ERMITE_CI_LOG_RETENTION` (default 90 days).
89+
90+## Unsupported features
91+
92+A workflow that uses a feature ermite does not support fails with `not supported yet: <feature>`, never silently.
A docs/cli.md +86 -0
@@ -0,0 +1,86 @@
1+# CLI
2+
3+The `ermite` binary also drives a running server over its API. Run `ermite <command> --help` for the details of each command.
4+
5+## Connect to a server
6+
7+| Setting | Environment | Config file key |
8+|---|---|---|
9+| server address | `ERMITE_URL` | `url` |
10+| token (optional for public reads) | `ERMITE_TOKEN` | `token.cmd`, a command that prints it |
11+
12+The environment wins over the config file.
13+
14+```sh
15+ermite config set url https://git.example.com
16+ermite config set token.cmd 'pass show ermite/token'
17+ermite config show [--json] # path and values
18+ermite config unset <key>
19+```
20+
21+The file holds no secret, only the command that fetches the token (`pass`, `gopass show -o`, `op read`, `secret-tool lookup`, …):
22+
23+```ini
24+# ~/.config/ermite/config
25+url = https://git.example.com
26+token.cmd = pass show ermite/token
27+```
28+
29+Location: `$XDG_CONFIG_HOME/ermite/config`, `~/Library/Application Support/ermite/config` on macOS, `%AppData%\ermite\config` on Windows. `ERMITE_CONFIG` names another file.
30+
31+A token sent over plain `http://` to a non-loopback host prints a warning.
32+
33+### How `token.cmd` runs
34+
35+- Through `sh -c` (`cmd /C` on Windows), at most once per run, only when a command needs the token.
36+- It gets your terminal when stdin is one, so pinentry or biometric prompts work.
37+- It must print the token alone on one line, under 4 KiB, within 60 seconds. On timeout or interrupt it is killed.
38+- A failure is reported in one line, without the command's output.
39+
40+It does not run:
41+
42+- when `ERMITE_TOKEN` is set;
43+- for `ermite version`, `ermite config` or shell completion;
44+- when `ERMITE_URL` points to another server than the file's `url` (the call is anonymous; set `ERMITE_TOKEN`);
45+- when the file or its directory is writable by other users, or owned by another user (not checked on Windows).
46+
47+## Commands
48+
49+```sh
50+ermite repo list | show <name> | create <name> [--description …] [--visibility public]
51+ermite repo edit <name> [--description …] [--visibility …] [--default-branch …] [--merge-modes …] [--failing-checks-block=false]
52+ermite repo delete <name> [--yes]
53+
54+ermite token create <label> [--preset cli|agent|ci|runner] [--scope …] [--repo …] [--branch-prefix …] [--expires 30d]
55+ermite token list | revoke <id>
56+
57+ermite mr create <repo> --source <branch> --title … | list <repo> | show <repo> <n> [--diff]
58+ermite mr comment <repo> <n> -m … [--path … --line … --side old|new] | resolve <repo> <n> <comment>
59+ermite mr merge <repo> <n> --head <id> [--squash] [--delete-branch] | close <repo> <n>
60+
61+ermite run list <repo> [--workflow …] [--branch …] [--status …]
62+ermite run view <repo> <id> [--attempt n] [--log [--job …]] [--summary]
63+ermite run watch <repo> <id> [--log] | cancel <repo> <id> | rerun <repo> <id> [--failed]
64+ermite run artifacts <repo> <id> | download <repo> <id> [--name N]… [--dir D]
65+ermite workflow run <file> --repo <repo> [--ref …] [-f name=value …]
66+ermite runner list | remove <id>
67+ermite secret set|list|delete … # CI secrets (needs ERMITE_SECRETS_KEY)
68+ermite variable set|get|list|delete …
69+ermite environment create|list|delete … --repo <repo>
70+
71+ermite web login # one-time sign-in link (60 s)
72+ermite web logout --all # sign every browser out
73+ermite web passkey list | remove <id>
74+ermite signing-key show # public key of squash commits
75+ermite oidc rotate-key
76+ermite watch [repo] [--json] # follow live events
77+ermite version # client and server versions
78+ermite completion bash|zsh|fish
79+```
80+
81+## Output
82+
83+- `--json` prints stable field names (listed in each `--help`) on `repo list|show|create|edit`, `token create|list`, `run list|view|cancel|rerun`, `runner list` and `version`.
84+- `watch --json` prints one object per line.
85+- Errors are one line on stderr (`ermite: …`) with exit status 1.
86+- Shell completion completes repository names from the server.
@@ -0,0 +1,86 @@
1+# CLI
2+
3+The `ermite` binary also drives a running server over its API. Run `ermite <command> --help` for the details of each command.
4+
5+## Connect to a server
6+
7+| Setting | Environment | Config file key |
8+|---|---|---|
9+| server address | `ERMITE_URL` | `url` |
10+| token (optional for public reads) | `ERMITE_TOKEN` | `token.cmd`, a command that prints it |
11+
12+The environment wins over the config file.
13+
14+```sh
15+ermite config set url https://git.example.com
16+ermite config set token.cmd 'pass show ermite/token'
17+ermite config show [--json] # path and values
18+ermite config unset <key>
19+```
20+
21+The file holds no secret, only the command that fetches the token (`pass`, `gopass show -o`, `op read`, `secret-tool lookup`, …):
22+
23+```ini
24+# ~/.config/ermite/config
25+url = https://git.example.com
26+token.cmd = pass show ermite/token
27+```
28+
29+Location: `$XDG_CONFIG_HOME/ermite/config`, `~/Library/Application Support/ermite/config` on macOS, `%AppData%\ermite\config` on Windows. `ERMITE_CONFIG` names another file.
30+
31+A token sent over plain `http://` to a non-loopback host prints a warning.
32+
33+### How `token.cmd` runs
34+
35+- Through `sh -c` (`cmd /C` on Windows), at most once per run, only when a command needs the token.
36+- It gets your terminal when stdin is one, so pinentry or biometric prompts work.
37+- It must print the token alone on one line, under 4 KiB, within 60 seconds. On timeout or interrupt it is killed.
38+- A failure is reported in one line, without the command's output.
39+
40+It does not run:
41+
42+- when `ERMITE_TOKEN` is set;
43+- for `ermite version`, `ermite config` or shell completion;
44+- when `ERMITE_URL` points to another server than the file's `url` (the call is anonymous; set `ERMITE_TOKEN`);
45+- when the file or its directory is writable by other users, or owned by another user (not checked on Windows).
46+
47+## Commands
48+
49+```sh
50+ermite repo list | show <name> | create <name> [--description …] [--visibility public]
51+ermite repo edit <name> [--description …] [--visibility …] [--default-branch …] [--merge-modes …] [--failing-checks-block=false]
52+ermite repo delete <name> [--yes]
53+
54+ermite token create <label> [--preset cli|agent|ci|runner] [--scope …] [--repo …] [--branch-prefix …] [--expires 30d]
55+ermite token list | revoke <id>
56+
57+ermite mr create <repo> --source <branch> --title … | list <repo> | show <repo> <n> [--diff]
58+ermite mr comment <repo> <n> -m … [--path … --line … --side old|new] | resolve <repo> <n> <comment>
59+ermite mr merge <repo> <n> --head <id> [--squash] [--delete-branch] | close <repo> <n>
60+
61+ermite run list <repo> [--workflow …] [--branch …] [--status …]
62+ermite run view <repo> <id> [--attempt n] [--log [--job …]] [--summary]
63+ermite run watch <repo> <id> [--log] | cancel <repo> <id> | rerun <repo> <id> [--failed]
64+ermite run artifacts <repo> <id> | download <repo> <id> [--name N]… [--dir D]
65+ermite workflow run <file> --repo <repo> [--ref …] [-f name=value …]
66+ermite runner list | remove <id>
67+ermite secret set|list|delete … # CI secrets (needs ERMITE_SECRETS_KEY)
68+ermite variable set|get|list|delete …
69+ermite environment create|list|delete … --repo <repo>
70+
71+ermite web login # one-time sign-in link (60 s)
72+ermite web logout --all # sign every browser out
73+ermite web passkey list | remove <id>
74+ermite signing-key show # public key of squash commits
75+ermite oidc rotate-key
76+ermite watch [repo] [--json] # follow live events
77+ermite version # client and server versions
78+ermite completion bash|zsh|fish
79+```
80+
81+## Output
82+
83+- `--json` prints stable field names (listed in each `--help`) on `repo list|show|create|edit`, `token create|list`, `run list|view|cancel|rerun`, `runner list` and `version`.
84+- `watch --json` prints one object per line.
85+- Errors are one line on stderr (`ermite: …`) with exit status 1.
86+- Shell completion completes repository names from the server.
A docs/configuration.md +69 -0
@@ -0,0 +1,69 @@
1+# Configuration
2+
3+`ermite serve` reads its settings from the environment.
4+
5+## Required
6+
7+| Variable | Notes |
8+|---|---|
9+| `ERMITE_DATA_DIR` | absolute path of the data root |
10+| `ERMITE_PUBLIC_URL` | `http(s)` base URL, no path |
11+| `ERMITE_ADMIN_TOKEN` | at least 32 characters; the root credential |
12+
13+```sh
14+export ERMITE_DATA_DIR=/var/lib/ermite
15+export ERMITE_PUBLIC_URL=https://git.example.com
16+export ERMITE_ADMIN_TOKEN=$(head -c 32 /dev/urandom | base64)
17+ermite serve
18+```
19+
20+## Server
21+
22+| Variable | Default | Notes |
23+|---|---|---|
24+| `ERMITE_ADDR` | `:7341` | listen address |
25+| `ERMITE_TRUSTED_PROXIES` | empty | comma-separated CIDRs of your TLS proxy, so login rate limits see the real client (`X-Forwarded-For`) |
26+| `ERMITE_OWNER` | `owner` | owner login: web header, passkey name, and `<owner>` in CI (`github.repository`). Same rules as repository names |
27+| `ERMITE_SOURCE_URL` | `$ERMITE_PUBLIC_URL/ermite` | link to the source (AGPL) |
28+| `ERMITE_LOG_LEVEL` | `info` | `trace`, `debug`, `info`, `warn`, `error` |
29+| `ERMITE_LOG_FORMAT` | `json` | `json` or `console` |
30+
31+## Git
32+
33+| Variable | Default | Notes |
34+|---|---|---|
35+| `ERMITE_GIT` | `git` | git 2.45 or newer |
36+| `ERMITE_GIT_READER` | `ermite-git` | `ermite-git` reads with the [ermite-git](https://github.com/bnema/ermite-git) library where it is faster, and falls back to `git`. `cli` uses `git` for every read: set it and restart if a read looks wrong |
37+| `ERMITE_COMMITTER_NAME` | `ermite` | committer of squash merges |
38+| `ERMITE_COMMITTER_EMAIL` | `ermite@<public host>` | committer email of squash merges |
39+
40+## CI
41+
42+| Variable | Default | Notes |
43+|---|---|---|
44+| `ERMITE_SECRETS_KEY` | empty | 32 random bytes in base64. Encrypts CI secrets and the OIDC key. See below |
45+| `ERMITE_CI_LOG_RETENTION` | `90d` | how long runs, jobs, checks and logs are kept. `Nd` or a Go duration (`720h`), at least `1h` |
46+| `ERMITE_CI_POLL_TIMEOUT` | `50s` | how long a runner's `FetchJob` call is held when no job is queued, `1s` to `55s`. Keep it below your proxy's response timeout (`25s` behind a proxy that cuts at 30 s) |
47+| `ERMITE_CI_ARTIFACTS` | `1` | `0` turns artifacts off |
48+| `ERMITE_CI_ARTIFACT_RETENTION` | `90d` | default and maximum `retention-days`, at least one day |
49+| `ERMITE_CI_ARTIFACT_QUOTA` | `10GiB` | artifact space per repository, uploads in progress included. Bytes or `KiB`, `MiB`, `GiB`, `TiB` |
50+| `ERMITE_CI_ARTIFACT_MAX` | `5GiB` | size of one artifact |
51+
52+### `ERMITE_SECRETS_KEY`
53+
54+```sh
55+head -c 32 /dev/urandom | base64
56+```
57+
58+- Store it like the admin token.
59+- Without it, `ermite secret set` fails, jobs get no secrets and OIDC is off. Variables and environments still work.
60+- The server refuses to start with a different key than the one the secrets were sealed with.
61+- Losing the key loses every secret. A backup without the key reveals none.
62+
63+## Runtime files
64+
65+The hook socket and scripts git calls on each push live in `$ERMITE_DATA_DIR/run/`. The socket path must stay under about 100 bytes, so keep `ERMITE_DATA_DIR` short.
66+
67+## Storage schema
68+
69+This build uses storage schema 3. Once it has run on your data, an older ermite refuses to start. To downgrade, restore the backup taken before the upgrade.
@@ -0,0 +1,69 @@
1+# Configuration
2+
3+`ermite serve` reads its settings from the environment.
4+
5+## Required
6+
7+| Variable | Notes |
8+|---|---|
9+| `ERMITE_DATA_DIR` | absolute path of the data root |
10+| `ERMITE_PUBLIC_URL` | `http(s)` base URL, no path |
11+| `ERMITE_ADMIN_TOKEN` | at least 32 characters; the root credential |
12+
13+```sh
14+export ERMITE_DATA_DIR=/var/lib/ermite
15+export ERMITE_PUBLIC_URL=https://git.example.com
16+export ERMITE_ADMIN_TOKEN=$(head -c 32 /dev/urandom | base64)
17+ermite serve
18+```
19+
20+## Server
21+
22+| Variable | Default | Notes |
23+|---|---|---|
24+| `ERMITE_ADDR` | `:7341` | listen address |
25+| `ERMITE_TRUSTED_PROXIES` | empty | comma-separated CIDRs of your TLS proxy, so login rate limits see the real client (`X-Forwarded-For`) |
26+| `ERMITE_OWNER` | `owner` | owner login: web header, passkey name, and `<owner>` in CI (`github.repository`). Same rules as repository names |
27+| `ERMITE_SOURCE_URL` | `$ERMITE_PUBLIC_URL/ermite` | link to the source (AGPL) |
28+| `ERMITE_LOG_LEVEL` | `info` | `trace`, `debug`, `info`, `warn`, `error` |
29+| `ERMITE_LOG_FORMAT` | `json` | `json` or `console` |
30+
31+## Git
32+
33+| Variable | Default | Notes |
34+|---|---|---|
35+| `ERMITE_GIT` | `git` | git 2.45 or newer |
36+| `ERMITE_GIT_READER` | `ermite-git` | `ermite-git` reads with the [ermite-git](https://github.com/bnema/ermite-git) library where it is faster, and falls back to `git`. `cli` uses `git` for every read: set it and restart if a read looks wrong |
37+| `ERMITE_COMMITTER_NAME` | `ermite` | committer of squash merges |
38+| `ERMITE_COMMITTER_EMAIL` | `ermite@<public host>` | committer email of squash merges |
39+
40+## CI
41+
42+| Variable | Default | Notes |
43+|---|---|---|
44+| `ERMITE_SECRETS_KEY` | empty | 32 random bytes in base64. Encrypts CI secrets and the OIDC key. See below |
45+| `ERMITE_CI_LOG_RETENTION` | `90d` | how long runs, jobs, checks and logs are kept. `Nd` or a Go duration (`720h`), at least `1h` |
46+| `ERMITE_CI_POLL_TIMEOUT` | `50s` | how long a runner's `FetchJob` call is held when no job is queued, `1s` to `55s`. Keep it below your proxy's response timeout (`25s` behind a proxy that cuts at 30 s) |
47+| `ERMITE_CI_ARTIFACTS` | `1` | `0` turns artifacts off |
48+| `ERMITE_CI_ARTIFACT_RETENTION` | `90d` | default and maximum `retention-days`, at least one day |
49+| `ERMITE_CI_ARTIFACT_QUOTA` | `10GiB` | artifact space per repository, uploads in progress included. Bytes or `KiB`, `MiB`, `GiB`, `TiB` |
50+| `ERMITE_CI_ARTIFACT_MAX` | `5GiB` | size of one artifact |
51+
52+### `ERMITE_SECRETS_KEY`
53+
54+```sh
55+head -c 32 /dev/urandom | base64
56+```
57+
58+- Store it like the admin token.
59+- Without it, `ermite secret set` fails, jobs get no secrets and OIDC is off. Variables and environments still work.
60+- The server refuses to start with a different key than the one the secrets were sealed with.
61+- Losing the key loses every secret. A backup without the key reveals none.
62+
63+## Runtime files
64+
65+The hook socket and scripts git calls on each push live in `$ERMITE_DATA_DIR/run/`. The socket path must stay under about 100 bytes, so keep `ERMITE_DATA_DIR` short.
66+
67+## Storage schema
68+
69+This build uses storage schema 3. Once it has run on your data, an older ermite refuses to start. To downgrade, restore the backup taken before the upgrade.
A docs/development.md +56 -0
@@ -0,0 +1,56 @@
1+# Development
2+
3+## Build and test
4+
5+```sh
6+go build ./cmd/ermite
7+go test ./...
8+```
9+
10+`go test ./...` includes the end-to-end tests of `internal/app` (a real server per test). Run them before opening a pull request: CI runs them on `main` only.
11+
12+Stamp the version with `-ldflags "-X bnema.dev/ermite/internal/app.stamped=<version>"`.
13+
14+## Dev server
15+
16+```sh
17+docker compose up # http://localhost:7341
18+docker compose down -v # also removes data and caches
19+```
20+
21+The source is mounted and [air](https://github.com/air-verse/air) rebuilds on each change to `.go`, `.templ` or web assets. The admin token is in `compose.yaml`. The container runs as root, which maps to your user under rootless Docker.
22+
23+## Docker image
24+
25+```sh
26+docker build --build-arg VERSION="$(git describe --tags --always --dirty)" -t ermite .
27+```
28+
29+Alpine with git, runs as uid 10001, data in `/data`. Behind a private route, `--build-arg GIT_REWRITE_BASE=<url>` rewrites the git host of the `bnema.dev/ermite-*` modules.
30+
31+## Tools
32+
33+Pinned in `go.mod`:
34+
35+```sh
36+go tool buf lint
37+go tool buf generate # proto/ -> gen/
38+go generate ./... # mockery mocks of the ports
39+go tool hexcheck -fail-on warn ./...
40+```
41+
42+## Architecture
43+
44+```
45+cmd/ermite → internal/app → internal/usecase → internal/ports → internal/domain
46+ ↘ internal/adapters (Git, KV, HTTP, API, web, client)
47+```
48+
49+- `internal/app`: CLI and composition root.
50+- `internal/usecase` imports only `domain` and `ports`.
51+- Adapters import `domain`, `ports` and `internal/clientlimit`; never a use case, `app` or another adapter.
52+- The web adapter calls use cases through `ports`, never the generated API code.
53+- `internal/ports` imports only `domain`; `domain` imports nothing from the module.
54+- `internal/config` is used by `internal/app` only.
55+
56+`internal/boundary/boundary_test.go` enforces the imports; `hexcheck` (`.hexcheck.yaml`) checks the layering.
@@ -0,0 +1,56 @@
1+# Development
2+
3+## Build and test
4+
5+```sh
6+go build ./cmd/ermite
7+go test ./...
8+```
9+
10+`go test ./...` includes the end-to-end tests of `internal/app` (a real server per test). Run them before opening a pull request: CI runs them on `main` only.
11+
12+Stamp the version with `-ldflags "-X bnema.dev/ermite/internal/app.stamped=<version>"`.
13+
14+## Dev server
15+
16+```sh
17+docker compose up # http://localhost:7341
18+docker compose down -v # also removes data and caches
19+```
20+
21+The source is mounted and [air](https://github.com/air-verse/air) rebuilds on each change to `.go`, `.templ` or web assets. The admin token is in `compose.yaml`. The container runs as root, which maps to your user under rootless Docker.
22+
23+## Docker image
24+
25+```sh
26+docker build --build-arg VERSION="$(git describe --tags --always --dirty)" -t ermite .
27+```
28+
29+Alpine with git, runs as uid 10001, data in `/data`. Behind a private route, `--build-arg GIT_REWRITE_BASE=<url>` rewrites the git host of the `bnema.dev/ermite-*` modules.
30+
31+## Tools
32+
33+Pinned in `go.mod`:
34+
35+```sh
36+go tool buf lint
37+go tool buf generate # proto/ -> gen/
38+go generate ./... # mockery mocks of the ports
39+go tool hexcheck -fail-on warn ./...
40+```
41+
42+## Architecture
43+
44+```
45+cmd/ermite → internal/app → internal/usecase → internal/ports → internal/domain
46+ ↘ internal/adapters (Git, KV, HTTP, API, web, client)
47+```
48+
49+- `internal/app`: CLI and composition root.
50+- `internal/usecase` imports only `domain` and `ports`.
51+- Adapters import `domain`, `ports` and `internal/clientlimit`; never a use case, `app` or another adapter.
52+- The web adapter calls use cases through `ports`, never the generated API code.
53+- `internal/ports` imports only `domain`; `domain` imports nothing from the module.
54+- `internal/config` is used by `internal/app` only.
55+
56+`internal/boundary/boundary_test.go` enforces the imports; `hexcheck` (`.hexcheck.yaml`) checks the layering.
A docs/git-http.md +13 -0
@@ -0,0 +1,13 @@
1+# Git over HTTP
2+
3+```sh
4+git clone https://git.example.com/<repo>.git # public: no credentials
5+git push https://x:<token>@git.example.com/<repo>.git main
6+```
7+
8+- The token is the password; the username is ignored. `Authorization: Bearer <token>` and `token <token>` work too.
9+- Use the admin token or a stored token. Keep it in a credential helper, not in the URL.
10+- Pushing to a new name creates a private repository.
11+- Repository names are lowercase `[a-z0-9._-]`.
12+- GitHub-shaped URLs work: `https://git.example.com/<owner>/<repo>` with `<owner>` = `ERMITE_OWNER`, `.git` optional, case-insensitive. Any other owner is a 404.
13+- TLS is terminated by your proxy. List its network in `ERMITE_TRUSTED_PROXIES`.
@@ -0,0 +1,13 @@
1+# Git over HTTP
2+
3+```sh
4+git clone https://git.example.com/<repo>.git # public: no credentials
5+git push https://x:<token>@git.example.com/<repo>.git main
6+```
7+
8+- The token is the password; the username is ignored. `Authorization: Bearer <token>` and `token <token>` work too.
9+- Use the admin token or a stored token. Keep it in a credential helper, not in the URL.
10+- Pushing to a new name creates a private repository.
11+- Repository names are lowercase `[a-z0-9._-]`.
12+- GitHub-shaped URLs work: `https://git.example.com/<owner>/<repo>` with `<owner>` = `ERMITE_OWNER`, `.git` optional, case-insensitive. Any other owner is a 404.
13+- TLS is terminated by your proxy. List its network in `ERMITE_TRUSTED_PROXIES`.
A docs/merge-requests.md +22 -0
@@ -0,0 +1,22 @@
1+# Merge requests
2+
3+A merge request proposes a branch for a target (the default branch unless set).
4+
5+```sh
6+ermite mr create myrepo --source feature --title "Add feature"
7+ermite mr show myrepo 1 --diff
8+ermite mr merge myrepo 1 --head <commit> [--squash] [--delete-branch]
9+```
10+
11+- **Fast-forward** moves the target. **Squash** writes one commit signed by the server's SSH key.
12+- The merge is refused when the branch moved since the reviewed `--head`, or when the target is not an ancestor of it.
13+- With `failing_checks_block` on (default), the merge is refused while a check of the head failed or is still running.
14+- `--delete-branch` deletes the source branch, unless it is the default branch or another open request uses it.
15+- `ermite repo edit --merge-modes` limits the modes per repository.
16+- Scopes: `mr:write` to open and comment, `mr:merge` to merge.
17+
18+Verify squash commits with the `allowed_signers` line from `ermite signing-key show`:
19+
20+```sh
21+git verify-commit <commit>
22+```
@@ -0,0 +1,22 @@
1+# Merge requests
2+
3+A merge request proposes a branch for a target (the default branch unless set).
4+
5+```sh
6+ermite mr create myrepo --source feature --title "Add feature"
7+ermite mr show myrepo 1 --diff
8+ermite mr merge myrepo 1 --head <commit> [--squash] [--delete-branch]
9+```
10+
11+- **Fast-forward** moves the target. **Squash** writes one commit signed by the server's SSH key.
12+- The merge is refused when the branch moved since the reviewed `--head`, or when the target is not an ancestor of it.
13+- With `failing_checks_block` on (default), the merge is refused while a check of the head failed or is still running.
14+- `--delete-branch` deletes the source branch, unless it is the default branch or another open request uses it.
15+- `ermite repo edit --merge-modes` limits the modes per repository.
16+- Scopes: `mr:write` to open and comment, `mr:merge` to merge.
17+
18+Verify squash commits with the `allowed_signers` line from `ermite signing-key show`:
19+
20+```sh
21+git verify-commit <commit>
22+```
A docs/tokens.md +44 -0
@@ -0,0 +1,44 @@
1+# Tokens
2+
3+```sh
4+ermite token create <label> [--preset …] [--scope …] [--repo …] [--branch-prefix …] [--expires 30d]
5+ermite token list
6+ermite token revoke <id>
7+```
8+
9+The secret is printed once. Presets and `--scope` add up. The full scope list is in [`skills/ermite/CLI.md`](../skills/ermite/CLI.md).
10+
11+| Preset | For |
12+|---|---|
13+| `cli` | every scope, the owner's daily use |
14+| `agent` | `repo:write`, `mr:write` |
15+| `ci` | `repo:read` |
16+| `runner` | `runner:register`: a runner host. It can read the code and secrets of every repository, so give it only to runners you operate |
17+
18+## Branch-limited tokens
19+
20+For an AI agent that should propose changes, not publish them:
21+
22+```sh
23+ermite token create claude --preset agent --scope repo:read --repo myrepo --branch-prefix agent/claude/
24+```
25+
26+`--branch-prefix` is repeatable (up to 8), each ending with `/`.
27+
28+The token **can**:
29+
30+- push, force-push and delete branches under its prefixes;
31+- open, comment on and close merge requests;
32+- with `mr:merge`, merge into a branch under its prefixes;
33+- read CI runs, logs and checks; with `ci:write`, cancel and re-run runs of its own branches;
34+- create tokens whose prefixes lie within its own.
35+
36+The token **cannot**:
37+
38+- push any other ref (`main`, other branches, tags, notes). The whole push is refused and the message names the ref;
39+- create, delete or configure a repository;
40+- open web sessions or get owner rights, even with every scope;
41+- manage runners, even with `runner:register`;
42+- get trusted CI runs (no secrets). See [CI trust](ci.md#trust).
43+
44+`ermite token list` shows a `branches` column (`all` when unlimited); `--json` has `branch_prefixes`.
@@ -0,0 +1,44 @@
1+# Tokens
2+
3+```sh
4+ermite token create <label> [--preset …] [--scope …] [--repo …] [--branch-prefix …] [--expires 30d]
5+ermite token list
6+ermite token revoke <id>
7+```
8+
9+The secret is printed once. Presets and `--scope` add up. The full scope list is in [`skills/ermite/CLI.md`](../skills/ermite/CLI.md).
10+
11+| Preset | For |
12+|---|---|
13+| `cli` | every scope, the owner's daily use |
14+| `agent` | `repo:write`, `mr:write` |
15+| `ci` | `repo:read` |
16+| `runner` | `runner:register`: a runner host. It can read the code and secrets of every repository, so give it only to runners you operate |
17+
18+## Branch-limited tokens
19+
20+For an AI agent that should propose changes, not publish them:
21+
22+```sh
23+ermite token create claude --preset agent --scope repo:read --repo myrepo --branch-prefix agent/claude/
24+```
25+
26+`--branch-prefix` is repeatable (up to 8), each ending with `/`.
27+
28+The token **can**:
29+
30+- push, force-push and delete branches under its prefixes;
31+- open, comment on and close merge requests;
32+- with `mr:merge`, merge into a branch under its prefixes;
33+- read CI runs, logs and checks; with `ci:write`, cancel and re-run runs of its own branches;
34+- create tokens whose prefixes lie within its own.
35+
36+The token **cannot**:
37+
38+- push any other ref (`main`, other branches, tags, notes). The whole push is refused and the message names the ref;
39+- create, delete or configure a repository;
40+- open web sessions or get owner rights, even with every scope;
41+- manage runners, even with `runner:register`;
42+- get trusted CI runs (no secrets). See [CI trust](ci.md#trust).
43+
44+`ermite token list` shows a `branches` column (`all` when unlimited); `--json` has `branch_prefixes`.
A docs/web-sign-in.md +24 -0
@@ -0,0 +1,24 @@
1+# Web sign-in
2+
3+Visitors read public repositories. The owner signs in to see private ones, review and merge. A session lasts 12 hours.
4+
5+## Two ways in
6+
7+- **One-time link**: `ermite web login` (admin token or a token with every scope) prints a link valid once, for 60 seconds. Use it the first time.
8+- **Passkey**: press "Sign in with a passkey" on `/-/login`. Add one from `/-/passkeys` while signed in. Up to 20.
9+
10+The cookie is `Secure`: plain `http` works only on localhost.
11+
12+## Passkey requirements
13+
14+`ERMITE_PUBLIC_URL` must be `https` (or `http://localhost`) with a **host name, not an IP**. A passkey only works on the name it was registered for. On `http://127.0.0.1:…` passkeys are off (the log says `passkeys disabled`).
15+
16+## Manage sessions
17+
18+```sh
19+ermite web passkey list
20+ermite web passkey remove <id>
21+ermite web logout --all
22+```
23+
24+Removing a passkey, `logout --all`, or revoking a token with every scope signs out every device. If you lose every device, `ermite web login` still works.
@@ -0,0 +1,24 @@
1+# Web sign-in
2+
3+Visitors read public repositories. The owner signs in to see private ones, review and merge. A session lasts 12 hours.
4+
5+## Two ways in
6+
7+- **One-time link**: `ermite web login` (admin token or a token with every scope) prints a link valid once, for 60 seconds. Use it the first time.
8+- **Passkey**: press "Sign in with a passkey" on `/-/login`. Add one from `/-/passkeys` while signed in. Up to 20.
9+
10+The cookie is `Secure`: plain `http` works only on localhost.
11+
12+## Passkey requirements
13+
14+`ERMITE_PUBLIC_URL` must be `https` (or `http://localhost`) with a **host name, not an IP**. A passkey only works on the name it was registered for. On `http://127.0.0.1:…` passkeys are off (the log says `passkeys disabled`).
15+
16+## Manage sessions
17+
18+```sh
19+ermite web passkey list
20+ermite web passkey remove <id>
21+ermite web logout --all
22+```
23+
24+Removing a passkey, `logout --all`, or revoking a token with every scope signs out every device. If you lose every device, `ermite web login` still works.

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 41

↑↓ moveenter openesc close