Compare commits

...
Author SHA1 Message Date
branderson d8b687f4e4 Merge pull request 'Refuse an oversized body before inviting it' (#413) from proxy-expect-100 into development
Reviewed-on: #413
Reviewed-by: branderson <brad@branderson.io>
2026-10-16 14:54:52 -07:00
branderson 06018fdda4 Refuse an oversized body before inviting it
tests / confined (mirror) (pull_request_target) Successful in 1m17s
tests / confined (conversation) (pull_request_target) Successful in 1m18s
tests / confined (proxy) (pull_request_target) Successful in 1m30s
tests / confined (providers) (pull_request_target) Successful in 2m13s
tests / confined (launch) (pull_request_target) Successful in 2m37s
tests / confined (share) (pull_request_target) Successful in 2m25s
tests / runner-env (confined) (pull_request_target) Successful in 1m11s
tests / confined (shellcheck) (pull_request_target) Successful in 1m41s
tests / confined (remote) (pull_request_target) Successful in 3m12s
tests / confined (audit) (pull_request_target) Successful in 4m12s
tests / confined (project) (pull_request_target) Successful in 4m52s
tests / confined (static) (pull_request_target) Successful in 4m1s
tests / confined (config) (pull_request_target) Successful in 5m57s
tests / privileged (pull_request_target) Successful in 6m17s
tests / runner-env (privileged) (pull_request_target) Successful in 1m4s
BaseHTTPRequestHandler answers 100 Continue and returns True before do_* runs,
so the proxy invited the upload, read the declared length in _read_body,
refused it and closed mid-flight. Whether the client read the 413 or only the
100 was timing - a race the client cannot win and a test cannot pass
reliably, which is how it was found: an intermittently red proxy job on a pull
request that did not touch the proxy.

The cap itself was doing the thing that matters, in that nothing oversized was
read into host memory. What was wrong was the conversation.

The new test asks what the FIRST answer is and sends no body at all, which is
the question the race cannot reach: the one beside it passes either way when
the timing is kind.

Fixes #400
2026-10-16 11:28:17 -07:00
branderson 02159d95e2 Merge pull request 'Mirror the organization profile repository, .github' (#410) from mirror-dotgithub into development
Reviewed-on: #410
Reviewed-by: branderson <brad@branderson.io>
2026-10-15 14:53:06 -07:00
branderson 51dc5ddf5c Mirror the organization profile repository, .github
tests / confined (conversation) (pull_request_target) Successful in 1m21s
tests / confined (mirror) (pull_request_target) Successful in 1m19s
tests / confined (proxy) (pull_request_target) Successful in 1m41s
tests / runner-env (confined) (pull_request_target) Successful in 1m10s
tests / confined (remote) (pull_request_target) Successful in 3m13s
tests / confined (audit) (pull_request_target) Successful in 4m18s
tests / confined (project) (pull_request_target) Successful in 5m3s
tests / confined (static) (pull_request_target) Successful in 3m40s
tests / confined (config) (pull_request_target) Successful in 6m10s
tests / runner-env (privileged) (pull_request_target) Successful in 1m3s
tests / confined (providers) (pull_request_target) Successful in 2m19s
tests / confined (launch) (pull_request_target) Successful in 2m42s
tests / confined (share) (pull_request_target) Successful in 2m27s
tests / confined (shellcheck) (pull_request_target) Successful in 1m40s
tests / privileged (pull_request_target) Successful in 6m3s
2026-10-15 14:31:03 -07:00
branderson affb2ac903 Merge pull request 'Rewrite the mirrored history: no AI co-author trailers, no operator values' (#408) from mirror-strip-attribution into development
Reviewed-on: #408
Reviewed-by: branderson <brad@branderson.io>
2026-10-14 19:40:15 -07:00
branderson dfdc720434 Give the mirror's ssh alias one multiplexed connection on port 443
tests / confined (mirror) (pull_request_target) Successful in 1m13s
tests / confined (providers) (pull_request_target) Successful in 2m14s
tests / confined (launch) (pull_request_target) Successful in 2m34s
tests / runner-env (confined) (pull_request_target) Successful in 1m9s
tests / confined (conversation) (pull_request_target) Successful in 1m20s
tests / confined (proxy) (pull_request_target) Successful in 1m28s
tests / confined (share) (pull_request_target) Successful in 2m24s
tests / confined (shellcheck) (pull_request_target) Successful in 1m39s
tests / confined (audit) (pull_request_target) Successful in 4m41s
tests / confined (remote) (pull_request_target) Successful in 3m22s
tests / confined (project) (pull_request_target) Successful in 6m10s
tests / privileged (pull_request_target) Successful in 6m17s
tests / confined (static) (pull_request_target) Successful in 5m17s
tests / runner-env (privileged) (pull_request_target) Successful in 1m1s
tests / confined (config) (pull_request_target) Successful in 9m52s
2026-10-14 19:12:49 -07:00
branderson 34f8fb85c9 Record the mirror's rewritten history in the changelog 2026-10-14 18:58:25 -07:00
branderson 4395887fa9 Document the mirror's history rewrite, the scrub file and --reset 2026-10-14 18:58:19 -07:00
branderson 44a74ce187 Rewrite the mirrored history: no AI co-author trailers, no operator values
Commit messages lose Co-Authored-By trailers crediting Claude, so GitHub
lists no tool as a contributor, and the values in a host-side scrub file
are replaced in every file and message. The rewrite is deterministic, so
syncs still fast-forward; --reset replaces a mirror built before it.
2026-10-14 18:56:55 -07:00
branderson f6e2480d37 Merge pull request 'Release 2026.10.14' (#407) from release-2026.10.14 into main
Reviewed-on: #407
Reviewed-by: branderson <brad@branderson.io>
2026-10-14 18:31:47 -07:00
branderson ab362e8d75 Refuse a symlinked README in the mirror rewrite
perl -i reads through the link and writes the target back as a regular
file, which the mirror commit would then publish.
2026-10-14 18:03:44 -07:00
branderson a808447747 End perl's options before the forge's filenames in the mirror rewrite
A tracked file named like -eBEGIN{...} was read as code, which ran on
the host holding the GitHub key.
2026-10-14 18:02:37 -07:00
branderson c6afe46220 Say in the security doc why the mirror script is watched and copied 2026-10-14 18:02:02 -07:00
branderson 11b1d7f726 Watch mirror/ in the exit audit, and install the mirror script as a copy
The timer runs it on the host with a key that can push to every
repository in the GitHub organisation, and a session working in this
checkout can edit it.
2026-10-14 18:01:55 -07:00
branderson 4289bb59d8 Date the release 2026.10.14 and record the GitHub mirrors 2026-10-14 17:56:04 -07:00
branderson e8a80ed3e8 Merge pull request 'Mirror every dejima repository's main and release tags to GitHub' (#406) from github-mirror into development
Reviewed-on: #406
Reviewed-by: branderson <brad@branderson.io>
2026-10-14 17:49:24 -07:00
branderson 361a7413fe Treat a repository with no forge links as nothing to rewrite, not a failure 2026-10-14 16:57:07 -07:00
branderson 9586a464ea Document mirroring every repository in the organisation 2026-10-14 16:29:22 -07:00
branderson 3bdc5a664b Configure the mirror timer for every repository in the organisation 2026-10-14 16:29:13 -07:00
branderson f86ceb9441 Mirror every dejima repository, and rewrite forge URLs in code too
One run syncs dejima, dejima-badge and the three sister projects, each
in its own subshell so one failure does not stop the rest. The rewrite
now covers every text file, since clone URLs and systemd Documentation=
lines live in code, and Gitea's /raw/branch/ becomes GitHub's /raw/.
2026-10-14 16:29:04 -07:00
branderson 8b7b9cfff0 Insert the mirror notice once, so the transform is idempotent 2026-10-14 15:15:37 -07:00
branderson 6c5f652375 Check for the wordmark rewrite itself, not a word the comment also uses 2026-10-14 14:49:42 -07:00
branderson ae91766acc Test tag ancestry with an if, not an && under set -e 2026-10-14 14:31:18 -07:00
branderson cfc1b01b1f Document the GitHub mirror 2026-10-14 14:27:51 -07:00
branderson 078b0d814b Add a systemd user timer that runs the GitHub mirror hourly 2026-10-14 14:27:29 -07:00
branderson d8924693d5 Add a script that publishes main and its tags to a GitHub mirror
The mirror's main is one commit on top of the forge's, rewriting forge
repository links to dejima-org and making the README wordmark follow
the reader's colour scheme. --settings turns off issues, pull requests
and Actions on the GitHub repository.
2026-10-14 14:27:07 -07:00
branderson bd590edfea Accept either host in the sister-project link tests 2026-10-14 14:22:02 -07:00
branderson 1436bb2614 Merge pull request 'Release 2026.10.13' (#403) from release-2026.10.13 into main
Reviewed-on: #403
Reviewed-by: branderson <brad@branderson.io>
2026-10-13 22:37:25 -07:00
branderson 9c1735de00 Merge pull request 'Release: the egress filter and the container image, both rewritten clean-room' (#347) from development into main
Reviewed-on: #347
Reviewed-by: branderson <brad@branderson.io>
2026-09-17 02:15:55 -07:00
branderson 16025633a1 Merge pull request 'Release: the egress filter in a container the session cannot reach' (#337) from development into main
Reviewed-on: #337
Reviewed-by: branderson <brad@branderson.io>
2026-09-11 15:36:37 -07:00
branderson 1005257774 Merge pull request 'Release: the harness and backend axes, and a credential proxy that is neither's' (#321) from release-pass-base into main
Reviewed-on: #321
Reviewed-by: branderson <brad@branderson.io>
2026-09-03 18:19:28 -07:00
branderson 110925c44a Merge pull request 'Release: a second harness, identities, and a capability floor' (#259) from release-2026-08-27 into main
tests / confined (providers) (pull_request_target) Successful in 1m42s
tests / confined (proxy) (pull_request_target) Successful in 49s
tests / confined (remote) (pull_request_target) Successful in 2m7s
tests / confined (audit) (pull_request_target) Successful in 3m7s
tests / confined (conversation) (pull_request_target) Successful in 50s
tests / confined (project) (pull_request_target) Successful in 2m31s
tests / runner-env (privileged) (pull_request_target) Successful in 41s
tests / confined (config) (pull_request_target) Successful in 3m19s
tests / confined (shellcheck) (pull_request_target) Successful in 1m3s
tests / privileged (pull_request_target) Successful in 3m30s
tests / runner-env (confined) (pull_request_target) Successful in 45s
tests / confined (share) (pull_request_target) Failing after 40s
tests / confined (static) (pull_request_target) Successful in 1m38s
Reviewed-on: #259
Reviewed-by: branderson <brad@branderson.io>
2026-08-27 17:24:04 -07:00
branderson c7797d05ac Merge pull request 'Release: harness axis, Cline, forge parity, the web toolbar, and a release pass' (#207) from release-2026-08-24 into main
Reviewed-on: #207
Reviewed-by: branderson <brad@branderson.io>
2026-08-24 08:16:05 -07:00
branderson e04131064c Merge development into main for release
tests / confined (proxy) (pull_request_target) Successful in 1m16s
tests / confined (conversation) (pull_request_target) Successful in 1m27s
tests / runner-env (confined) (pull_request_target) Successful in 37s
tests / confined (shellcheck) (pull_request_target) Successful in 52s
tests / confined (providers) (pull_request_target) Successful in 2m24s
tests / privileged (pull_request_target) Successful in 3m46s
tests / confined (remote) (pull_request_target) Successful in 3m55s
tests / runner-env (privileged) (pull_request_target) Successful in 40s
tests / confined (static) (pull_request_target) Successful in 4m46s
tests / confined (project) (pull_request_target) Successful in 5m25s
tests / confined (config) (pull_request_target) Successful in 7m13s
tests / confined (audit) (pull_request_target) Successful in 9m53s
Made on a branch off main rather than by merging main into development
first. The reconciliation the cherry-pick in #173 made necessary has to
happen somewhere, and doing it here means it happens once, in a pull
request with a diff someone can read - the other order produces a merge
with no file changes at all, which is a confusing thing to ask anyone to
approve.

Three files conflicted, all of them main's cherry-picked state against
what development made of it. Resolved to development in each case: the
harness hook list is a strict superset, the entrypoint's seed loop is the
same loop relocated, and the guard descriptions were reworded when the
PATH shadows stopped depending on the harness.

The merged tree is byte-identical to development's, which is the property
a release should have and the one worth checking rather than assuming.
2026-08-24 03:43:55 -07:00
branderson c57c6ba778 Merge pull request 'Bring the plugin fix and the launch optimisation to main, without Cline' (#173) from main-without-cline into main
Reviewed-on: #173
Reviewed-by: branderson <brad@branderson.io>
2026-08-22 01:44:54 -07:00
branderson 1e6711d1b2 Carry the two static-test helpers the plugin change depends on
tests / confined (conversation) (pull_request_target) Successful in 58s
tests / confined (proxy) (pull_request_target) Successful in 1m12s
tests / confined (providers) (pull_request_target) Successful in 2m1s
tests / confined (remote) (pull_request_target) Successful in 3m31s
tests / runner-env (privileged) (pull_request_target) Successful in 34s
tests / confined (project) (pull_request_target) Successful in 4m48s
tests / confined (audit) (pull_request_target) Successful in 8m22s
tests / runner-env (confined) (pull_request_target) Successful in 35s
tests / confined (shellcheck) (pull_request_target) Successful in 53s
tests / confined (static) (pull_request_target) Successful in 2m59s
tests / privileged (pull_request_target) Successful in 3m51s
tests / confined (config) (pull_request_target) Successful in 6m7s
Both came from the Cline branch, which is not on this branch, and the plugin
change was written against them.

lib() runs a function from the provider library in a subshell, the way
providers.bats does. Its absence here is not only a missing convenience: the
test added with the briefing work already calls it, so `lib manifest_get
harness claude-code briefing_args` ran as a command that does not exist, `run`
captured the 127 and its message as output, and the assertion that the output
is non-empty passed on that. The test has been asserting nothing on this
branch since it landed.

The other is the rule that a plugin function whose name starts with an
underscore is the plugin's own helper rather than a misspelled hook. The
plugin change adds _plugin_version_mounts, which without this reads as a hook
core never calls.

Both are byte-identical to the versions on development, so the merge that
eventually brings Cline across sees the same content on both sides rather
than two spellings of it.
2026-08-22 01:23:36 -07:00
branderson 8d61332742 Let a session update a plugin, by seeding the tree instead of mounting it
Cherry-picked from #172, which landed on development after the Cline harness.
Cline is deliberately not carried across with it: it has not been exercised
beyond its own tests yet, and holding it back is the point of taking this
route rather than merging development whole.

Three hunks needed adapting, all of them places where the Cline branch had
added something adjacent: the harness hook list gains harness_seed without
Cline's two, docker-compose passes SANDBOX_HARNESS_SEED without the
briefing_path variable that only Cline uses, and entrypoint.sh keeps the
argument-only briefing block this branch already had rather than the
two-route version. Nothing else differed.

The change itself is unmodified. ~/.claude/plugins was mounted read-only,
which meant Claude Code could neither materialise a plugin update nor record
it, so the plugin was left at a version the catalogue no longer matched -
presenting as a plugin that would not load, fixable only by running Claude
Code on the host and restarting. What the harness rewrites is now seeded as a
writable copy it owns, and each already-installed version stays a read-only
mount of the host's.
2026-08-22 01:23:36 -07:00
branderson d356191859 Merge commit 'efc5d63' into main-without-cline 2026-08-22 01:09:15 -07:00
branderson 86e1a5be71 Merge pull request 'Release: the harness-decoupling programme, and a briefing that actually arrives' (#168) from development into main
Reviewed-on: #168
Reviewed-by: branderson <brad@branderson.io>
2026-08-21 20:12:04 -07:00
branderson fce3dbc2ac Merge pull request 'Release: epitaph, the threat model, and the development branch process' (#132) from development into main
Reviewed-on: #132
Reviewed-by: branderson <brad@branderson.io>
2026-08-13 17:53:32 -07:00
17 changed files with 764 additions and 18 deletions
+1
View File
@@ -89,6 +89,7 @@ jobs:
- providers
- conversation
- share
- mirror
name: confined (${{ matrix.suite }})
container:
# Debian bookworm plus a node binary - node is what actions/checkout
+39 -4
View File
@@ -6,7 +6,7 @@ What each release changed, newest first, for one question: **should I update,
and what will I be getting.** `dejima --version` says which release the
checkout you are running is on.
A release is a date - `2026.10.13` - and the git tag of that name on `main` is
A release is a date - `2026.10.14` - and the git tag of that name on `main` is
where it lives. A date rather than a version number because the useful fact
about a release is how old it is, and because `MAJOR.MINOR` would be a promise
about compatibility and patch lines that this project does not make.
@@ -22,9 +22,44 @@ requests, which are linked.
## Unreleased
Nothing yet. The release pull request that merges `development` into `main`
replaces this heading with the date it merges, and tags `main` with the same
date.
The release pull request that merges `development` into `main` replaces this
heading with the date it merges, and tags `main` with the same date.
### Changed
- **The GitHub mirrors carry a rewritten history.** AI co-author trailers are
removed from commit messages, so GitHub credits no tool as a contributor,
and operator-specific values are replaced throughout. Commits keep their
authors and dates, but a forge SHA no longer resolves on GitHub.
### Fixed
- **The credential proxy refuses an oversized request body before inviting
it.** It answered `100 Continue` and only then read the declared length, so
whether a session saw the `413` or only the `100` was timing. The cap itself
always held - nothing oversized was read into host memory - but the
conversation was a race, and it surfaced as an intermittently red `proxy`
job.
([#400](https://gitbox.branderson.io/dejima/dejima/issues/400))
## 2026.10.14
Nothing a session does has changed. This release is about where dejima can be
read.
### New
- **Read-only mirrors on GitHub.** dejima, dejima-badge and the three sister
projects are published to [github.com/dejima-org](https://github.com/dejima-org):
`main` and its release tags, synced hourly. Development, issues and pull
requests stay on the forge, and the mirrors accept neither. A clone of a
mirror has GitHub links and clone URLs where the forge's copy has the forge's.
([#406](https://gitbox.branderson.io/dejima/dejima/pulls/406))
### Rollback
This release adds no config key and changes nothing a session runs, so rolling
back changes nothing an operator would notice.
## 2026.10.13
+3
View File
@@ -8,6 +8,9 @@ anonymous issues or pull requests. So there is currently no way for you to
file either, and that is a property of where this lives rather than a comment
on the contribution.
The repositories under [github.com/dejima-org](https://github.com/dejima-org)
are read-only mirrors of `main`, with issues and pull requests turned off.
That will change: this project will provide a way to submit issues and pull
requests. Until it does, if you are using dejima and need it to do something
different, **fork it and make the change in your fork**. Nothing here depends
+4 -4
View File
@@ -4,10 +4,10 @@
browser is set - so anything conditional shows dark ink on a dark page to
a reader whose browser prefers light, and does it silently.
The suffix names the mode a file is FOR, not the colour of its ink:
-darkmode is the pale wordmark that belongs on a dark page. A mirror on a
light-themed host swaps the filename below for dejima-wordmark-lightmode.png,
which is committed alongside for exactly that. One line, on the mirror,
where someone can see whether it worked.
-darkmode is the pale wordmark that belongs on a dark page. GitHub does
follow its own theme setting through <picture>, so the GitHub mirror
replaces the tag below with one that falls back to
dejima-wordmark-lightmode.png; see mirror/github-mirror.sh.
Every &nbsp; here is spacing, and it has to be a character rather than CSS
or a presentation attribute. Gitea's sanitizer strips `style` from the tag
and from any wrapper, `hspace`/`vspace` survive sanitizing only to be
+3 -3
View File
@@ -5,9 +5,9 @@ page that renders dark, `-lightmode` for one that renders light. The suffix
names the mode a file is for, not the colour of its ink, so `-darkmode` is the
pale one.
The README points at `-darkmode` unconditionally. A mirror on a light-themed
host changes that one line; the comment above the tag says why it is not
conditional.
The README points at `-darkmode` unconditionally; the comment above the tag
says why it is not conditional. The GitHub mirror rewrites that tag to pick
between the two by the reader's theme.
**These are trademarks and are not covered by this repository's GPL licence.**
Who holds them, and what may be done with them, is stated once for the whole
+4 -1
View File
@@ -974,7 +974,10 @@ tcb_snapshot() {
# backends/ likewise: a backend.conf names the upstream every request
# is forwarded to and the host path the credential is read from, so an
# edit here redirects where a real credential is sent.
for dir in bin hooks docker test forges workflows harnesses tools backends; do
#
# mirror/ runs on the host from a timer, holding a key that can push
# to every repository in the GitHub organisation.
for dir in bin hooks docker test forges workflows harnesses tools backends mirror; do
if [ -d "$ws/$dir" ]; then
while IFS= read -r -d '' f; do
stage_entry "tcb:${f#"$ws"/}" "$f"
+27
View File
@@ -424,6 +424,33 @@ class Handler(BaseHTTPRequestHandler):
% (what, n, self.MAX_BODY_BYTES))
return n
def handle_expect_100(self):
"""Refuse an oversized body before inviting it.
The default answers `100 Continue` and returns True, and it runs
before do_* - so the proxy invited the upload, read the length in
_read_body, refused it and closed mid-flight. Whether the client saw
the 413 or only the 100 was timing, which is a race a client cannot
be expected to win and a test cannot be expected to pass reliably.
The declared length is all there is to go on before a body arrives.
A chunked request declares nothing and is capped as it is read, which
is the one case where the conversation still ends mid-upload - there
is no earlier moment to end it at.
"""
try:
self._length(self.headers.get("Content-Length", 0), "Content-Length")
except BodyTooLarge as e:
# Closed for the same reason the other 413 closes: a client that
# sends anyway - curl gives up waiting after a second - leaves its
# bytes queued, and the next parse would read them as a request
# line and answer with garbage 400s instead of one clean refusal.
self.close_connection = True
self._fail(413, "credential proxy: request body too large",
log_message="refused a request body: %s" % e)
return False
return super().handle_expect_100()
def _read_body(self):
if self.headers.get("Transfer-Encoding", "").lower() == "chunked":
chunks = []
+28
View File
@@ -160,3 +160,31 @@ getting - so it names what an operator would notice rather than what changed.
The pull requests carry the detail and are linked from it. A tag is a record
of what shipped, not a version anyone may stay on; `CHANGELOG.md` says so at
the top, and `SECURITY.md` is where that is binding.
## 7. The GitHub mirror
`mirror/github-mirror.sh` publishes `main` and the tags on it, for this
repository and every other one in the organisation, to
[github.com/dejima-org](https://github.com/dejima-org) from the operator's
host, hourly, so a release needs no step here. Issues, pull requests and
Actions are off there; all of that stays on the forge. A new repository is
mirrored once it is added to `MIRROR_REPOS` in the script.
Each mirror's history is the forge's `main` with AI co-author trailers taken
out of the commit messages, so GitHub credits no tool as a contributor, and
with the values in the operator's scrub file replaced throughout. That file
stays on the host, because the list is the thing it hides. The commits keep
their authors and dates but not their SHAs, so a forge SHA does not resolve on
GitHub. On top sits one commit that points repository links and clone URLs at
GitHub, lets the README wordmark follow the reader's
theme, and says at the top of the README that it is a mirror. A README change
that breaks one of those rewrites stops that repository's mirror rather than
publishing the forge's version. Here, `test/mirror.bats` fails on it first; in
the other repositories, the hourly run fails and names it.
Setup is in the header of `mirror/dejima-github-mirror.service`. Once the
GitHub repositories exist, `github-mirror.sh --settings` turns their features
off, and `--dry-run` builds what a sync would publish without pushing it. A
sync refuses a mirror whose `main` is not built on the history it would
publish, which happens when the rewrite rules change or the scrub file gains a
value; `--reset` force-pushes over it.
+3 -1
View File
@@ -280,7 +280,9 @@ layout it is unpacked with, all consumed by the image build - so a session
that edits one chooses what the next launch downloads and installs as root.
`backends/*/backend.conf` names the upstream every request is forwarded to and
the host path the credential is read from, so an edit there redirects where a
real credential is sent.
real credential is sent. `mirror/github-mirror.sh` runs on the host from a
timer with a key that can push to every repository in the GitHub organisation,
which is why its unit installs a copy rather than linking into the checkout.
An ordinary edit is a diff you are about to read and is not reported; one git
cannot see - hidden by `.gitignore`, `.git/info/exclude`, `assume-unchanged`
+50
View File
@@ -0,0 +1,50 @@
# User unit for the GitHub mirror (#209), on a host that can push to GitHub.
# Never in a dejima session: a sandbox may push only to its own forge.
#
# install -m 755 mirror/github-mirror.sh ~/.local/bin/dejima-github-mirror
# cp mirror/dejima-github-mirror.service mirror/dejima-github-mirror.timer ~/.config/systemd/user/
# systemctl --user daemon-reload
# systemctl --user enable --now dejima-github-mirror.timer
#
# A copy, not a link into the checkout: the timer holds a key that can push to
# every repository in the organisation, and a session working in this
# checkout can edit the script. Reinstall it after reviewing an update.
#
# ~/.config/dejima/github-mirror.env sets MIRROR_SOURCE, the forge
# organisation each repository name is appended to, and whatever else
# mirror/github-mirror.sh documents. The GitHub key belongs behind an ssh Host
# alias named in MIRROR_TARGET, so it is not also offered to the forge. A
# deploy key covers one repository, so this one is an account's key with push
# access to every repository in the organisation:
#
# MIRROR_SOURCE=git@forge.example.com:dejima
# MIRROR_TARGET=git@github-mirror:dejima-org
#
# A sync opens several connections per repository. One multiplexed connection
# to GitHub's port 443 endpoint keeps a network that throttles bursts of
# outbound ssh from timing out every repository after the first:
#
# Host github-mirror
# HostName ssh.github.com
# Port 443
# User git
# IdentityFile ~/.ssh/id_ed25519
# IdentitiesOnly yes
# ControlMaster auto
# ControlPath ~/.ssh/cm-%C
# ControlPersist 60
#
# ~/.config/dejima/github-mirror.scrub lists values replaced throughout the
# published history, one "value replacement" pair per line. Adding one changes
# history already published, so the next run refuses until it is given
# --reset, once, by hand:
#
# gitea.internal.example gitea.example.com
# 10.9.8. 192.0.2.
[Unit]
Description=Mirror the dejima repositories' main and release tags to GitHub
[Service]
Type=oneshot
EnvironmentFile=%h/.config/dejima/github-mirror.env
ExecStart=%h/.local/bin/dejima-github-mirror
+9
View File
@@ -0,0 +1,9 @@
[Unit]
Description=Mirror the dejima repositories' main and release tags to GitHub, hourly
[Timer]
OnCalendar=hourly
Persistent=true
[Install]
WantedBy=timers.target
+282
View File
@@ -0,0 +1,282 @@
#!/usr/bin/env bash
# Publishes main and its release tags of every dejima repository to a
# read-only mirror on GitHub (#209).
#
# MIRROR_SOURCE=<forge org URL> mirror/github-mirror.sh sync every repository
# ... mirror/github-mirror.sh --dry-run build and print, push nothing
# ... mirror/github-mirror.sh --reset replace a mirror built on other history
# mirror/github-mirror.sh --settings configure the GitHub repos (needs gh)
# mirror/github-mirror.sh --transform DIR rewrite a checkout in place
#
# Runs on the host, never in a session: a sandbox may push only to its own
# forge, and this needs push access to GitHub.
#
# The GitHub copy is not byte-for-byte. Its history is the forge's main with
# AI co-author trailers taken out of the commit messages, so no tool is
# credited as a contributor, and the MIRROR_SCRUB values replaced in every
# file and message (rewrite_history). That changes the SHA of every commit
# from the first one touched on, but the same input always makes the same
# commits. On top sits one extra commit, whose tree is the forge's with the
# rewrites in transform() applied, and whose parents are the rewritten commit
# and the previous mirror commit, so each sync is a fast-forward for anyone
# tracking it. A tag on main is published as a lightweight tag on the
# rewritten commit it maps to.
#
# A mirror whose main is not built on that history - an earlier rule, or a
# forge main that was rewound - is refused. --reset force-pushes over it.
#
# Environment:
# MIRROR_SOURCE forge URL each repository name is appended to, as
# $MIRROR_SOURCE/<name> (required to sync)
# MIRROR_ORG GitHub organisation, default dejima-org
# MIRROR_TARGET push URL prefix, default git@github.com:$MIRROR_ORG
# MIRROR_REPOS repositories, by their name on both sides
# MIRROR_STATE working bare repositories, default ~/.cache/dejima-github-mirror
# MIRROR_SCRUB values to replace throughout the published history, one
# "value replacement" pair per line, default
# ~/.config/dejima/github-mirror.scrub. Kept on the host
# because the list itself is what it hides.
set -euo pipefail
MIRROR_ORG="${MIRROR_ORG:-dejima-org}"
MIRROR_TARGET="${MIRROR_TARGET:-git@github.com:$MIRROR_ORG}"
MIRROR_REPOS="${MIRROR_REPOS:-dejima dejima-badge dejima-ci-hardener dejima-devbox-hardener dejima-guard .github}"
MIRROR_STATE="${MIRROR_STATE:-${XDG_CACHE_HOME:-$HOME/.cache}/dejima-github-mirror}"
MIRROR_SCRUB="${MIRROR_SCRUB:-${XDG_CONFIG_HOME:-$HOME/.config}/dejima/github-mirror.scrub}"
export MIRROR_ORG MIRROR_SCRUB
NAME=""
say() { echo "github-mirror: ${NAME:+$NAME: }$*"; }
die() { say "$*" >&2; exit 1; }
# Rewrites a fast-export stream. Commit messages lose Co-Authored-By trailers
# crediting Claude or anything else at anthropic.com, and every data block -
# file contents and messages - has the MIRROR_SCRUB values replaced, matched
# only where the value does not continue a longer name or number. Data
# needing neither change passes through byte for byte, so its commit keeps
# its SHA.
rewrite_history() {
perl -e '
binmode STDIN; binmode STDOUT;
my @pairs;
if (open my $fh, "<", $ENV{MIRROR_SCRUB}) {
while (<$fh>) {
next if /^\s*(?:#|$)/;
my ($from, $to) = split " ";
defined $to or die "MIRROR_SCRUB: no replacement on line $.\n";
push @pairs, [qr/(?<![\w.-])\Q$from\E/, $to];
}
}
my $in_commit = 0;
while (my $line = <STDIN>) {
if ($line =~ /^commit /) {
$in_commit = 1;
} elsif ($line =~ /^data (\d+)$/) {
my $len = $1;
read(STDIN, my $data, $len) == $len or die "short data block\n";
if ($in_commit
&& $data =~ s/^Co-Authored-By:[^\n]*(?:claude|anthropic\.com)[^\n]*(?:\n|\z)//mgi) {
$data =~ s/\s+\z/\n/;
}
$data =~ s/$_->[0]/$_->[1]/g for @pairs;
$in_commit = 0;
print "data ", length($data), "\n", $data;
next;
}
print $line;
}
'
}
# Fails if a MIRROR_SCRUB value survives in a tree about to be published.
check_scrubbed() {
[ -f "$MIRROR_SCRUB" ] || return 0
local from to
while read -r from to; do
case "$from" in ''|'#'*) continue ;; esac
if git -C "$1" grep -qIF -- "$from"; then
die "a MIRROR_SCRUB value is still in the tree to publish"
fi
done < "$MIRROR_SCRUB"
}
# The rewritten commit a forge commit became, from the two runs' marks.
rewritten() {
local mark
mark="$(awk -v c="$1" '$2 == c { print $1; exit }' "$STATE/source.marks")"
[ -n "$mark" ] || die "no rewritten commit for $1"
awk -v m="$mark" '$1 == m { print $2; exit }' "$STATE/clean.marks"
}
WORK=""
STATE=""
cleanup() {
[ -n "$WORK" ] || return 0
git -C "$STATE" worktree remove --force "$WORK" 2>/dev/null || true
rm -rf "$WORK"
}
# Rewrites a checkout for GitHub. Each rewrite is checked, so a README that
# drifted away from what this expects stops the mirror instead of publishing
# the forge's version. Running it twice changes nothing, so a clone of the
# mirror still passes test/mirror.bats.
transform() {
local dir="$1"
local readme="$dir/README.md"
# perl -i would write a symlink's target back as the README, publishing it.
[ -f "$readme" ] && [ ! -L "$readme" ] || die "README.md is missing or a symlink in $dir"
# Repository links and clone URLs move to the org on GitHub, in code as
# well as docs; issue and pull request links stay on the forge, which is
# the only place those exist. Gitea's /src/branch/ is GitHub's /blob/, and
# its /raw/branch/ GitHub's /raw/.
# Exit 1 from git grep is no match, which a repository may well have. The
# paths come from the forge, so -- keeps one from being read as a perl option.
(cd "$dir" && { git grep -lzIF 'gitbox.branderson.io/dejima/' || [ $? -eq 1 ]; } | xargs -0r perl -pi -e '
s{https://gitbox\.branderson\.io/dejima/([\w.-]++)(?!/(?:issues|pulls)/)}{https://github.com/$ENV{MIRROR_ORG}/$1}g;
s{(https://github\.com/\Q$ENV{MIRROR_ORG}\E/[\w.-]+)/src/branch/}{$1/blob/}g;
s{(https://github\.com/\Q$ENV{MIRROR_ORG}\E/[\w.-]+)/raw/branch/}{$1/raw/}g;
' --)
# GitHub follows the reader's light/dark setting through <picture>, which
# the forge cannot; see the comment above the tag in README.md.
if [ -f "$dir/assets/dejima-wordmark-lightmode.png" ]; then
perl -0pi -e '
s{<img src="assets/dejima-wordmark-darkmode\.png" alt="dejima" height="72">}
{<picture><source media="(prefers-color-scheme: dark)" srcset="assets/dejima-wordmark-darkmode.png"><img src="assets/dejima-wordmark-lightmode.png" alt="dejima" height="72"></picture>}
' "$readme"
grep -qF 'darkmode.png"><img src="assets/dejima-wordmark-lightmode.png"' "$readme" \
|| die "README.md: the wordmark tag was not found"
fi
# The first thing a visitor to a mirror does is open a pull request. Under
# the rule after the wordmark, or under the title where there is none.
grep -q '^> \*\*This is a read-only mirror' "$readme" || perl -0pi -e '
s{^(---|# [^\n]*)\n}{$1\n\n> **This is a read-only mirror.** dejima is developed on a private forge,\n> and issues and pull requests are not accepted here. See\n> [CONTRIBUTING.md](https://github.com/$ENV{MIRROR_ORG}/dejima/blob/main/CONTRIBUTING.md).\n}m
' "$readme"
grep -q '^> \*\*This is a read-only mirror' "$readme" \
|| die "README.md: no --- line or title to put the mirror notice under"
}
# Issues, pull requests and Actions off. Settings rather than content, so a
# sync does not reapply them; run once after creating the repositories.
settings() {
command -v gh >/dev/null || die "--settings needs the gh CLI"
local repo
for repo in $MIRROR_REPOS; do
gh api -X PATCH "repos/$MIRROR_ORG/$repo" --silent \
-F has_issues=false -F has_pull_requests=false -F has_projects=false \
-F has_wiki=false -F has_discussions=false
gh api -X PUT "repos/$MIRROR_ORG/$repo/actions/permissions" --silent -F enabled=false
done
}
sync_repo() {
NAME="$1"
STATE="$MIRROR_STATE/$NAME.git"
local source="$MIRROR_SOURCE/$NAME" target="$MIRROR_TARGET/$NAME"
local git=(git -C "$STATE")
trap cleanup EXIT
[ -d "$STATE" ] || git init -q --bare "$STATE"
"${git[@]}" fetch -q --no-tags --prune "$source" \
'+refs/heads/main:refs/source/main' '+refs/tags/*:refs/source/tags/*'
local src clean prev=""
src="$("${git[@]}" rev-parse refs/source/main)"
# Rebuilt from the whole history each run.
"${git[@]}" fast-export --signed-tags=strip --reencode=yes \
--export-marks="$STATE/source.marks" \
--refspec refs/source/main:refs/clean/main refs/source/main \
| rewrite_history \
| "${git[@]}" fast-import --quiet --force --export-marks="$STATE/clean.marks"
clean="$("${git[@]}" rev-parse refs/clean/main)"
# An absent main (exit 2) is a first run; anything else is a real failure.
local rc=0
git ls-remote -q --exit-code "$target" refs/heads/main >/dev/null || rc=$?
if [ "$rc" -eq 0 ]; then
"${git[@]}" fetch -q --no-tags "$target" '+refs/heads/main:refs/mirror/main'
prev="$("${git[@]}" rev-parse refs/mirror/main)"
elif [ "$rc" -ne 2 ]; then
die "cannot read $target"
fi
local force=""
if [ -n "$prev" ] && ! "${git[@]}" merge-base --is-ancestor "$prev^1" "$clean"; then
[ -n "$RESET" ] \
|| die "GitHub's main is not built on the history this run publishes; --reset force-pushes over it"
prev=""
force=+
fi
WORK="$(mktemp -d)"
"${git[@]}" worktree add -q --detach "$WORK" "$clean"
transform "$WORK"
check_scrubbed "$WORK"
git -C "$WORK" add -A
local tree
tree="$(git -C "$WORK" write-tree)"
if [ -n "$prev" ] && [ "$("${git[@]}" rev-parse "$prev^1")" = "$clean" ] \
&& [ "$("${git[@]}" rev-parse "$prev^{tree}")" = "$tree" ]; then
say "already at ${src:0:12}"
else
# The forge commit's committer and date, so the same input always
# makes the same commit.
local name email date commit
IFS=$'\t' read -r name email date < <("${git[@]}" log -1 --format='%cn%x09%ce%x09%cI' "$src")
commit="$(GIT_AUTHOR_NAME="$name" GIT_AUTHOR_EMAIL="$email" GIT_AUTHOR_DATE="$date" \
GIT_COMMITTER_NAME="$name" GIT_COMMITTER_EMAIL="$email" GIT_COMMITTER_DATE="$date" \
"${git[@]}" commit-tree "$tree" -p "$clean" ${prev:+-p "$prev"} \
-m "Mirror ${src:0:12} for GitHub" \
-m "Forge links, the README header and commit trailers rewritten by dejima's mirror/github-mirror.sh.")"
"${git[@]}" update-ref refs/mirror/next "$commit"
[ -n "$DRY_RUN" ] || "${git[@]}" push -q "$target" "$force$commit:refs/heads/main"
say "main is ${src:0:12} as ${commit:0:12}"
fi
# Only tags on main: one on another branch would publish unreleased work.
local ref
local refspecs=()
while IFS= read -r ref; do
if "${git[@]}" merge-base --is-ancestor "$ref" "$src"; then
local at
at="$(rewritten "$("${git[@]}" rev-parse "$ref^{commit}")")"
refspecs+=("$force$at:refs/tags/${ref#refs/source/tags/}")
say "tag ${ref#refs/source/tags/} at ${at:0:12}"
fi
done < <("${git[@]}" for-each-ref --format='%(refname)' refs/source/tags/)
[ "${#refspecs[@]}" -eq 0 ] || [ -n "$DRY_RUN" ] \
|| "${git[@]}" push -q "$target" "${refspecs[@]}"
}
# Each repository in a subshell of its own, so one that fails does not keep
# the rest from syncing. Not `( ... ) || ...`, which turns off set -e inside.
sync() {
[ -n "${MIRROR_SOURCE:-}" ] || die "MIRROR_SOURCE is not set"
local repo rc failed=""
for repo in $MIRROR_REPOS; do
set +e
(set -e; sync_repo "$repo")
rc=$?
set -e
[ "$rc" -eq 0 ] || failed="$failed $repo"
done
[ -z "$failed" ] || die "failed:$failed"
}
DRY_RUN=""
RESET=""
case "${1:-}" in
--settings) settings; exit ;;
--transform) [ -n "${2:-}" ] || die "--transform needs a directory"; transform "$2"; exit ;;
esac
while [ "$#" -gt 0 ]; do
case "$1" in
--dry-run) DRY_RUN=1 ;;
--reset) RESET=1 ;;
*) sed -n '5,9p' "$0" >&2; exit 2 ;;
esac
shift
done
sync
+245
View File
@@ -0,0 +1,245 @@
#!/usr/bin/env bats
# mirror/github-mirror.sh: what the GitHub copies are rewritten to, and how
# their main is built. Nothing here pushes; --dry-run stops before the push, which
# is the one line these tests leave to the operator's first sync.
setup() {
REPO_ROOT="$BATS_TEST_DIRNAME/.."
MIRROR="$REPO_ROOT/mirror/github-mirror.sh"
T="$(mktemp -d)"
export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1
export GIT_AUTHOR_NAME=test GIT_AUTHOR_EMAIL=test@example.com \
GIT_COMMITTER_NAME=test GIT_COMMITTER_EMAIL=test@example.com
# The forge: this tree's docs, as one commit on main.
F="$T/forge/dejima"
git init -q -b main "$F"
cp -r "$REPO_ROOT"/*.md "$REPO_ROOT/docs" "$REPO_ROOT/assets" "$F/"
git -C "$F" add -A
git -C "$F" commit -qm base
mkdir "$T/github"
git init -q --bare "$T/github/dejima"
export MIRROR_SOURCE="$T/forge" MIRROR_TARGET="$T/github" MIRROR_STATE="$T/state" \
MIRROR_REPOS=dejima MIRROR_SCRUB="$T/scrub"
unset MIRROR_ORG
}
teardown() {
rm -rf "$T"
}
mirrored() {
git -C "$MIRROR_STATE/dejima.git" rev-parse refs/mirror/next
}
@test "the mirror's README follows the reader's colour scheme" {
"$MIRROR" --transform "$F"
local readme="$F/README.md"
grep -qF '<source media="(prefers-color-scheme: dark)" srcset="assets/dejima-wordmark-darkmode.png">' "$readme"
grep -qF '<img src="assets/dejima-wordmark-lightmode.png" alt="dejima" height="72"></picture>' "$readme"
}
@test "the mirror's README says it takes no issues or pull requests, above the fold" {
"$MIRROR" --transform "$F"
local notice heading
notice="$(grep -n 'This is a read-only mirror' "$F/README.md" | cut -d: -f1)"
heading="$(grep -n '^## ' "$F/README.md" | head -1 | cut -d: -f1)"
[ -n "$notice" ] && [ "$notice" -lt "$heading" ]
grep -q 'issues and pull requests are not accepted here' "$F/README.md"
}
@test "repository links move to GitHub and issue links stay on the forge" {
grep -q 'gitbox.branderson.io/dejima/dejima/\(issues\|pulls\)/' "$F/CHANGELOG.md"
"$MIRROR" --transform "$F"
cd "$F"
run grep -rhoE 'https://gitbox\.branderson\.io/dejima/[^)]*' --include='*.md' .
local link
for link in "${lines[@]}"; do
[[ "$link" =~ ^https://gitbox\.branderson\.io/dejima/dejima/(issues|pulls)/[0-9]+$ ]] \
|| { echo "still on the forge: $link"; return 1; }
done
grep -qF '](https://github.com/dejima-org/dejima)' README.md
grep -qF '(https://github.com/dejima-org/dejima-guard/blob/main/SECURITY.md)' docs/threat-model.md
! grep -rq 'github.com/dejima-org/[^)]*/src/branch/' --include='*.md' .
}
@test "transforming twice changes nothing, so a clone of the mirror passes this suite" {
"$MIRROR" --transform "$F"
cp -r "$F" "$T/once"
"$MIRROR" --transform "$F"
diff -r -x .git "$T/once" "$F"
}
@test "a README the transform no longer recognises stops the mirror" {
sed -i 's/dejima-wordmark-darkmode.png/renamed.png/' "$F/README.md"
run "$MIRROR" --transform "$F"
[ "$status" -ne 0 ]
[[ "$output" == *"wordmark tag was not found"* ]]
}
@test "the mirror commit sits on the forge's main, and the same input makes the same commit" {
run "$MIRROR" --dry-run
[ "$status" -eq 0 ]
local first src
first="$(mirrored)"
src="$(git -C "$F" rev-parse main)"
[ "$(git -C "$MIRROR_STATE/dejima.git" rev-parse "$first^1")" = "$src" ]
run git -C "$MIRROR_STATE/dejima.git" rev-parse -q --verify "$first^2"
[ "$status" -ne 0 ]
MIRROR_STATE="$T/state2" run "$MIRROR" --dry-run
[ "$(git -C "$T/state2/dejima.git" rev-parse refs/mirror/next)" = "$first" ]
}
@test "each sync fast-forwards the previous mirror commit" {
"$MIRROR" --dry-run
local prev
prev="$(mirrored)"
git -C "$MIRROR_TARGET/dejima" fetch -q "$MIRROR_STATE/dejima.git" "$prev:refs/heads/main"
echo change >> "$F/docs/ci.md"
git -C "$F" commit -qam change
"$MIRROR" --dry-run
local next
next="$(mirrored)"
[ "$(git -C "$MIRROR_STATE/dejima.git" rev-parse "$next^1")" = "$(git -C "$F" rev-parse main)" ]
[ "$(git -C "$MIRROR_STATE/dejima.git" rev-parse "$next^2")" = "$prev" ]
git -C "$MIRROR_TARGET/dejima" fetch -q "$MIRROR_STATE/dejima.git" "+$next:refs/heads/main"
run "$MIRROR" --dry-run
[[ "$output" == *"already at"* ]]
}
@test "only tags on main are mirrored" {
git -C "$F" tag released
git -C "$F" checkout -qb development
git -C "$F" commit -q --allow-empty -m unreleased
git -C "$F" tag unreleased
run "$MIRROR" --dry-run
[ "$status" -eq 0 ]
[[ "$output" == *"tag released"* ]]
[[ "$output" != *"tag unreleased"* ]]
}
@test "clone URLs in code move too, and a repo with no wordmark gets the notice under its title" {
# Assembled, so the mirror's copy of this file is not itself rewritten.
local B="$T/forge/dejima-badge" forge="https://gitbox.branderson.io"
git init -q -b main "$B"
printf '# Developed in dejima\n\nSee [dejima](%s/dejima/dejima).\n\n## Use\n\n curl -fLO %s/dejima/dejima-badge/raw/branch/main/dejima-badge.svg\n' "$forge" "$forge" > "$B/README.md"
printf '#!/bin/sh\nURL=${URL:-%s/dejima/dejima.git}\n' "$forge" > "$B/clone"
git -C "$B" add -A
"$MIRROR" --transform "$B"
[ "$(sed -n 3p "$B/README.md")" = '> **This is a read-only mirror.** dejima is developed on a private forge,' ]
grep -qF 'https://github.com/dejima-org/dejima-badge/raw/main/dejima-badge.svg' "$B/README.md"
grep -qF 'URL=${URL:-https://github.com/dejima-org/dejima.git}' "$B/clone"
}
@test "a repository that fails does not stop the others, and the run says which" {
MIRROR_REPOS="missing dejima" run "$MIRROR" --dry-run
[ "$status" -ne 0 ]
[[ "$output" == *"dejima: main is "* ]]
[[ "$output" == *"failed: missing"* ]]
}
@test "a repository with no forge links still gets its notice" {
local B="$T/forge/plain"
git init -q -b main "$B"
printf '# Plain\n\nNothing to rewrite.\n' > "$B/README.md"
git -C "$B" add -A
"$MIRROR" --transform "$B"
grep -q '^> \*\*This is a read-only mirror' "$B/README.md"
}
@test "a file named like a perl option is rewritten, not run" {
local forge="https://gitbox.branderson.io"
printf 'See %s/dejima/dejima-guard\n' "$forge" > "$F/-eBEGIN{exit 3}"
git -C "$F" add -A
"$MIRROR" --transform "$F"
grep -qF 'https://github.com/dejima-org/dejima-guard' "$F/-eBEGIN{exit 3}"
}
@test "a README that is a symlink stops the mirror instead of publishing its target" {
local B="$T/forge/linked"
git init -q -b main "$B"
printf '# Not for publishing\n' > "$T/outside"
ln -s "$T/outside" "$B/README.md"
git -C "$B" add -A
run "$MIRROR" --transform "$B"
[ "$status" -ne 0 ]
[ -L "$B/README.md" ]
[ "$(cat "$T/outside")" = '# Not for publishing' ]
}
published() {
git -C "$MIRROR_STATE/dejima.git" "$@" refs/mirror/next
}
@test "no AI co-author trailer reaches the published history, and other commits keep their SHA" {
local base
base="$(git -C "$F" rev-parse HEAD)"
echo change >> "$F/docs/ci.md"
git -C "$F" commit -qa -m change -m 'Keeps this line.' \
-m 'Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>'
"$MIRROR" --dry-run
run published log --format=%B
[[ "$output" != *"Co-Authored-By"* ]] || { echo "$output"; return 1; }
[[ "$output" == *"Keeps this line."* ]]
run published rev-list --max-parents=0
[ "$output" = "$base" ]
}
@test "MIRROR_SCRUB values are replaced in every file and message in the history" {
printf '# example\ngitbox.internal.example gitbox.example.com\n10.9.8. 192.0.2.\n' > "$T/scrub"
printf 'forge = gitbox.internal.example\nhost = 10.9.8.7\nother = 110.9.8.7\n' > "$F/docs/net.md"
git -C "$F" add -A
git -C "$F" commit -qm 'Point at gitbox.internal.example'
git -C "$F" rm -q docs/net.md
git -C "$F" commit -qm 'Take it back out'
"$MIRROR" --dry-run
run published log -p --format=%B
[[ "$output" != *"internal.example"* ]] || { echo "$output"; return 1; }
[[ "$output" == *"Point at gitbox.example.com"* ]]
[[ "$output" == *"host = 192.0.2.7"* ]]
[[ "$output" == *"other = 110.9.8.7"* ]]
}
@test "a scrub value the rewrite could not reach stops the mirror" {
printf 'gitbox.internal.example gitbox.example.com\n' > "$T/scrub"
echo 'see xgitbox.internal.example' > "$F/docs/net.md"
git -C "$F" add -A
git -C "$F" commit -qm net
run "$MIRROR" --dry-run
[ "$status" -ne 0 ]
[[ "$output" == *"MIRROR_SCRUB value is still in the tree"* ]]
}
@test "tags are published on the rewritten commit they map to" {
git -C "$F" commit -q --allow-empty -m tagged \
-m 'Co-Authored-By: Claude <noreply@anthropic.com>'
git -C "$F" tag release
run "$MIRROR" --dry-run
[ "$status" -eq 0 ]
local at
at="$(git -C "$MIRROR_STATE/dejima.git" rev-parse refs/clean/main)"
[ "$at" != "$(git -C "$F" rev-parse release)" ]
[[ "$output" == *"tag release at ${at:0:12}"* ]]
}
@test "a mirror built on other history is refused, and --reset replaces it" {
# What GitHub held before trailers were rewritten: a mirror commit whose
# first parent is the forge commit itself.
git -C "$F" commit -q --allow-empty -m tagged \
-m 'Co-Authored-By: Claude <noreply@anthropic.com>'
local old
old="$(git -C "$F" commit-tree "$(git -C "$F" rev-parse HEAD^{tree})" -p HEAD -m mirror)"
git -C "$T/github/dejima" fetch -q "$F" "$old:refs/heads/main"
run "$MIRROR" --dry-run
[ "$status" -ne 0 ]
[[ "$output" == *"--reset force-pushes over it"* ]]
run "$MIRROR" --dry-run --reset
[ "$status" -eq 0 ]
run git -C "$MIRROR_STATE/dejima.git" rev-parse -q --verify refs/mirror/next^2
[ "$status" -ne 0 ]
}
+39
View File
@@ -485,6 +485,45 @@ make_cert() {
return 0
}
@test "an oversized body is refused before the 100 Continue that invites it" {
# The test above is about what the client ends up with, and it can pass
# either way when the timing is kind: the proxy used to answer
# `100 Continue` first and refuse from the body reader afterwards, so
# whether a client read the 413 or only the 100 was a race. This one asks
# the question the race cannot reach - what the FIRST answer is - and never
# sends a body at all.
start_proxy
run python3 - "$SOCK" "$PLACEHOLDER" <<'PROBE'
import socket, sys
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.settimeout(10)
sock.connect(sys.argv[1])
# Declared far over the cap, and not one byte of it sent: a client offering a
# body is entitled to be told no before it starts.
sock.sendall(
b"POST /v1/messages HTTP/1.1\r\n"
b"Host: x\r\n"
b"Authorization: Bearer " + sys.argv[2].encode() + b"\r\n"
b"Content-Type: application/json\r\n"
b"Content-Length: 99999999999\r\n"
b"Expect: 100-continue\r\n\r\n")
data = b""
while b"\r\n" not in data:
chunk = sock.recv(4096)
if not chunk:
break
data += chunk
print(data.split(b"\r\n")[0].decode("ascii", "replace"))
PROBE
[ "$status" -eq 0 ] || { echo "the probe failed: $output"; return 1; }
[[ "$output" == *"413"* ]] \
|| { echo "the first answer was not a refusal: $output"; return 1; }
grep -q 'refused a request body' "$TD/stderr" \
|| { echo "nothing logged: $(cat "$TD/stderr")"; return 1; }
}
@test "a normal-sized request body still passes through" {
# The cap must be far above anything real. A conversation with attachments
# is the biggest thing a Messages request carries.
+4 -2
View File
@@ -78,6 +78,7 @@ suites_for() {
forges/*|workflows/*|harnesses/*|tools/*) \
echo 'providers static audit config conversation' ;;
install.sh) echo 'config audit providers remote static' ;;
mirror/*) echo 'mirror static' ;;
# static.bats asserts on files it does not own, so a shipped doc or a
# docker/ file needs it as well as whatever else the change touched -
@@ -105,13 +106,14 @@ suites_for() {
# would otherwise answer for it. The docs themselves are asserted
# against by more than static.bats - the README's axis lists, the
# provider tables, the nginx example and the remote-access page each
# have a suite checking the code agrees with them.
# have a suite checking the code agrees with them, and mirror.bats runs
# the GitHub rewrite over them.
#
# So a doc-only edit runs more than static alone. That is the cost of
# the lane answering for what reads a file, and it is the safe
# direction: narrowing this back would report a doc change as green
# from suites that assert against it.
docs/*|README.md|*.md) echo 'static config providers remote' ;;
docs/*|README.md|*.md) echo 'static config providers remote mirror' ;;
*) echo ALL ;;
esac
+1
View File
@@ -30,6 +30,7 @@ list_scripts() {
# these the last scripts that should go unchecked, not the first -
# and leaving them out hid a real fault in the release gate itself.
ls test/*.sh 2>/dev/null || true
ls mirror/*.sh 2>/dev/null || true
} | sort -u | while read -r f; do
[ -f "$f" ] || continue
# Sourced fragments are not executed, so they carry no shebang and the
+22 -3
View File
@@ -7825,8 +7825,9 @@ EOF
grep -qF "$piece" <<< "$rows" \
|| { echo "the README stack table has no row for $piece"; return 1; }
done
# Either host: mirror/github-mirror.sh rewrites these for the GitHub copy.
for piece in dejima-devbox-hardener dejima-guard dejima-ci-hardener; do
grep -qF "https://gitbox.branderson.io/dejima/$piece" <<< "$rows" \
grep -qE "https://(gitbox\.branderson\.io/dejima|github\.com/dejima-org)/$piece" <<< "$rows" \
|| { echo "$piece is named in the README but not linked"; return 1; }
done
@@ -7898,9 +7899,9 @@ EOF
# Linked, like the other two, and pointed at its own SECURITY.md for the
# comparisons this doc deliberately does not lead with.
local tm="$REPO_ROOT/docs/threat-model.md"
grep -q '(https://gitbox.branderson.io/dejima/dejima-guard)' "$tm" \
grep -qE '\(https://(gitbox\.branderson\.io/dejima|github\.com/dejima-org)/dejima-guard\)' "$tm" \
|| { echo "dejima-guard is named but not linked"; return 1; }
grep -q 'dejima-guard/src/branch/main/SECURITY.md' "$tm" \
grep -qE 'dejima-guard/(src/branch|blob)/main/SECURITY.md' "$tm" \
|| { echo "nothing points at the guard's own security policy"; return 1; }
}
@@ -9551,6 +9552,24 @@ _provider_proxied_domain() {
done
}
@test "the GitHub mirror is in the audit's TCB list, and is installed as a copy" {
# The timer runs it on the host with a key that can push to every
# repository in the organisation. A symlink into this checkout would run a
# session's edit within the hour, before any exit audit had seen it.
local dirs
dirs="$(sed -n '/^tcb_snapshot()/,/^}/p' "$REPO_ROOT/bin/dejima-audit" \
| sed -n 's/.*for dir in \(.*\); do/\1/p')"
case " $dirs " in
*" mirror "*) ;;
*) echo "mirror is not in the TCB list: $dirs"; return 1 ;;
esac
local unit="$REPO_ROOT/mirror/dejima-github-mirror.service"
grep -q '^# install -m 755 mirror/github-mirror.sh ~/.local/bin/dejima-github-mirror$' "$unit" \
|| { echo "the unit no longer installs the script as a copy"; return 1; }
run grep -n 'ln -s' "$unit"
[ "$status" -eq 1 ] || { echo "the unit links the script into the checkout: $output"; return 1; }
}
@test "the transcript format is the harness's, the store is dejima's" {
# What a compaction looks like in a transcript is a fact about the
# harness; where the summary is kept, that nothing mounts it, and what