Skip to content
quarry Artifact [ffae1ca618]
Login

Artifact [ffae1ca618]

Artifact ffae1ca6180a67f7a555beceba59ab87109148b795dfb70112e9753b63cb0774:


- Feature Name: `git-mirror-out` (a repository admin mirrors a public
  repository's trunk to GitHub or any Git host from its settings, pushed
  after each change, with a deploy key the forge makes and keeps)
- Start Date: 2026-10-15
- Status: Draft. Four decisions settled by the owner on 2026-10-15 (see
  [Decisions](#decisions)). Not built.
- Depends on: zit's `git export --autopush` (zit#220: export new
  check-ins into a Git repository and push trunk fast-forward only);
  RFC 0008 (the `a` letter); RFC 0021 (agents hold `a` under their
  operator's ceiling); the sealed-secret store (internal/oltp/secret.go)
- Replaces: the operator script `/usr/local/sbin/mirror-to-github` on
  the forge host and its ten-minute timer (pikchr-studio today)
- Ticket: #292. The inbound direction (a Git upstream into a quarry
  store, zit `import-git --into`) is #358 and is not this RFC.

## Summary
[summary]: #summary

A repository's settings gain a **Mirror to Git** section. A repository
admin gives a remote URL (an SSH URL such as
`[email protected]:owner/repo.git`) and a branch name (default `main`). On
enable the forge makes an ed25519 key pair for that repository, keeps
the private half sealed, and shows the public half to add to the host as
a deploy key with write access. After every change to trunk the forge
runs `zit git export` with that key, which exports the new check-ins and
pushes fast-forward only. The section shows the last pushed check-in,
when, and the last error in words. Turning the mirror off stops pushing
and discards the key. Only public repositories may be mirrored.

## Decisions
[decisions]: #decisions

Settled by the owner on 2026-10-15.

1. **Outbound only, now.** This RFC pushes quarry repositories out.
   Following a Git upstream in (#358) is separate work.
2. **Any repository admin may set it up.** The bar is `a`, the same as
   webhooks and environments, so an agent holding `a` (curator on quarry)
   may too.
3. **Public repositories only.** A private repository cannot be
   mirrored, and a repository that becomes private stops pushing (see
   [Private](#private)).
4. **Author email is zit's rule.** A commit carries the author's contact
   email from the repository's user table, else `[email protected]`. No
   new account setting; a person who wants the host to link their
   commits adds a contact email.

## The setting
[setting]: #the-setting

One row per repository: remote URL, push branch (default `main`),
enabled, the sealed private key, the public key, the last exported
check-in, when it was pushed, the last error and when. The remote URL is
validated as an SSH remote with no password in it (zit refuses a
password in an autopush URL, and an HTTPS URL would need a credential
this design does not keep). Every change is written to the repository's
settings log with its actor and via, as the other settings are.

The key is made on first enable with crypto/ed25519, the private half
sealed with the same sealer that holds sync secrets, and shown only as
its OpenSSH public line. It is never shown again in private form, never
logged, and never leaves the forge except as the file the push reads for
the length of one run (written 0600 in a per-run temporary directory and
removed after). Disabling the mirror deletes the key; enabling again
makes a new one, which the admin adds to the host again.

## When it pushes
[when]: #when

After trunk moves, not on a timer. The trigger is the store's own fold
noticing a new trunk tip, which covers a direct push, an accepted
landing and a merge the forge writes, with one path. A run is queued per
repository and coalesced: several trunk moves while a run is going make
one more run, not several. Only the node holding the repository's write
lease runs it, so a two-node forge never pushes twice.

A run is `zit git export MIRROR --autopush URL --push-branch BRANCH`
against a Git repository the forge keeps under the repository's data
directory, with `GIT_SSH_COMMAND` naming the per-run key file and
`IdentitiesOnly=yes`. zit exports what is new and pushes fast-forward
only.

A failure is recorded and retried with backoff (one minute, doubling to
an hour); it never blocks the forge or the repository. The last error is
put in words for the page: "the host refused the deploy key: check it is
added with write access", "the remote's main has history this
repository does not: it was not pushed", "the host could not be
reached".

## No force
[no-force]: #no-force

The forge never force-pushes. If the remote branch has history the
export did not write (a first enable onto a non-empty repository, or
someone pushed to the mirror directly), the run refuses and says so,
and the mirror stays enabled and keeps refusing until the remote is
reset by hand. There is no "replace the remote" control in this RFC.

## Private
[private]: #private

Enabling is refused on a private repository. A mirrored repository that
is made private stops pushing at once: the next run is not started, the
status says "the repository is private, so the mirror is paused", and
the key is kept so that making it public again resumes. Nothing already
pushed is withdrawn, and the page says that too.

## What the page shows
[page]: #page

The section shows the remote, the branch, on or off, the public key
with a copy control, and the status line: last pushed check-in (linked)
and when, or the last error and when. The repository's About shows "Mirrored to <host
path>" with a link while a mirror is on and current. No help-text
paragraphs; the error sentences carry what a person needs.

## Fossil remote
[fossil-remote]: #fossil-remote

A repository can also be pushed to a Fossil server, such as
`https://fossil.craftdesign.group/quarry`, over Fossil's sync protocol:
`repo.fossilmirror.get`, `.set` and `.off`, and a "Mirror to Fossil"
section on the settings page, beside the Git one. The row (`fossil_mirror`)
exists only while the mirror is on.

- **What is pushed.** Everything Fossil sync carries: every branch, wiki,
  tickets, forum and technotes, not only trunk. So a run never skips
  because trunk's tip is unchanged: it runs after every fold. The retry
  tick runs only a mirror that has never succeeded, or whose last attempt
  failed and whose backoff (a minute, doubling to an hour) has passed.
  `pushed` is trunk's tip at the last success, for display.
- **Everything in the store goes.** A Fossil push carries every
  artifact the store holds, including the earlier versions of wiki pages
  and documents that were redacted or moderated, and forum posts that
  were hidden or removed: things the forge would not show a visitor. The
  mirror's remote therefore sees more than the forge's pages do. Mirror
  only to a server whose readers may see all of it.
- **No force.** Fossil sync only adds artifacts and never discards one,
  so "never force" holds by construction; there is nothing to refuse
  and no divergence to report.
- **The credential.** The remote is `https` (plain `http` only to this
  machine, the forge's one outbound rule) with no user name, password,
  query or fragment in it, and a host that is not loopback, private or
  link-local unless the forge was started with `--allow-private-bridges`;
  checked again at each push. The login's password is sealed with the
  same seal as the deploy key, is read only by the push, and is given to
  `zit push` as `ZIT_PASSWORD` in its environment, never in its
  arguments. The push uses `--once`, so zit keeps no login and no remote
  in the store: a stored login would be sent to any other remote the
  store talks to, the inbound mirror's among them. Neither op nor page
  returns the password, and the settings log records only that it
  changed. It is required when the mirror is first set and when the
  remote or the user changes; a blank one otherwise keeps the stored one.
  Turning the mirror off deletes it.
- **The project code.** Fossil sync refuses a server whose project code
  differs, and zit reports that as a refused login, so the remote must be
  a clone or sibling of the repository (`fossil clone` or `zit clone` of
  it). The failure is worded for the admin: wrong user or password, a
  login that may not push, a differing project code, a host that cannot be
  reached, a timeout.
- **Where the push goes.** The forge checks the remote's address when
  the mirror is set. At each push it resolves the host once, applies the
  same address rule to every answer (loopback, private, link-local and
  shared ranges are refused unless `--allow-private-bridges`), and hands
  zit the first vetted address as `--resolve HOST:PORT:ADDR`, so zit
  never looks the name up and an answer that changes cannot matter. If
  zit cannot connect to that address the next vetted one is tried, up to
  three. zit 0.14.5 and later refuse to follow a redirect to another
  host or port, so a server cannot send the push, and the password with
  it, somewhere the forge did not check; that failure is worded for the
  admin without naming the target. A host that is itself an address
  needs no pin. The certificate probe that tells an untrusted certificate
  from an unreachable host connects to the same vetted address.
- **A changed mirror ends a running push.** The login is bound to one
  configuration of the mirror: a change to the remote, the user or the
  password while a push runs ends that push without sending the new
  password to the old host or recording its result.
- **Pause and refusal.** A private repository cannot be mirrored and a
  mirrored one made private, or taken down, is paused exactly as the Git
  mirror is; nothing already pushed is withdrawn. A repository is never
  mirrored out to the Fossil it follows.

## Build order
[build]: #build-order

| Rung | Work |
|---|---|
| 1 | The setting row, key making and sealing, the settings section (enable, disable, URL, branch, public key), the private-repository refusal, the settings-log rows |
| 2 | The run: trigger on a trunk-tip change, per-repository coalescing, lease holder only, the per-run key file, `zit git export --autopush`, status and error words, backoff |
| 3 | Pause on private, the About line |
| 4 | Move pikchr-studio to the setting and retire `mirror-to-github`, its timer and the `gitmirror` user on the host |

## Testing
[testing]: #testing

- Enable on a public repository makes a key, stores it sealed, shows the
  public line, and logs the change; enable on a private one is refused;
  disable deletes the key; a remote URL with a password or an HTTPS URL
  is refused with the reason.
- A run against a local bare Git repository as the remote (an `ssh`
  stub that execs `git-receive-pack` locally) exports and pushes after a
  trunk move, and a second move while it runs coalesces into one more
  run.
- A remote with unrelated history is refused and recorded, and nothing
  is forced.
- The key file exists only during the run and is 0600; no log line
  carries it.
- A repository made private pauses; made public again resumes.
- An agent holding `a` may enable it; a person without `a` may not.