release: migrate distribution to Forgejo and improve safety

ober

cff4ec15a766f6cd26a472f5449930e471174db2

diff --git a/AGENTS.md b/AGENTS.md
index 0dec250..ed19f0f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -370,18 +370,16 @@ improved versions of the above.
 
 When working in a Jerboa project, **ONLY modify files in the current repo** unless the user explicitly names another path.
 
-### Release Distribution: SourceHut Only
-
-**Never use GitHub Releases, GitHub release URLs, or GitHub as a fallback for
-Jerboa release artifacts.** Jerboa's sole official release channel is attached
-files on a versioned SourceHut git tag. Publish them with
-`hut git -r ~lisp/jerboa artifact upload <file> --rev <tag>` (normally through
-`make release-upload`) and download them from
-`https://git.sr.ht/~lisp/jerboa/refs/download/<tag>/<artifact>`.
-
-Do not add a GitHub mirror or fallback when SourceHut delivery needs work;
-repair or configure the SourceHut release path instead. See the SourceHut
-manual: <https://man.sr.ht/git.sr.ht/#attaching-files-to-releases>.
+### Release Distribution: git.jerboa.sh Only
+
+**Never use SourceHut or GitHub Releases, release URLs, mirrors, or fallbacks
+for Jerboa artifacts.** The canonical human-facing repository URL is
+`https://git.jerboa.sh/ober/jerboa`. Publish release files on the
+matching versioned release at `git.jerboa.sh`; consumers download them from
+`https://git.jerboa.sh/ober/jerboa/releases/download/<tag>/<artifact>`.
+
+If delivery needs work, repair or configure the `git.jerboa.sh` release path.
+Do not reintroduce SourceHut or add a GitHub mirror.
 
 Common sibling repos that exist but must NOT be touched without explicit instruction:
 - `~/mine/jerboa-mcp` — Legacy node MCP, superseded. The active MCP server now lives in THIS repo at `mcp/` + `data/`. Don't modify the legacy repo unless told.
diff --git a/Makefile b/Makefile
index bb08ebd..a47f576 100644
--- a/Makefile
+++ b/Makefile
@@ -336,7 +336,8 @@ help:
 	@echo "                   (cross targets need: make chez-cross CHEZ_TARGET_MACHINE=ta6le|tarm64le|ta6fb ...)"
 	@echo "  release-artifact Package one built target: RELEASE_VERSION=v$(PROJECT_VERSION) RELEASE_TARGET=linux-amd64"
 	@echo "  release-artifacts Build/package all release targets"
-	@echo "  release-upload   Upload dist/release artifacts to a SourceHut git tag via hut"
+	@echo "  release-upload   Upload signed dist/release artifacts to Forgejo (requires FORGEJO_TOKEN)"
+	@echo "  jpkg-workspace   Verify/pack jerboa*/jpkg.sexp repos under JPKG_WORKSPACE_ROOT"
 	@echo "  native-cross     Cross-build Rust native lib for a target"
 	@echo "                   Args: CHEZ_TARGET_MACHINE=<mt> CROSS_CC=<cc> [CROSS_NATIVE_FEATURES=tls,...]"
 	@echo "  typecheck        Typecheck Typed Jerboa sources"
@@ -853,25 +854,25 @@ check-cross-tools:
 
 jerboa-portable: check-cross-tools jerboa-macos-arm64 jerboa-linux-amd64 jerboa-linux-arm64 jerboa-freebsd-amd64
 
-# ── Release packaging for SourceHut tag artifacts ───────────────────────────
-# Native SourceHut jobs call `release-artifact` after `make jerboa`. Local
-# maintainers can call `release-artifacts` to cross-build and package all
-# supported bootstrap toolchains in one pass.
+# ── Release packaging for Forgejo releases ─────────────────────────────────
+# Run `release-artifact` natively on each release host, consolidate the output,
+# sign it once, then upload the complete set to the matching Forgejo release.
 RELEASE_VERSION ?= $(shell git describe --tags --exact-match 2>/dev/null || printf 'v%s\n' "$(PROJECT_VERSION)")
 RELEASE_TARGET ?= $(shell host="$(HOST_UNAME_S)-$(HOST_UNAME_M)"; if [ "$(HOST_UNAME_O)" = Android ] && { [ "$(HOST_UNAME_M)" = aarch64 ] || [ "$(HOST_UNAME_M)" = arm64 ]; }; then echo android-arm64; elif [ "$$host" = Darwin-arm64 ]; then echo macos-arm64; elif [ "$$host" = Linux-x86_64 ]; then echo linux-amd64; elif [ "$$host" = Linux-aarch64 ] || [ "$$host" = Linux-arm64 ]; then echo linux-arm64; elif [ "$$host" = FreeBSD-amd64 ] || [ "$$host" = FreeBSD-x86_64 ]; then echo freebsd-amd64; else echo unknown; fi)
-RELEASE_TARGETS ?= macos-arm64 linux-amd64 linux-arm64 freebsd-amd64
+RELEASE_TARGETS ?= macos-arm64 linux-amd64 freebsd-amd64
 RELEASE_DIR ?= dist/release
-RELEASE_REPO ?= ~lisp/jerboa
+FORGEJO_OWNER ?= ober
+FORGEJO_REPO ?= jerboa
 EVIDENCE_DIR ?= dist/release-evidence
 SIGNING_EVIDENCE_DIR ?= dist/signing-evidence
 SBOM_DIR ?= dist/sbom
 REPRO_DIR ?= dist/reproducibility
 
-.PHONY: release-artifact release-artifacts sign-release-artifacts signing-evidence release-upload sbom reproducibility-report release-evidence
+.PHONY: release-artifact release-artifacts sign-release-artifacts signing-evidence release-upload jpkg-workspace sbom reproducibility-report release-evidence
 release-artifact:
 	@support/package-jerboa-release.sh "$(RELEASE_VERSION)" "$(RELEASE_TARGET)" "$(RELEASE_DIR)"
 
-release-artifacts: jerboa-portable
+release-artifacts: jerboa-macos-arm64 jerboa-linux-amd64 jerboa-freebsd-amd64
 	@for target in $(RELEASE_TARGETS); do \
 		support/package-jerboa-release.sh "$(RELEASE_VERSION)" "$$target" "$(RELEASE_DIR)"; \
 	done
@@ -887,7 +888,7 @@ release-upload:
 		JERBOA_REQUIRE_RELEASE_SIGNATURES=1 \
 		JERBOA_REQUIRE_RELEASE_SIGNATURE_VERIFICATION=1 \
 		support/sign-release-artifacts.sh check
-	@support/sourcehut-release-upload.sh "$(RELEASE_VERSION)" "$(RELEASE_REPO)" \
+	@support/forgejo-release-upload.sh "$(RELEASE_VERSION)" "$(FORGEJO_OWNER)" "$(FORGEJO_REPO)" \
 		"$(RELEASE_DIR)"/jerboa-"$(RELEASE_VERSION)"-*.tar.gz \
 		"$(RELEASE_DIR)"/jerboa-"$(RELEASE_VERSION)"-*.tar.gz.sha256 \
 		"$(RELEASE_DIR)"/jerboa-"$(RELEASE_VERSION)"-*.tar.gz.asc \
@@ -905,6 +906,13 @@ release-upload:
 		"$(RELEASE_DIR)"/install.sh.sig \
 		"$(RELEASE_DIR)"/install.sh.minisig
 
+JPKG_WORKSPACE_ROOT ?=
+JPKG_WORKSPACE_OUT ?= $(CURDIR)/dist/jpkg-workspace
+
+jpkg-workspace:
+	@test -n "$(JPKG_WORKSPACE_ROOT)" || { echo "ERROR: set JPKG_WORKSPACE_ROOT to the directory containing jerboa* repositories" >&2; exit 1; }
+	@support/pack-jpkg-workspace.sh "$(JPKG_WORKSPACE_ROOT)" "$(JPKG_WORKSPACE_OUT)"
+
 sbom:
 	JERBOA_SBOM_DIR="$(SBOM_DIR)" SCHEME="$(SCHEME)" support/sbom.sh
 
diff --git a/branches.md b/branches.md
new file mode 100644
index 0000000..b10f814
--- /dev/null
+++ b/branches.md
@@ -0,0 +1,27 @@
+# Branch review
+
+Reviewed 2026-07-30 against `master` (`origin/master`). “Ahead” and “behind” are
+the counts from `git rev-list --left-right --count master...<branch>`.
+
+## Unmerged branches
+
+| Branch | Remote/local refs | Ahead | Behind | Latest commit |
+|---|---|---:|---:|---|
+| `typed` | local, `origin/typed` | 3 | 0 | `0ec12c3c` remove DuckDB native integration |
+| `cross` | local, `origin/cross` | 10 | 618 | `c9251c22` add docs |
+| `secure` | local, `origin/secure` | 8 | 864 | `1668942e` Update secure.md: all 6 implementation items complete |
+| `security/p0-audit-fixes` | local, `origin/security/p0-audit-fixes` | 3 | 163 | `e374ab65` data: add P0 prevention artifacts |
+| `origin/gc-improvement` | remote only | 32 | 230 | `4b2954f3` Unify AGENTS.md with canonical jerboa version |
+| `wip/native-hmac-ed25519-verify` | local only | 1 | 705 | `0bc3ddcf` WIP: HMAC-SHA512 + ed25519 verify FFI exports |
+| `github/main` | remote default branch | 1 | 1091 | `03ece279` jerboa binary release mirror |
+
+The current branch is `typed`; no unmerged branches were deleted.
+
+## Deleted merged branches
+
+- Local: `browser-repl-plan`, `improve-gc`, `jerboa-on-jerboa`, `rust-or-bust`,
+  `slang2`, `worktree-agent-a2fd2155`.
+- `origin`: `browser-repl-plan`, `jerboa-on-jerboa`, `rust-or-bust`, `slang2`.
+- `github`: `master` (merged non-default branch; `github/main` was retained).
+
+The primary branches `master`, `origin/master`, and `github/main` were retained.
diff --git a/data/anti-patterns.sexp b/data/anti-patterns.sexp
index 7cedcfa..08a5c7c 100644
--- a/data/anti-patterns.sexp
+++ b/data/anti-patterns.sexp
@@ -5945,4 +5945,23 @@
    ("title"
      .
      "Jerboa prelude traps: keep, sleep, and make-mutex mismatch")
-   ("tools" "jerboa_eval" "jerboa_module_exports")))
+   ("tools" "jerboa_eval" "jerboa_module_exports"))
+ (("advice"
+    .
+    "When a closer-run refactor touches a multi-argument core form (dynamic-wind, guard, call-with-values), verify the ARG COUNT by reading the form after jerboa_check_balance passes. Treat any build warning of the form \"possible incorrect argument count in call (<core-form> ...)\" as build-breaking: find it, fix it, rebuild until silent. After every server.ss change, boot the binary once and confirm it answers a query — jerboa-ss-boot-smoke.")
+   ("avoid"
+     .
+     "Assuming jerboa_check_balance (or a successful compile) means the form structure is right, and ignoring \"possible incorrect argument count in call (dynamic-wind ...)\" compile warnings. A paren moved one slot inside a closer run keeps the file balanced but nests an intended argument (e.g. the dynamic-wind OUT thunk) inside another lambda body, where it is silently discarded — the call then fails only at runtime, in a path unit tests never exercise.")
+   ("id" . "balanced-but-wrong-arity-closer-run")
+   ("kinds" "edit" "refactor" "review")
+   ("pattern" . "possible incorrect argument count in call")
+   ("severity" . "high")
+   ("tags" "dynamic-wind" "paren-balance" "compiler-warning"
+     "closer-run" "startup-crash")
+   ("title"
+     .
+     "Balanced file, wrong arity: paren drift inside closer runs survives check_balance")
+   ("tools"
+     "jerboa_check_balance"
+     "jerboa_verify"
+     "jerboa_compile_check")))
diff --git a/data/error-fixes.sexp b/data/error-fixes.sexp
index a35a9be..a4a1783 100644
--- a/data/error-fixes.sexp
+++ b/data/error-fixes.sexp
@@ -3362,4 +3362,16 @@
      "Use the quoted empty list `'()` when assigning or passing an empty list value; reserve square brackets for grouped forms with content.")
    ("id" . "empty-square-brackets-expression-invalid")
    ("pattern" . "invalid syntax \\(\\).*\\(set! .* \\[\\]\\)")
-   ("type" . "Syntax Error")))
+   ("type" . "Syntax Error"))
+ (("code_example"
+    .
+    ";; BAD (raises whenever prelude is imported):\n;; (sleep (make-time 'time-duration 0 1))\n;; GOOD:\n(sleep-ms 1000)  ;; prelude export, uses %chez-make-time internally")
+   ("explanation"
+     .
+     "Files importing (jerboa prelude) get its shadowed make-time. (sleep (make-time 'time-duration ns sec)) then raises \"not a time record of type time-duration\" — deterministically, in both interpreter and embedded builds. This silently killed jdns background threads (CDB reloader, TCP accept loop) because each called a locally-defined sleep-ms built on the shadowed make-time.")
+   ("fix"
+     .
+     "Do not pass (make-time ...) to Chez `sleep` when (jerboa prelude) is imported: the prelude shadows make-time with the (std datetime) constructor, which produces a dt-raw record, not a Chez time record. Use the prelude's own (sleep-ms ms) instead (it internally uses a private %chez-make-time). If you must build a duration by hand, re-import Chez's make-time under a rename: (rename (only (chezscheme) make-time) (make-time %chez-make-time)).")
+   ("id" . "sleep-prelude-make-time-type-mismatch")
+   ("pattern" . "is not a time record of type time-duration")
+   ("type" . "runtime")))
diff --git a/data/features.sexp b/data/features.sexp
index d8f184c..e33c518 100644
--- a/data/features.sexp
+++ b/data/features.sexp
@@ -4008,4 +4008,24 @@
    ("use_case"
      .
      "Saving discoveries and checking duplicates should not be blocked by one malformed or compound anti-pattern field.")
+   ("votes" . 0))
+ (("description"
+    .
+    "anti_pattern_lookup crashed with `Exception in string-downcase: (\"FileNotFoundException.*\\\\.csv|AssetManager.*open|box_types\") is not a string` while searching for sibling-checkout build-file anti-patterns. The lookup path should validate or stringify pattern fields so a malformed entry cannot prevent discovery or duplicate checks.") ("estimated_token_reduction" . "")
+   ("example_scenario"
+     .
+     "Call anti_pattern_lookup with query `build files fallback to ../jerboa sibling checkout or ~/mine/jerboa absolute path`; lookup should skip or normalize malformed entries and return matches instead of throwing.")
+   ("id" . "anti-pattern-lookup-non-string-pattern-guard")
+   ("impact"
+     .
+     "Restores reliable duplicate checking before adding anti-patterns and prevents agents from bypassing the knowledge-management workflow.")
+   ("status" . "open")
+   ("tags" "anti-patterns" "lookup" "knowledge-base" "tooling"
+     "robustness")
+   ("title"
+     .
+     "Guard anti_pattern_lookup against non-string pattern fields")
+   ("use_case"
+     .
+     "Before saving a reusable anti-pattern, agents must search existing entries. A single non-string pattern currently blocks that mandatory workflow.")
    ("votes" . 0)))
diff --git a/docs/jerboa-for-clojure-devs.md b/docs/jerboa-for-clojure-devs.md
index ec2c859..4dfcaa9 100644
--- a/docs/jerboa-for-clojure-devs.md
+++ b/docs/jerboa-for-clojure-devs.md
@@ -5,7 +5,7 @@ Jerboa is a Chez Scheme dialect that provides a Clojure-compatible surface for d
 ## Installation
 
 ```bash
-git clone https://github.com/jafourni/jerboa.git
+git clone https://git.jerboa.sh/ober/jerboa
 cd jerboa
 make build
 ```
diff --git a/docs/jpkg-migration.md b/docs/jpkg-migration.md
index cab7aa4..e52a91a 100644
--- a/docs/jpkg-migration.md
+++ b/docs/jpkg-migration.md
@@ -22,7 +22,7 @@ For a leaf package (no jerboa-* dependencies):
 
 ```sh
 cd ~/mine/jerboa-gitsafe
-jerboa pkg init @lisp/gitsafe        # creates jpkg.sexp
+jerboa pkg init @ober/gitsafe        # creates jpkg.sexp
 # edit jpkg.sexp: set version, license, (modules ((root "gitsafe"))), jerboa req
 jerboa pkg verify                    # manifest is valid
 jerboa pkg pack                      # → lisp-gitsafe-X.Y.Z.jpkg (reproducible)
@@ -37,16 +37,16 @@ declare the dependencies in the dependent's `jpkg.sexp` and `jpkg add` them.
 
 ## 1. Decide scope and identity
 
-All these repos live in the sourcehut `~lisp` namespace, so use the scope
-**`@lisp`**. The package name should match the repo's purpose, not
+All these repos live in the `git.jerboa.sh` `ober` namespace, so use the scope
+**`@ober`**. The package name should match the repo's purpose, not
 necessarily the repo name:
 
 | Repo | Suggested package |
 |---|---|
-| `jerboa-gitsafe` | `@lisp/gitsafe` |
-| `jerboa-websearch` | `@lisp/websearch` (or `@lisp/jerbsearch`) |
-| `jerboa-secmonlib` | `@lisp/secmon` |
-| `jerboa-crypto` | `@lisp/crypto` |
+| `jerboa-gitsafe` | `@ober/gitsafe` |
+| `jerboa-websearch` | `@ober/websearch` (or `@ober/jerbsearch`) |
+| `jerboa-secmonlib` | `@ober/secmon` |
+| `jerboa-crypto` | `@ober/crypto` |
 
 Scopes are how delegation and publisher trust are organized later (§6), so
 keep them consistent.
@@ -55,17 +55,17 @@ keep them consistent.
 
 ## 2. Add a manifest (`jpkg.sexp`)
 
-Run `jerboa pkg init @lisp/<name>`, then edit. The key field is `modules`'
+Run `jerboa pkg init @ober/<name>`, then edit. The key field is `modules`'
 `root` — point it at the directory that actually holds the package's `.ss`
 sources (this is what `pack` ships and what consumers import):
 
 ```scheme
 (package
-  (name "@lisp/gitsafe")
+  (name "@ober/gitsafe")
   (version "0.3.0")                 ; pick a real semver; bump on release
   (description "Git secret scanner")
   (license "Apache-2.0")
-  (source "https://git.sr.ht/~lisp/jerboa-gitsafe")
+  (source "https://git.jerboa.sh/ober/jerboa-gitsafe")
   (jerboa ">=0.1.0")
   (modules ((root "gitsafe")        ; gitsafe/*.ss   (src/ for most repos)
             (exports ((gitsafe main) (gitsafe scanner)))))
@@ -122,27 +122,27 @@ resolved from a registry. For local and team use, a self-hosted registry is
 one directory plus one key.
 
 ```sh
-mkdir -p ~/srv/jpkg/lisp
-# one signing key for the @lisp scope (keep it safe; mode 0600 is set for you)
-jerboa pkg publish --registry ~/srv/jpkg/lisp --key ~/.keys/lisp.key --keygen
+mkdir -p ~/srv/jpkg/ober
+# one signing key for the @ober scope (keep it safe; mode 0600 is set for you)
+jerboa pkg publish --registry ~/srv/jpkg/ober --key ~/.keys/ober.key --keygen
 
-# point your shell at it (or: jerboa pkg dir add lisp ~/srv/jpkg/lisp)
-export JERBOA_PKG_REGISTRIES="lisp=$HOME/srv/jpkg/lisp"
+# point your shell at it (or: jerboa pkg dir add ober ~/srv/jpkg/ober)
+export JERBOA_PKG_REGISTRIES="ober=$HOME/srv/jpkg/ober"
 ```
 
 The first publish bootstraps a TUF registry whose root/targets/snapshot/
-timestamp authority and `@lisp` publisher key are all this key — fine for
+timestamp authority and `@ober` publisher key are all this key — fine for
 self-hosting. (Split the roles later via key rotation + delegation, §6.)
 
 Publish each leaf package:
 
 ```sh
 cd ~/mine/jerboa-secmonlib
-jerboa pkg publish --registry ~/srv/jpkg/lisp --key ~/.keys/lisp.key \
-                   --source "https://git.sr.ht/~lisp/jerboa-secmonlib"
+jerboa pkg publish --registry ~/srv/jpkg/ober --key ~/.keys/ober.key \
+                   --source "https://git.jerboa.sh/ober/jerboa-secmonlib"
 ```
 
-Now `@lisp/secmon` is a verifiable, immutable release with a signature,
+Now `@ober/secmon` is a verifiable, immutable release with a signature,
 provenance, and a transparency-log entry.
 
 ---
@@ -150,12 +150,12 @@ provenance, and a transparency-log entry.
 ## 5. Convert cross-repo dependencies
 
 Suppose `jerboa-secmon` uses code currently checked out from
-`jerboa-secmonlib`. After publishing `@lisp/secmon` (the lib):
+`jerboa-secmonlib`. After publishing `@ober/secmon` (the lib):
 
 ```sh
 cd ~/mine/jerboa-secmon
-jerboa pkg init @lisp/secmon-app            # if not already
-jerboa pkg add @lisp/secmon                 # resolves, writes jpkg.lock, installs
+jerboa pkg init @ober/secmon-app            # if not already
+jerboa pkg add @ober/secmon                 # resolves, writes jpkg.lock, installs
 git add jpkg.sexp jpkg.lock
 ```
 
@@ -175,9 +175,9 @@ every change — link the working checkout:
 
 ```sh
 cd ~/mine/jerboa-secmon
-jerboa pkg link @lisp/secmon ~/mine/jerboa-secmonlib
+jerboa pkg link @ober/secmon ~/mine/jerboa-secmonlib
 # …edit the lib freely; the app sees changes immediately…
-jerboa pkg unlink @lisp/secmon         # back to the registry version
+jerboa pkg unlink @ober/secmon         # back to the registry version
 ```
 
 Linked deps are recorded in `jpkg.lock` as **non-reproducible**;
@@ -192,7 +192,7 @@ release.
 Once more than one person publishes, split trust:
 
 - **Delegate the scope** to maintainer keys with a threshold, so publishing
-  to `@lisp/*` doesn't require the registry root key.
+  to `@ober/*` doesn't require the registry root key.
 - **Rotate** the root to offline-held keys.
 - **Transfer** a package's namespace to a new maintainer set when ownership
   changes.
@@ -216,14 +216,14 @@ jerboa pkg audit                     # advisories, yanks, transparency
 
 For each `jerboa-*` repo:
 
-- [ ] `jerboa pkg init @lisp/<name>`; set version, license, `source`.
+- [ ] `jerboa pkg init @ober/<name>`; set version, license, `source`.
 - [ ] Set `(modules ((root "<srcdir>") (exports …)))` to the real source dir.
 - [ ] Declare `capabilities` matching `.jerbuild` (native/ffi/network/exec).
 - [ ] `jerboa pkg verify` passes.
 - [ ] `jerboa pkg pack` produces a `.jpkg`; `jerboa pkg verify FILE.jpkg` passes.
 - [ ] Commit `jpkg.sexp` (and `jpkg.lock` once it has deps).
 - [ ] Publish to the registry; confirm with `jerboa pkg search <name>`.
-- [ ] Replace sibling/vendored deps with `jerboa pkg add @lisp/<dep>`.
+- [ ] Replace sibling/vendored deps with `jerboa pkg add @ober/<dep>`.
 - [ ] Wire dep paths into the build via `jerboa pkg env` / `.jerbuild` libdirs.
 - [ ] In CI, set `JPKG_REQUIRE_SIGNATURES` and run `jerboa pkg audit`.
 
@@ -246,7 +246,7 @@ For each `jerboa-*` repo:
 
 1. Migrate **leaf** libraries first (no jerboa-* deps): `jerboa-crypto`,
    `jerboa-secmonlib`, `jerboa-compat`, `jerboa-temp-dir`, etc.
-2. Stand up the `~lisp` registry (§4) and publish those leaves.
+2. Stand up the `@ober` registry (§4) and publish those leaves.
 3. Migrate **mid-tier** packages that depend on the leaves; `add` the deps.
 4. Migrate **apps** (binaries) last; they pin everything via `jpkg.lock`.
 5. Turn on fail-closed policy + `audit` in each repo's CI.
diff --git a/docs/kimi3-security-recommmendations.md b/docs/kimi3-security-recommmendations.md
index b02ad29..1d616f3 100644
--- a/docs/kimi3-security-recommmendations.md
+++ b/docs/kimi3-security-recommmendations.md
@@ -941,7 +941,7 @@ Chez cannot heap-cap a thread; `run-safe-eval` rightly refuses
   helper enters
   Capsicum before reading stdin or evaluating the supplied expression for
   supported `fs`/`exec`/`net`/`capsicum` axes. Per-platform CI parity now has a
-  macOS Seatbelt worker smoke workflow and a SourceHut FreeBSD Capsicum worker
+  macOS Seatbelt worker smoke workflow and a FreeBSD Capsicum worker
   smoke manifest. Arbitrary-command/proxy-aware Capsicum parity remains open.
 
 ### K3-P1-09 — Confused-deputy defenses: capability plans enforced at runtime
diff --git a/docs/quickstart.md b/docs/quickstart.md
index ead7727..bbcb384 100644
--- a/docs/quickstart.md
+++ b/docs/quickstart.md
@@ -5,7 +5,7 @@ Get from zero to a working Jerboa program in 5 minutes.
 ## 1. Get Jerboa
 
 ```bash
-git clone https://git.sr.ht/~lisp/jerboa
+git clone https://git.jerboa.sh/ober/jerboa
 cd jerboa
 ```
 
diff --git a/docs/release-artifacts.md b/docs/release-artifacts.md
index 7161d7b..46debf2 100644
--- a/docs/release-artifacts.md
+++ b/docs/release-artifacts.md
@@ -5,19 +5,16 @@ build without cloning and compiling this repository first.
 
 ## Distribution Channel
 
-SourceHut git-tag artifacts are the only official release channel. Never
-publish or download Jerboa release artifacts through GitHub Releases, including
-as a mirror or fallback. Attach each signed release file to its versioned
-SourceHut tag with `hut git -r ~lisp/jerboa artifact upload <file> --rev <tag>`;
-`make release-upload` performs this for the complete release set. Consumers
-download assets at:
+The Jerboa repository and its versioned releases on `git.jerboa.sh` are the
+only official distribution channel. Never publish or download Jerboa release
+artifacts through SourceHut or GitHub Releases, including as a mirror or
+fallback. Attach each signed release file to its matching versioned release at
+`https://git.jerboa.sh/ober/jerboa`. Consumers download assets at:
 
 ```text
-https://git.sr.ht/~lisp/jerboa/refs/download/<tag>/<artifact>
+https://git.jerboa.sh/ober/jerboa/releases/download/<tag>/<artifact>
 ```
 
-See [SourceHut's release-artifact manual](https://man.sr.ht/git.sr.ht/#attaching-files-to-releases).
-
 Each release target produces:
 
 ```text
@@ -60,7 +57,7 @@ Once you have that small local trust file, the Linux AMD64 install is:
 
 ```sh
 version=vX.Y.Z # exact release tag
-base="https://git.sr.ht/~lisp/jerboa/refs/download/$version"
+base="https://git.jerboa.sh/ober/jerboa/releases/download/$version"
 trust_file="$HOME/.config/jerboa/release.allowed_signers"
 identity='jerboa-release@<publisher-domain>'
 
@@ -101,8 +98,111 @@ installer accepts `--allowed-signers` and `--signer-identity` so its required
 trust inputs are visible in the command rather than hidden in environment
 variables.
 
+### FreeBSD amd64
+
+FreeBSD's base system provides `fetch`, `sha256`, `tar`, and `ssh-keygen`, so no
+package manager dependency is required for the release installer. Confirm that
+the machine reports `FreeBSD` and `amd64`, then use the authenticated installer
+flow above; it selects `freebsd-amd64` automatically:
+
+```sh
+uname -s
+uname -m
+
+version=vX.Y.Z # replace with an exact tag that has attached artifacts
+base="https://git.jerboa.sh/ober/jerboa/releases/download/$version"
+trust_file="$HOME/.config/jerboa/release.allowed_signers"
+identity='jerboa-release@<publisher-domain>'
+
+mkdir -p "$(dirname "$trust_file")"
+# Populate $trust_file with the independently verified release signer.
+fetch -o install.sh "$base/install.sh"
+fetch -o install.sh.sig "$base/install.sh.sig"
+ssh-keygen -Y verify \
+  -f "$trust_file" -I "$identity" -n file \
+  -s install.sh.sig < install.sh
+chmod 0755 install.sh
+./install.sh --version "$version" \
+  --allowed-signers "$trust_file" --signer-identity "$identity"
+
+export PATH="$HOME/.local/bin:$PATH"
+jerboa version
+jerbuild --version
+```
+
+Replace `vX.Y.Z` with the exact release tag you intend to install; the tag must
+have an installer, signature, manifest, and FreeBSD archive attached. Make the
+`PATH` change persistent in `~/.profile` if necessary. For a system-wide
+installation, run the verified installer as an administrator with
+`--prefix /usr/local`; do not run an unverified network response as root.
+
+### Install from an already verified artifact or binary
+
+The FreeBSD artifact is named
+`jerboa-<tag>-freebsd-amd64.tar.gz`. After authenticating
+`release-manifest.sha256` with the independently obtained release key and
+checking the archive digest, install its multicall binary directly:
+
+```sh
+version=vX.Y.Z # replace with an exact tag that has attached artifacts
+archive="jerboa-${version}-freebsd-amd64.tar.gz"
+root="jerboa-${version}-freebsd-amd64"
+
+tar -xzf "$archive"
+mkdir -p "$HOME/.local/bin"
+install -m 0755 "$root/bin/jerboa" "$HOME/.local/bin/jerboa"
+for command in jerbuild jpkg jmcp jlsp; do
+  ln -sf jerboa "$HOME/.local/bin/$command"
+done
+
+"$HOME/.local/bin/jerboa" version
+```
+
+If you receive only the extracted `jerboa` binary, copy it with the same
+`install -m 0755` command and create the four symlinks. A bare binary has no
+built-in provenance: authenticate its digest against the signed release
+manifest before executing it. Keep the archive's `NOTICE`, `LICENSE-CHEZ`, and
+`share/licenses/jerboa/` files when redistributing the binary.
+
 ## Build Locally
 
+Build each requested release target natively on the matching architecture and
+operating system. From an exact `vX.Y.Z` tag, run:
+
+```sh
+make jerboa
+make release-artifact RELEASE_VERSION=vX.Y.Z
+```
+
+The detected targets are `linux-amd64`, `freebsd-amd64`, and `macos-arm64`.
+Copy the three resulting archives and `.sha256` files into one trusted
+`dist/release/` directory on the signing host. Sign and verify the complete set:
+
+```sh
+JERBOA_RELEASE_SIGNING_TOOL=ssh-keygen \
+JERBOA_RELEASE_SSH_SIGNING_KEY=/path/to/release-key \
+make sign-release-artifacts
+
+JERBOA_RELEASE_SSH_ALLOWED_SIGNERS=/path/to/release.allowed_signers \
+JERBOA_RELEASE_SSH_SIGNER_IDENTITY='jerboa-release@example.org' \
+make signing-evidence
+```
+
+Create a Forgejo API token with repository write permission, keep it out of
+shell history, and upload the verified release set:
+
+```sh
+export FORGEJO_TOKEN
+make release-upload RELEASE_VERSION=vX.Y.Z
+unset FORGEJO_TOKEN
+```
+
+`release-upload` creates a draft Forgejo release when necessary, uploads every
+signed asset, and publishes it only after all uploads succeed. Existing assets
+are never overwritten unless `FORGEJO_REPLACE_ASSETS=1` is explicitly set.
+The exact tag must exist locally; protected remote-only builders can opt in with
+`FORGEJO_ALLOW_UNTAGGED_RELEASE=1` after independently verifying the remote tag.
+
 ```sh
 make release-artifacts
 ```
@@ -378,10 +478,11 @@ missing. Use `JERBOA_WASM_GC_RELEASE_ENGINES` only to split the same hard gate
 across hosts; do not use skipped engines as release evidence. Safari remains a
 supported split gate on hosts where remote automation has been enabled.
 
-## SourceHut CI
+## Release builders
 
-The `.builds/release-*.yml` manifests build Linux and FreeBSD release targets
-natively on SourceHut:
+The checked-in `.builds/release-*.yml` files are legacy build manifests. They
+describe the native Linux and FreeBSD release targets but are not an official
+artifact host:
 
 ```text
 .builds/release-linux-amd64.yml
@@ -390,28 +491,16 @@ natively on SourceHut:
 ```
 
 The `macos-arm64` artifact is produced on an Apple Silicon macOS host with the
-same `make release-artifact RELEASE_TARGET=macos-arm64` packaging path, then
-uploaded to the release tag with `make release-upload`.
-
-They are constrained to `refs/tags/v*`, so ordinary branch pushes do not run
-the release builders. The build job uses SourceHut's `GIT_REF` environment
-variable to name the artifact, because the checkout itself is pinned by commit
-hash. Every successful job exposes short-lived build artifacts for inspection.
-Tag builds also run:
-
-```sh
-make release-upload RELEASE_VERSION="$tag"
-```
+same `make release-artifact RELEASE_TARGET=macos-arm64` packaging path.
 
-`release-upload` now requires complete, locally verified signatures (including
-the installer/manifest SSH signatures) and fails closed when verification
-material is absent. The ordinary SourceHut builders intentionally cannot
-promote their unsigned outputs; a protected signing/promotion environment must
-provide the signed candidate and consumer trust policy. Promotion attaches the
-versioned tarball, `.sha256` file, authenticated installer, detached signatures,
-and `release-manifest.sha256` to the SourceHut git tag with
-`hut git artifact upload --rev "$tag"`. SourceHut job artifacts are pruned; git
-tag artifacts are the stable download channel.
+Release builders must pin the checkout by commit and derive releases only from
+`refs/tags/v*`; ordinary branch pushes are not release candidates. A protected
+signing/promotion environment must provide the signed candidate and consumer
+trust policy. Promotion attaches the versioned tarball, `.sha256` file,
+authenticated installer, detached signatures, and
+`release-manifest.sha256` to the matching release at
+`https://git.jerboa.sh/ober/jerboa/releases`. Only those versioned release
+attachments are the stable download channel.
 
 The `.sha256` files are integrity evidence, not publisher signatures. Release
 consumers should verify the detached signatures and compare uploaded checksums
@@ -442,12 +531,13 @@ $(JERBOA_BINDIR)/jerboa:
 The bootstrap script downloads from:
 
 ```text
-https://git.sr.ht/~lisp/jerboa/refs/download/<tag>/<artifact>
+https://git.jerboa.sh/ober/jerboa/releases/download/<tag>/<artifact>
 ```
 
 The allowed-signers file must be committed or provisioned by the consuming
 project, not downloaded beside the artifact. Override `JERBOA_RELEASE_REPO` or
 `JERBOA_RELEASE_ORIGIN` only for a fork. `JERBOA_RELEASE_BASE` is reserved for
-local test fixtures; official artifacts are downloaded only from SourceHut.
+local test fixtures; official artifacts are downloaded only from
+`git.jerboa.sh`.
 Override `JERBOA_RELEASE_TARGET` only for tests or unusual hosts where `uname`
 detection is not enough.
diff --git a/docs/tutorial.md b/docs/tutorial.md
index 4743a16..be5905f 100644
--- a/docs/tutorial.md
+++ b/docs/tutorial.md
@@ -20,7 +20,7 @@ Jerboa program — the project's CI verifies that.
 - A clone of Jerboa with the local runtime built and `jerboa` on your `PATH`:
 
 ```bash
-git clone https://git.sr.ht/~lisp/jerboa
+git clone https://git.jerboa.sh/ober/jerboa
 cd jerboa
 make chez build
 export PATH="$PWD/bin:$PATH"
diff --git a/jpkg.sexp b/jpkg.sexp
index 8c5d619..8788a9f 100644
--- a/jpkg.sexp
+++ b/jpkg.sexp
@@ -1,5 +1,5 @@
 (package
-  (name "@local/jerboa")
+  (name "@ober/jerboa")
   (version "0.2.0")
   (description "")
   (license "UNLICENSED")
diff --git a/lib/jerboa/prelude/safe.ss b/lib/jerboa/prelude/safe.ss
index 342e9c0..b2c6165 100644
--- a/lib/jerboa/prelude/safe.ss
+++ b/lib/jerboa/prelude/safe.ss
@@ -100,6 +100,17 @@
     ;; Safe source wrappers
     getenv
 
+    ;; ---- AI compatibility aliases (must match (jerboa prelude)) ----
+    hash-has-key? hash-table-set!
+    directory-exists?
+    eql?
+    random-integer
+    read-line
+    force-output
+    string-map
+    processor-count
+    regex-match regex-search regex-replace regex-replace-all
+
     ;; ---- Safe APIs under STANDARD names ----
 
     ;; SQLite (literal-SQL macros over safe parameterized wrappers)
@@ -208,6 +219,7 @@
     (std misc list)
     (std misc alist)
     (except (std misc ports) read-file-string read-file-lines)
+    (std regex)
     ;; Safety modules
     (prefix (std safe) safe:)
     (prefix (std security taint) taint:)
@@ -363,4 +375,30 @@
   ;; Mode control
   (def *safe-mode*       safe:*safe-mode*)
 
+  ;; ---- AI compatibility aliases ----
+  (def hash-has-key? hash-key?)
+  (def hash-table-set! hash-put!)
+  (def directory-exists? file-directory?)
+  (def eql? eqv?)
+  (def random-integer random)
+  (def processor-count
+    ;; Safe fallback: no FFI. Use env var or default.
+    (let ([v (raw-getenv "JERBOA_PROCESSOR_COUNT")])
+      (if v (string->number v)
+          (let ([v2 (raw-getenv "NCPUS")])
+            (if v2 (string->number v2) 4)))))
+  (def (read-line . args)
+    (if (null? args)
+        (get-line (current-input-port))
+        (get-line (car args))))
+  (def (force-output . args)
+    (flush-output-port
+      (if (null? args) (current-output-port) (car args))))
+  (def (string-map f s)
+    (list->string (map f (string->list s))))
+  (def (regex-match pat str)       (re-search pat str))
+  (def (regex-search pat str)      (re-search pat str))
+  (def (regex-replace pat str rep) (re-replace pat str rep))
+  (def (regex-replace-all pat str rep) (re-replace-all pat str rep))
+
 ) ;; end library
diff --git a/lib/std/lsp/server.ss b/lib/std/lsp/server.ss
index c474786..073895a 100644
--- a/lib/std/lsp/server.ss
+++ b/lib/std/lsp/server.ss
@@ -43,7 +43,7 @@
   (def (read-message port)
     (let ([len (read-header port)])
       (and len
-           (let ([buf (get-bytevector-n (standard-input-port) len)])
+           (let ([buf (get-bytevector-n port len)])
              (and (bytevector? buf)
                   (string->json-object (utf8->string buf)))))))
 
@@ -53,9 +53,8 @@
            [len (bytevector-length bv)])
       (display (string-append "Content-Length: " (number->string len)
                               "\r\n\r\n") port)
-      (put-bytevector (standard-output-port) bv)
-      (flush-output-port port)
-      (flush-output-port (standard-output-port))))
+      (put-bytevector port bv)
+      (flush-output-port port)))
 
   ;; ---- Response helpers ----
 
diff --git a/lib/std/net/httpsd.ss b/lib/std/net/httpsd.ss
index 5a043ca..0676f53 100644
--- a/lib/std/net/httpsd.ss
+++ b/lib/std/net/httpsd.ss
@@ -307,7 +307,8 @@
     (cond
       [(<= need 0) ""]
       [else
-       (let* ([body-start (+ header-end 4)]
+       ;; httparse returns the byte offset immediately after CRLFCRLF.
+       (let* ([body-start header-end]
               [available (max 0 (- filled body-start))]
               [pre (min available need)]
               [out (make-bytevector need)])
diff --git a/lib/std/pkg/search.ss b/lib/std/pkg/search.ss
index 084d3a2..06077ca 100644
--- a/lib/std/pkg/search.ss
+++ b/lib/std/pkg/search.ss
@@ -180,7 +180,7 @@
   (def (dir-list)
     ;; env override shadows the file (read-only) — report both honestly.
     ;; When neither env nor file configures anything, registry-config falls
-    ;; back to the built-in default @lisp registry; report that as "default".
+    ;; back to the built-in default @ober registry; report that as "default".
     (let ([env (getenv "JERBOA_PKG_REGISTRIES")]
           [no-default (getenv "JPKG_NO_DEFAULT_REGISTRY")])
       (cond
diff --git a/lsp/analysis/document.ss b/lsp/analysis/document.ss
index 1314939..aa3405f 100644
--- a/lsp/analysis/document.ss
+++ b/lsp/analysis/document.ss
@@ -25,10 +25,11 @@
   ;; Convert file:// URI to filesystem path
   (define (uri->file-path uri)
     (cond
+      [(not (string? uri)) uri]
       [(string-prefix? "file://" uri)
        (let ([path (substring uri 7 (string-length uri))])
-         ;; Handle percent-encoding for spaces, etc.
-         (uri-decode path))]
+          ;; Handle percent-encoding for spaces, etc.
+          (uri-decode path))]
       [else uri]))
 
   ;; Convert filesystem path to file:// URI
diff --git a/lsp/handlers/lifecycle.ss b/lsp/handlers/lifecycle.ss
index fdc21f6..f5032d4 100644
--- a/lsp/handlers/lifecycle.ss
+++ b/lsp/handlers/lifecycle.ss
@@ -18,8 +18,9 @@
     (let ([root-uri (json-get params "rootUri")]
           [root-path (json-get params "rootPath")])
       ;; Set workspace root
-      (let ([root (or (and root-uri (uri->file-path root-uri)) root-path)])
-        (when root
+      (let ([root (or (and (string? root-uri) (uri->file-path root-uri))
+                      (and (string? root-path) root-path))])
+        (when (string? root)
           (server-state-workspace-root-set! state root)
           (lsp-info "Workspace root:" root)))
 
diff --git a/lsp/util/position.ss b/lsp/util/position.ss
index ab33f89..559ff2c 100644
--- a/lsp/util/position.ss
+++ b/lsp/util/position.ss
@@ -54,10 +54,13 @@
       (if (>= line (length lines))
         ""
         (let* ([ln (list-ref lines line)]
-               [end (min col (string-length ln))])
-          (let ([start (find-word-start ln (max 0 (- end 1)))])
-            (if (>= start end) ""
-              (substring ln start end)))))))
+               [len (string-length ln)])
+          (if (or (= len 0) (= col 0))
+            ""
+            (let* ([end (min col len)]
+                   [start (find-word-start ln (max 0 (- end 1)))])
+              (if (>= start end) ""
+                (substring ln start end))))))))
 
   (define (find-word-start str pos)
     (let loop ([i pos])
diff --git a/support/forgejo-release-upload.sh b/support/forgejo-release-upload.sh
new file mode 100755
index 0000000..2e6594f
--- /dev/null
+++ b/support/forgejo-release-upload.sh
@@ -0,0 +1,146 @@
+#!/bin/sh
+# Create or update a Forgejo release and upload authenticated artifacts.
+
+set -eu
+umask 077
+
+usage() {
+    echo "Usage: $0 VERSION OWNER REPO FILE..." >&2
+    echo "  requires FORGEJO_TOKEN with repository write permission" >&2
+}
+
+[ "${1:-}" ] || { usage; exit 2; }
+[ "${2:-}" ] || { usage; exit 2; }
+[ "${3:-}" ] || { usage; exit 2; }
+
+version=$1
+owner=$2
+repo=$3
+shift 3
+
+[ "$#" -gt 0 ] || { usage; exit 2; }
+[ -n "${FORGEJO_TOKEN:-}" ] || {
+    echo "ERROR: FORGEJO_TOKEN is required" >&2
+    exit 1
+}
+printf '%s\n' "$version" | grep -Eq '^v[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*$' || {
+    echo "ERROR: version must be an exact tag such as v0.2.8" >&2
+    exit 1
+}
+case "$owner/$repo" in
+    *[!A-Za-z0-9._/-]*) echo "ERROR: invalid Forgejo owner/repo" >&2; exit 1 ;;
+esac
+
+api=${FORGEJO_API_URL:-https://git.jerboa.sh/api/v1}
+publish=${FORGEJO_PUBLISH_RELEASE:-1}
+replace=${FORGEJO_REPLACE_ASSETS:-0}
+
+command -v curl >/dev/null 2>&1 || {
+    echo "ERROR: curl is required" >&2
+    exit 1
+}
+command -v jq >/dev/null 2>&1 || {
+    echo "ERROR: jq is required" >&2
+    exit 1
+}
+
+if git rev-parse --git-dir >/dev/null 2>&1 &&
+   ! git rev-parse -q --verify "refs/tags/$version" >/dev/null; then
+    [ "${FORGEJO_ALLOW_UNTAGGED_RELEASE:-0}" = 1 ] || {
+        echo "ERROR: $version is not a local tag" >&2
+        echo "Set FORGEJO_ALLOW_UNTAGGED_RELEASE=1 only when a protected builder verified the remote tag." >&2
+        exit 1
+    }
+fi
+
+tmp=$(mktemp -d "${TMPDIR:-/tmp}/jerboa-forgejo-upload.XXXXXX")
+trap 'rm -rf "$tmp"' EXIT HUP INT TERM
+
+request() {
+    method=$1
+    url=$2
+    output=$3
+    shift 3
+    curl --silent --show-error --location \
+        --request "$method" \
+        --header "Authorization: token $FORGEJO_TOKEN" \
+        --output "$output" \
+        --write-out '%{http_code}' \
+        "$@" "$url"
+}
+
+release_json="$tmp/release.json"
+status=$(request GET "$api/repos/$owner/$repo/releases/tags/$version" "$release_json")
+case "$status" in
+    200) ;;
+    404)
+        create_json="$tmp/create.json"
+        jq -n --arg tag "$version" --arg name "Jerboa $version" \
+            '{tag_name:$tag,name:$name,draft:true,prerelease:false}' > "$create_json"
+        status=$(request POST "$api/repos/$owner/$repo/releases" "$release_json" \
+            --header 'Content-Type: application/json' --data-binary "@$create_json")
+        [ "$status" = 201 ] || {
+            echo "ERROR: Forgejo release creation failed (HTTP $status)" >&2
+            jq -r '.message // empty' "$release_json" >&2 || true
+            exit 1
+        }
+        ;;
+    *)
+        echo "ERROR: Forgejo release lookup failed (HTTP $status)" >&2
+        jq -r '.message // empty' "$release_json" >&2 || true
+        exit 1
+        ;;
+esac
+
+release_id=$(jq -r '.id // empty' "$release_json")
+[ -n "$release_id" ] || { echo "ERROR: Forgejo response omitted release id" >&2; exit 1; }
+
+assets_json="$tmp/assets.json"
+status=$(request GET "$api/repos/$owner/$repo/releases/$release_id/assets" "$assets_json")
+[ "$status" = 200 ] || { echo "ERROR: asset listing failed (HTTP $status)" >&2; exit 1; }
+
+files=
+for file in "$@"; do
+    case "$file" in *'*'*) continue ;; esac
+    [ -f "$file" ] || continue
+    files="${files}${files:+
+}$file"
+done
+[ -n "$files" ] || { echo "ERROR: no release files matched" >&2; exit 1; }
+
+printf '%s\n' "$files" | while IFS= read -r file; do
+    name=$(basename "$file")
+    case "$name" in
+        ''|*[!A-Za-z0-9._-]*) echo "ERROR: unsupported asset name: $name" >&2; exit 1 ;;
+    esac
+    existing_id=$(jq -r --arg name "$name" '.[] | select(.name == $name) | .id' "$assets_json" | head -n 1)
+    if [ -n "$existing_id" ]; then
+        if [ "$replace" != 1 ]; then
+            echo "ERROR: release asset already exists: $name" >&2
+            echo "Set FORGEJO_REPLACE_ASSETS=1 to replace it explicitly." >&2
+            exit 1
+        fi
+        status=$(request DELETE "$api/repos/$owner/$repo/releases/$release_id/assets/$existing_id" "$tmp/delete.json")
+        [ "$status" = 204 ] || { echo "ERROR: deleting $name failed (HTTP $status)" >&2; exit 1; }
+    fi
+
+    echo "uploading $name to $owner/$repo release $version"
+    status=$(request POST "$api/repos/$owner/$repo/releases/$release_id/assets?name=$name" "$tmp/upload.json" \
+        --header 'Content-Type: application/octet-stream' --data-binary "@$file")
+    [ "$status" = 201 ] || {
+        echo "ERROR: uploading $name failed (HTTP $status)" >&2
+        jq -r '.message // empty' "$tmp/upload.json" >&2 || true
+        exit 1
+    }
+done
+
+if [ "$publish" = 1 ]; then
+    edit_json="$tmp/edit.json"
+    jq -n --arg tag "$version" --arg name "Jerboa $version" \
+        '{tag_name:$tag,name:$name,draft:false,prerelease:false}' > "$edit_json"
+    status=$(request PATCH "$api/repos/$owner/$repo/releases/$release_id" "$release_json" \
+        --header 'Content-Type: application/json' --data-binary "@$edit_json")
+    [ "$status" = 200 ] || { echo "ERROR: publishing release failed (HTTP $status)" >&2; exit 1; }
+fi
+
+echo "Forgejo release ready: https://git.jerboa.sh/$owner/$repo/releases/tag/$version"
diff --git a/support/install.sh b/support/install.sh
index 41b20c5..61a8dea 100755
--- a/support/install.sh
+++ b/support/install.sh
@@ -33,8 +33,8 @@ Other environment:
   JERBOA_VERSION         Exact version tag (alternative to --version)
   JERBOA_INSTALL_DIR     Bin directory (default: $HOME/.local/bin)
   PREFIX                 Prefix used when JERBOA_INSTALL_DIR is unset
-  JERBOA_RELEASE_REPO    SourceHut repo path (default: ~lisp/jerboa)
-  JERBOA_RELEASE_ORIGIN  HTTPS SourceHut origin (default: https://git.sr.ht)
+  JERBOA_RELEASE_REPO    Forgejo repo path (default: ober/jerboa)
+  JERBOA_RELEASE_ORIGIN  HTTPS Forgejo origin (default: https://git.jerboa.sh)
   JERBOA_RELEASE_BASE    Complete HTTPS release base URL override
   JERBOA_RELEASE_TARGET  Target override, e.g. linux-amd64
   JERBOA_RELEASE_SSH_NAMESPACE  Signature namespace (default: file)
@@ -170,8 +170,8 @@ if [ -z "$bindir" ]; then
     fi
 fi
 
-repo=${JERBOA_RELEASE_REPO:-~lisp/jerboa}
-origin=${JERBOA_RELEASE_ORIGIN:-https://git.sr.ht}
+repo=${JERBOA_RELEASE_REPO:-ober/jerboa}
+origin=${JERBOA_RELEASE_ORIGIN:-https://git.jerboa.sh}
 repo_url="${origin%/}/$repo"
 target=$(detect_target)
 case "$target" in
@@ -181,10 +181,10 @@ esac
 
 file="jerboa-${version}-${target}.tar.gz"
 
-# Release base URL. SourceHut git-tag artifacts are the only official release
+# Release base URL. Forgejo release attachments are the only official release
 # download channel. JERBOA_RELEASE_BASE exists solely for local test fixtures.
-sourcehut_base="${repo_url}/refs/download/${version}"
-release_bases=${JERBOA_RELEASE_BASE:-$sourcehut_base}
+forgejo_base="${repo_url}/releases/download/${version}"
+release_bases=${JERBOA_RELEASE_BASE:-$forgejo_base}
 
 tmp_parent=$(choose_tmp_parent)
 tmp=$(mktemp -d "$tmp_parent/jerboa-install.XXXXXX")
diff --git a/support/pack-jpkg-workspace.sh b/support/pack-jpkg-workspace.sh
new file mode 100755
index 0000000..7202ee0
--- /dev/null
+++ b/support/pack-jpkg-workspace.sh
@@ -0,0 +1,70 @@
+#!/bin/sh
+# Verify and deterministically pack every Jerboa package under a workspace.
+
+set -u
+
+usage() {
+    echo "Usage: $0 WORKSPACE_DIR [OUTPUT_DIR]" >&2
+}
+
+[ "${1:-}" ] || { usage; exit 2; }
+
+workspace=$1
+output=${2:-dist/jpkg-workspace}
+root=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
+jerboa=${JPKG_JERBOA:-$root/bin/jerboa}
+replace=${JPKG_REPLACE_ARTIFACTS:-0}
+
+[ -d "$workspace" ] || { echo "ERROR: workspace directory not found: $workspace" >&2; exit 1; }
+[ -x "$jerboa" ] || { echo "ERROR: Jerboa executable not found: $jerboa" >&2; exit 1; }
+
+mkdir -p "$output"
+output=$(CDPATH= cd -- "$output" && pwd)
+report="$output/report.tsv"
+printf 'repository\tstatus\tartifact-or-error\n' > "$report"
+
+pass=0
+fail=0
+found=0
+
+for manifest in "$workspace"/jerboa*/jpkg.sexp; do
+    [ -f "$manifest" ] || continue
+    found=$((found + 1))
+    repo=${manifest%/jpkg.sexp}
+    repo_name=$(basename "$repo")
+    version=$(sed -n 's/^[[:space:]]*(version "\([^"]*\)").*/\1/p' "$manifest" | head -n 1)
+    if [ -z "$version" ]; then
+        printf '%s\tfail\tmissing version\n' "$repo_name" >> "$report"
+        echo "FAIL $repo_name: missing version" >&2
+        fail=$((fail + 1))
+        continue
+    fi
+
+    artifact="$output/$repo_name-$version.jpkg"
+    if [ -e "$artifact" ] && [ "$replace" != 1 ]; then
+        printf '%s\tfail\tartifact already exists\n' "$repo_name" >> "$report"
+        echo "FAIL $repo_name: artifact already exists (set JPKG_REPLACE_ARTIFACTS=1)" >&2
+        fail=$((fail + 1))