mesh/merge-gate head 661b83249c2a merged into trunk bb291b5ad8af — pass: builds gitea, ssh-client → ace, g14, novox, shanks; no bus step; every machine com…
mesh/repo-check head 661b83249c2a merged into trunk bb291b5ad8af — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
236 lines
19 KiB
Markdown
236 lines
19 KiB
Markdown
# gitea
|
|
|
|
The forge, and the mesh's npm package registry: it claims the `git` and `npm-package-registry` seats.
|
|
Its tools, its events and the provisioner of registry accounts are the TypeScript bundle (`index.ts`,
|
|
`tools/`, `provisioner/`). This README covers the two Go bundles beside it.
|
|
|
|
**What a registry consumer is told** (`serves.npm-package-registry`): where the registry answers on the
|
|
private network (`scheme`, `port`, `npm-path`), and its `public-root`, the address the forge writes every
|
|
tarball at: `https://` and the forge's route's composed public name, under the npm path
|
|
(`${bound:route:name-web}`, novox/hq ADR 0346). A lock file holds that address, and npm 12 installs a
|
|
locked tarball only from the registry it is configured with, so a consumer that installs from lock files
|
|
trusts that root. On a machine with no public domain the key is left out, and the plan says so.
|
|
|
|
## Announcing pull requests to the merge check (novox/hq to-be 45 §9, issue 347)
|
|
|
|
Every new head of an open pull request is announced as `pull.updated`, once, and the controller has it checked.
|
|
|
|
- **Which repositories a pass looks at.** Each that moved since the last pass, less a minute's margin (issue 250);
|
|
each holding a pull request updated since then, found by the forge's own search — opening, reopening or
|
|
retargeting a pull request moves its own update time and not its repository's, and a pull request opened more
|
|
than a minute after its push was never announced (issue 347); and every repository on a sweep, every ten
|
|
minutes, or when the search fails.
|
|
- **A pull request nobody checks is said — by the delivery's owner, not here.** `gitea_open_pulls` answers every
|
|
open pull request from the forge itself, with the statuses its base requires and the ones its head has. The
|
|
`mesh-delivery` module asks it every five minutes, and a head on a base requiring `mesh/merge-gate` or
|
|
`mesh/repo-check` with neither for ten minutes since it first saw it so is a delivery held past its bound
|
|
(`delivery.<id>.stalled`, state `unannounced`), which the controller raises like any other (issue 347).
|
|
Each of the head's statuses comes with its state and when it was set, so a head whose check started and never
|
|
answered — a required check pending, or never set beside one that is — is said as a stalled line in the state
|
|
`unanswered` once that has lasted the delivery table's bound for a check asked and not answered, an hour, counted
|
|
from its first sight and again only when a check is asked anew (issue 438).
|
|
- **How to see it work.** A pull request opened more than a minute after its branch was pushed — this fix's own
|
|
was one, and the announcer it fixes never saw it — has `mesh/merge-gate` pending within a pass of the next sweep
|
|
at the latest, and `mesh-controller.conditions` names none for it.
|
|
- **A pull request or issue number that is not one is refused** before the forge is asked: a tool called with a
|
|
misnamed argument sent `pulls/NaN`.
|
|
|
|
## An agent's commit and the reads around it (novox/hq issue 375)
|
|
|
|
An agent that finished a change on a branch had no way to put it on the forge: the module had no commit or
|
|
push tool, `gitea_api` refused the forge's patch endpoint, and its only file write took one whole file
|
|
base64-encoded. The Go bundle `cmd/forge` serves the commit, and the reads an agent otherwise made through
|
|
`gitea_api` as raw JSON, summarised.
|
|
|
|
| tool | | what |
|
|
|---|---|---|
|
|
| `gitea_commit` | a | one commit on a branch of its own, created from `base` (default the default branch) when absent, carrying either `files` (each `{path, content \| content_base64 \| delete: true}`) `patch` (a unified diff, as `git diff` writes it), or `patch_file` with `patch_sha256` (a patch too large to type, see below); answers the commit and its parent |
|
|
| `gitea_commits` | r | a ref's history, newest first, optionally only commits touching a path: sha, author, date, subject |
|
|
| `gitea_compare` | r | what head holds that base does not: its commits and the files they touch |
|
|
| `gitea_pull_reviews` | r | a pull request's reviews — state, the commit reviewed and whether it is the head now, stale, dismissed — with their line comments |
|
|
| `gitea_list_dir` | r | a directory's entries at a ref: name, type, a file's size |
|
|
| `gitea_search_code` | r | the lines of a ref's text files matching a literal string or a regular expression, optionally under a path |
|
|
|
|
**Where a commit may go** is the rule `gitea_api` already holds for a file written (novox/hq ADR 0266):
|
|
never the repository's default branch, never a branch a protection rule names. The tools' account may not read the
|
|
rules themselves (the forge keeps them for a repository's administrator, novox/hq ADR 0365): for it an existing branch
|
|
is judged by the protection the forge says it applies there, and a branch not made yet by the forge itself, which
|
|
refuses the commit that would make a branch a rule guards. A rule covers a branch as the
|
|
forge itself decides it (Gitea 1.27, novox/hq issue 539): a plain name covers that branch in any case; a glob
|
|
(`*`, `**`, `?`, `[abc]`, `[!abc]`, `{a,b}`, `\` escaping) covers case-sensitively with `/` as separator; a glob
|
|
the forge cannot compile covers its own text. `cmd/forge/rules.go` and `guard.ts` hold this one rule. The rules are read before anything is sent; rules that cannot be read refuse the
|
|
commit. Nothing about merging changes: a merge still needs an independent review at the head and both
|
|
required checks green, through `gitea_merge_pull_request`.
|
|
|
|
**A patch too large to type** (novox/hq issue 852: a feature's diff is megabytes) goes as `patch_file`, with
|
|
`patch_sha256`. The caller writes the patch into the module's spool directory `forge-patches`
|
|
(`/var/lib/mesh/forge-patches`, mode 0733 and owned by the machine's account, which the tools run as: anyone writes,
|
|
nobody lists; the bundle is given its path as `MESH_GITEA_PATCH_DIR`) and names it: `patch_file` is the file's NAME,
|
|
32 to 64 characters of `0-9a-f` that the caller chooses at random, never a path. The tool then reads that one file,
|
|
applies it exactly as it applies `patch` (same size limit, same answer) and removes it once it has found it, whether
|
|
the commit was made or not. A file refused before that (a bad name, a missing hash or spool directory, a name with no
|
|
file) stays in the spool. It refuses, sending nothing, a name of another shape, a file that is missing, empty, over
|
|
4 MiB, with more than one hard link or not a regular file (a symlink is never followed), and a file whose SHA-256 is not `patch_sha256`: the directory has no sticky bit
|
|
(the manifest allows none), so another writer could replace a file, and the hash is how the caller knows the tool read
|
|
the bytes it wrote. It reads nothing outside the directory.
|
|
|
|
**How the commit is made.** `files` become one call to the forge's multi-file contents endpoint: a path the
|
|
ref holds is updated, one it does not is created, and an update or delete carries the blob read just
|
|
before, so a file someone changed meanwhile is refused by the forge rather than overwritten. The blobs are read at
|
|
the commit the branch (or, for a new branch, its base) was read at, never at its name. A `patch` goes to the
|
|
forge's diff endpoint, which applies it; for a small change to a large file this is far shorter than the
|
|
file. Neither endpoint takes a commit to build on, only a branch (Gitea 1.27), so the parent answered is the
|
|
one the forge says it made the commit on; when the branch moved between the read and the commit, the answer
|
|
says so. One commit carries at most 200 files, 4 MiB per file and 16 MiB together, or a patch of 4 MiB. Either way the commit is the forge's own, made as the tools' account, so its sha is not the
|
|
local commit's: the merge check judges the head on the forge. After the write the branch is read back, and a
|
|
branch not at the commit the forge named is a failure, never a success.
|
|
|
|
**Code search** has no endpoint on the forge, so the ref's archive (`/archive/{ref}.tar.gz`, at most 64 MiB)
|
|
is read once and searched in memory; a binary file is passed over, a file over 4 MiB is named under
|
|
`skipped`, and at most `max` matches (default 100) are answered, with `truncated` when more were found. A
|
|
search that reaches the call's deadline stops there and answers what it found, with `stopped_at_deadline`.
|
|
|
|
It reaches the forge through the same HTTP client as the npm-registry bundle (`internal/forgehttp`), as the
|
|
tools' own account (novox/hq ADR 0365 §2, issue 469): the token the TypeScript bundle mints for `mesh-tools` and
|
|
keeps (`MESH_GITEA_TOKEN_FILE`), sent only when its scopes line names that account, never the admin login. One tool
|
|
call takes at most 50 seconds.
|
|
`go test ./cmd/forge` runs it against a fake forge: the files of one commit created, updated and deleted on
|
|
a new branch from its base; an existing branch committed on; the default branch, a protected branch and
|
|
unreadable rules refused before anything is sent; a patch sent to the forge; a branch read back at another
|
|
commit failing; a branch or base that moved under the commit said, with the parent the forge used; a file
|
|
changed under the commit refused; a commit over its size limits refused; protection globs matched as the
|
|
forge matches them; a code search stopped at its deadline; every malformed ask refused; and each read
|
|
summarised.
|
|
|
|
## Each agent's credential (novox/hq ADR 0365 §1, §3–§5, issue 737; ADR 0398 §1, issue 948)
|
|
|
|
An agent clones, fetches and pushes branches of the repositories as a forge account of its own, `agent-<node>`. The
|
|
module offers the provision `gitea-read`, whose credential its software issues (ADR 0305) one per holder (issue
|
|
749): the Go bundle `cmd/agent-token` serves `gitea_agent_token`, which the vault alone asks (ADR 0349 clause 10;
|
|
any other caller is refused and nothing is minted). For each holder the vault names that is the claude-code module
|
|
on a machine, it
|
|
|
|
- makes the account `agent-<node>` with the administrator's login, when there is none: no administrator, a random
|
|
password sent once and kept nowhere. It is added to the team `agents` of the organisation `novox`, which the
|
|
administrator's login makes when there is none and puts back to its shape when found changed: write on code, issues
|
|
and pull requests of every repository, no right to make repositories (the shape of the forge tools' team). The
|
|
operator's read teams (`agent-read-*`, ADR 0365 §3) are `cmd/agent-teams`'s and stay. An account found made an administrator, inactive or barred from logging in is
|
|
made a plain account again;
|
|
- deletes the account's token by the name `mesh-agent-read` (by its id, every one of them) and mints one whose
|
|
scopes are `write:repository` and `write:issue`: the forge refuses it any token or administration of its own
|
|
(§5), and the main branch of every repository takes no push from it (ADR 0398 §2). Every ask mints anew; that is its rotation;
|
|
- answers git's credential-store lines for it — the forge's public origin, read from `origins` (the route's name),
|
|
and on the forge's own machine its address there too — sealed to the key the vault made for the call. A holder of
|
|
another module is not answered and said under `skipped`.
|
|
|
|
The vault keeps each as `provision/<forge's node>/gitea-read/<node>/claude-code` and hands it to that holder alone;
|
|
the claude-code module places it in the agent's home (ADR 0365 §4). The token is never logged, answered in the clear
|
|
or put in an error. `go test ./cmd/agent-token` runs it against a fake forge.
|
|
|
|
## The agents' read teams (novox/hq ADR 0365 §3, issue 758)
|
|
|
|
An agent's account reads more than public repositories only through a team, per repository, and only to read. Which
|
|
teams exist and who is in them is the operator's, and it is one trusted config value, `agent-read-teams`:
|
|
`{"<team>": {"repos": ["<owner>/<repo>", ...], "members": ["<node>", ...]}}`, a member `<node>` being the account
|
|
`agent-<node>`. The module renders it into `agent-teams.json` and runs `cmd/agent-teams` when it starts and
|
|
whenever that file changes. For each team it makes `agent-read-<team>` in the organisation of the first repository's
|
|
owner, with permission read and no other right, its repositories exactly the listed ones and its members exactly the
|
|
listed accounts (an account that does not exist yet is skipped and said). A team named `agent-read-*` that has left
|
|
the value is emptied, never deleted. A team without that prefix is never touched. There are no verbs: a change is a
|
|
change of the value. It says each change in one line on stdout, and never a secret. `go test ./cmd/agent-teams`
|
|
runs it against a fake forge.
|
|
|
|
## The package registry's retention (novox/hq ADR 0251 §4, to-be 51)
|
|
|
|
Every publish adds a version to the registry, and until this nothing removed one. The Go bundle
|
|
`cmd/npm-registry` says which versions each package holds, what keeps each one, and — asked with a why —
|
|
deletes the versions nothing keeps.
|
|
|
|
| tool | | what |
|
|
|---|---|---|
|
|
| `npm_packages` | r | every package of the registry's owner, each version newest first with its publish time, its size and why it is kept |
|
|
| `npm_retention` | a | the versions retention would delete, and the bytes. **A dry run unless `dry_run` is false**; a real run needs `why` |
|
|
|
|
**A version is kept when:**
|
|
|
|
- a lockfile on the default branch of any repository on the forge names it — `package-lock.json`
|
|
(every lockfile version), `npm-shrinkwrap.json`, `yarn.lock` (classic and berry) and `pnpm-lock.yaml`
|
|
(by each resolved `name@version` or `name/version`), whatever the version's age;
|
|
- it is the highest published version satisfying a range that a `package.json` there names in its
|
|
dependencies, development, peer or optional dependencies (an `npm:` alias counts for the package it
|
|
stands for; a path, a URL, git or a workspace is not a range on this registry). npm itself installs
|
|
the `latest` dist-tag when it satisfies the range; that version is kept by its dist-tag;
|
|
- a dist-tag names it — and a range that *is* a dist-tag's name keeps that tag's version;
|
|
- it is among the newest `keep` of its package, five by default (the artifact store's five builds,
|
|
ADR 0189 §3), by semantic-version order. **Pre-releases count** among the newest.
|
|
|
|
A version that is not a semantic version is never ordered, so it is kept. A range that cannot be read,
|
|
or a package whose dist-tags cannot be read, keeps **every** version of its package, and the answer says
|
|
why under `keeps_all`.
|
|
|
|
**Nothing is deleted on partial knowledge.** A repository whose files could not be listed, or a
|
|
manifest or lockfile that could not be fetched, is named under `repositories_unread` in every answer,
|
|
and a real run then deletes nothing at all. A file that was fetched but is not readable (broken JSON) is
|
|
named under `files_not_read`, and so is a lockfile entry of a shape the reader does not know (the rest of
|
|
that file is still read); what it would have named is not known, so a real run then deletes nothing at all
|
|
either. Under `node_modules` nothing is read: that is what a lockfile already says. An empty
|
|
repository, or one without its default branch, holds nothing to read and is not a failure.
|
|
|
|
A real run deletes each version through the forge's own interface (`DELETE
|
|
/api/v1/packages/{owner}/npm/{name}/{version}`), says what it deleted and what the forge refused, and a
|
|
version already gone counts as deleted.
|
|
|
|
### How it reaches the forge
|
|
|
|
Over the forge's HTTP interface on the machine (`MESH_GITEA_URL`), as the admin account the vault
|
|
delivered (`MESH_GITEA_ADMIN_USER`, the password in `MESH_GITEA_ADMIN_PASSWORD_FILE` — the same file
|
|
the TypeScript bundle mints its token with), by basic authentication: this bundle mints no token and
|
|
keeps nothing. The password is read per call and never logged, answered or put in an error. The
|
|
registry's owner is `MESH_NPM_OWNER`, the owner in the seat's `npm-path`.
|
|
|
|
Calls to the forge run eight at a time, and one tool call reads for at most 50 seconds, inside a
|
|
module's 60-second ask.
|
|
|
|
### Why the manifest lists no `tools`
|
|
|
|
The TypeScript bundle's tools are served without a `tools` list, and a claim without `serves` offers
|
|
the module's `tools` as the seat's verbs — so a list here would make these two tools verbs of both the
|
|
`git` and the `npm-package-registry` seats. The two names are kept in `ToolNames`, and a test holds them
|
|
to this README and to what the bundle serves.
|
|
|
|
### Tests
|
|
|
|
```
|
|
go test ./...
|
|
```
|
|
|
|
Against a fake forge (`httptest`): what keeps a version (a lockfile whatever its age, the highest
|
|
version satisfying a range, a dist-tag, the newest `keep`); a dry run deleting nothing; a real run
|
|
without why refused; a real run deleting only what nothing keeps; an unread repository stopping a real
|
|
run before anything is deleted; an unreadable range keeping its whole package; a wrong password failing
|
|
without saying the password. And the range matcher against npm's rules — exact, `^`, `~`, `x` and `*`,
|
|
comparisons with partial versions, hyphen ranges, `||`, and the pre-release rule — and each lockfile
|
|
format.
|
|
|
|
## Main takes no push, and only the tools' account merges (novox/hq ADR 0398 §2, issue 948)
|
|
|
|
The rule for `main` is the same in every repository of the organisation `novox`: `enable_push` false, and a merge
|
|
whitelist of the tools' account (`mesh-tools`) alone. `protection.ts` composes it (`mainRuleBody`, and
|
|
`protectionBody` for the branch `main` always carries it); `setUpToolsAccount` puts it right in every repository
|
|
whenever the tools' account is set up, with the administrator's login (`GiteaAdmin.ensureMainMerges`) — the tools'
|
|
account is no repository's administrator, so no tool can. A rule found keeps the statuses it requires; a repository
|
|
with none is given one that requires none. `judgeProtectionChange` refuses, for `main`, a change that enables
|
|
push, drops the merge whitelist or widens it past that account.
|
|
|
|
## The forge account for git over ssh (novox/hq ADR 0399, ADR 0400, issue 955)
|
|
|
|
Gitea provides `forge-account`, and `cmd/forge-account-provider` answers its consumers (the `ssh-client` of each
|
|
machine). The provision has no credential: a public key is no secret. For a machine whose ssh-client has reported its
|
|
key in the state `keys` (read, `ssh-client.keys`), the provider makes the account `agent-<node>` in the agents team
|
|
(`internal/forgeaccount`, the code `cmd/agent-token` shares), registers the key under the title `mesh:<node>.<account>`,
|
|
and serves the entry values `user`, `ssh_host` (the forge machine's mesh name, from the fact `forge-host`), `ssh_port`, `ssh_alias` (`mesh-forge`, the forge's own name in ssh's configuration, ADR 0409),
|
|
`known_host` (read from the forge's own sshd with `ssh-keyscan`), `ssh_base` and `https_base`. A machine that has
|
|
reported no key gets no entry, and its client says it waits. Keys titled `mesh:` that are no longer wanted are removed,
|
|
a key added by hand never is. Every consumer is made again every ten minutes and at once when a key changes.
|
|
`go test ./cmd/forge-account-provider ./internal/forgeaccount` runs it against a fake forge.
|