Releasing TorbScript#
Status: planning; the CI and the release pipeline of section 13 are built, and since 2026-09-27 they run on the
project's own forge at git.torb.dev (section 14) — this record plans
the public release of the language: what has to exist first, what a download contains, the website at torb.dev and
the package registry at packages.torb.dev. Apart from section 13 and the package manager's client of section 7.13,
no command, page, server or file described here exists yet, the domain serves nothing, and nothing has been registered, published or announced. Every statement about what the repository does today comes from section 1, which was measured on the
commit this record was written on (653af8bb).
A release is three launches, not one, and each has its own gate. The preview publishes the language: downloads,
installers and the documentation at torb.dev, with dependencies only through path:, git: and workspaces. The
registry opens packages.torb.dev once the package manager of milestone 8 can talk to it. 1.0 is a promise about
compatibility, and it is given once the language has stopped changing weekly — not on a date. Everything the three need
that does not block core work can be built now, in the order of section 10.
torb.dev packages.torb.dev
┌──────────────────────────────────────────────────┐ ┌──────────────────────────────────────────────┐
│ / landing │ │ /acme/http a package's page │
│ /install the one-line installers │ │ /acme/http/1.2.5/docs torb doc, generated │
│ /download every artifact, hashes, signatures │ │ /index/acme/http.trb the sparse index │
│ /learn the guide and the tour │ │ /archives/... immutable source │
│ /docs/<ver> reference, std, tooling │ │ /api/1/... publish, yank, owners │
│ /play the VM in the browser │ └──────────────────────────────────────────────┘
└──────────────────────────────────────────────────┘ read: static files behind a CDN
generated by `torb docs site` from docs/ write: one small service, `torb publish`- 1. Where the repository stands — eighteen facts, measured
- 2. Readiness — what each of the three launches needs, by milestone
- 3. Versions and the stability promise — the 0.x period, what 1.0 promises, no editions, pre-releases and the monthly cadence
- 4. What a download contains — the binary,
std/,runtime/, and the C compiler question - 5. Installing, upgrading, channels and signing
- 6. The website —
torb docs site, versions, search, the playground, hosting - 7. The registry — owners, publishing, the index, security, capabilities, policies
- 8. Terminology — package, project, workspace, module, library
- 9. Governance and legal
- 10. Slices — from now to each launch
- 11. What this is not
- 12. Open — the questions for the owner
- 13. The release pipeline, as built — CI, the published seed, nightlies, releases, signing
- 14. The forge — git.torb.dev and cr.torb.dev, runners, the tokens of Authorized Integrations, GitHub as a mirror, security
1. Where the repository stands#
| # | Fact | How it was established |
|---|---|---|
| 1 | build/release/torb.exe is 16.2 MB (Windows x64, gcc from MinGW); the compiler's own program.c is 106 MB (docs/RUST-EXIT.md section 4 measured 55.6 MiB and 3.2 MiB compressed when it was written) | ls -l build/release |
| 2 | std/ is 121 .trb files and 886 KB, runtime/ 972 KB with its tests, docs/ 3.6 MB | find, du |
| 3 | There is no CI: no .github/, no other pipeline. The gates run on the maintainer's Windows machine | ls -a |
| 4 | runtime/os/linux.c, posix.c, macos.c and freebsd.c are compiled on their own system only, and no gate has built them (docs/design/OS.md, "Every arm, on every machine") | OS.md, fact 3 |
| 5 | Process.executablePath() answered None on the BSDs (on macOS until the first CI runs, section 13), and it is how torb finds the std/ and runtime/ beside itself (compiler/src/project/toolchain.trb). A FreeBSD torb then needed $TORB_STD and $TORB_RUNTIME; CurrentProcess.executable() of std/os replaced it and answers on both (#251) | OS.md section 1, toolchain.trb line 54 |
| 6 | torb build needs a C compiler ($TORB_CC, clang, gcc, cc, cl) and answers exit code 3 without one; every build compiles every runtime/*.c and runtime/os/*.c together with the program, except the Unicode tables (unicode.c, unicode_data.c), the normalization tables (normalization.c, normalization_data.c) and the IDNA tables (idna.c, idna_data.c), which only a program that calls a native that reads them carries | docs/tooling/torb-build.md, compiler/src/cli/build.trb |
| 7 | torb run and torb test build natively too; the VM is torb run --vm and runs 120 of the 142 conformance programs | docs/design/VM.md |
| 8 | There is no license: no LICENSE file, and no project.trb of the repository has a license line | ls, grep |
| 9 | Every manifest says version "0.1.0"; nothing is tagged | project.trb, compiler/project.trb |
| 10 | std/project has registry, but not source, program, language or license; build { input } and test { input } are still read; project.lock.trb does not exist | std/project/src/lib.trb, PROJECT.md status line |
| 11 | add, remove, update, lock, publish, format, lint, doc and repl are not commands; new, check, build, run, test, canon, docs, manifest and highlight are | compiler/src/main.trb |
| 12 | 25 lines of the manifest of natives are still planned: Decimal (milestone 8), the streams of File, the three standard streams, Process.start and Child (7.3), Expression.value/captures (5.11), isSame (5.9), and get/post/request of std/http (8) | grep plannedRuntimeOf compiler/src/backend/c/natives.trb |
| 13 | Nothing in std/ or runtime/ computes SHA-256, reads or writes a tar or gzip archive, or speaks TLS | grep |
| 14 | docs/ has 221 pages with status: stable, 6 planned and 2 draft. torb docs bundle writes llms.txt and llms-full.txt, torb docs skill the Agent Skill; nothing writes HTML | grep, compiler/src/documentation/command.trb |
| 15 | The documentation tool reads a page through std/markdown (CommonMark 0.31.2 with GitHub's tables) and its front matter through std/yaml (compiler/src/documentation/markdown.trb, docs/design/TEXT-FORMATS.md section 3a); nothing renders HTML from it yet | the file's module comment |
| 16 | The VS Code extension lives in editors/vscode (publisher torbscript, the toolchain's version) and starts torb lsp; every release publishes it to the Visual Studio Marketplace and Open VSX (section 13, since 2026-09-28) | editors/vscode/package.json, docs/design/LANGUAGE-SERVER.md |
| 17 | The repository's remote is github.com/TorbScript/language | git remote -v |
| 18 | OperatingSystem has four cases (Windows, Linux, MacOs, FreeBsd) and Architecture two (X64, Arm64); adding a case is a breaking change of std/core (OS.md, "Adding an operating system") | std/core/src/target.trb |
Facts 3 to 5 are the reason the preview is not "tag a commit and upload the binary": what runs today is one target, verified on one machine.
2. Readiness#
Decision: the three launches have three lists, and an item belongs to the earliest launch that cannot do without
it. The milestones are the repository's own (docs/ARCHITECTURE.md: 7 is the VM, tasks and the sandbox, 8 the
formatter, the language server and the package manager, 9 the JS and PHP back ends, and 10 the breadth of std;
docs/ROADMAP.md lists them).
The preview: torb.dev with downloads and documentation#
| Item | Where it is specified | Why the preview needs it |
|---|---|---|
Milestone 7 finished: VM slices 4 to 8 (keys by equals, the leak gate, test/group, tasks, the whole suite in both back ends) and the 7.3 natives of fact 12 | docs/design/VM.md section 9, docs/BACKEND.md section 5 | CONCEPT's execution model sends torb run to the VM. A preview whose first command needs a C compiler contradicts CONCEPT's key facts ("Runs interpreted (no compile step)") and puts section 4's hardest question in front of every newcomer |
torb run and torb test default to the VM; torb build stays native | CONCEPT, "Execution Model" | the same; it is a default, and --native keeps today's path |
PROJECT.md slices 1 to 4 and the language setting of slice 6 | docs/design/PROJECT.md section 12 | every user writes a project.trb on day one. Shipping build { input } and removing it a month later is churn in every early adopter's repository. language has to exist in the first public version, because PROJECT.md section 10 says it is the one setting an older toolchain must understand, and "adding it late would mean the versions that cannot read it already exist" |
| CI on every tier 1 target (section 5), running tier A and tier B | tools/gates.sh, compiler/CONTRIBUTING.md | facts 3 and 4: without it, a Linux or macOS download is a guess |
Process.executablePath() on macOS (_NSGetExecutablePath) and FreeBSD (sysctl KERN_PROC_PATHNAME) | OS.md section 5.9 | fact 5: a downloaded torb must find its own std/ and runtime/ |
| A license, a security policy, a code of conduct | section 9 | fact 8: without a license nobody may use it |
torb docs site and the site | section 6 | the documentation is the product of a preview |
| The installers, archives, hashes and signatures | sections 4 and 5 | — |
git: and path: sources (PROJECT.md slice 6, resolution of the two) | PROJECT.md section 7 | a preview without a registry still needs dependencies; git: shells out to the git on the machine (Cargo's net.git-fetch-with-cli), so it needs no Git implementation |
Not needed for the preview: the registry, the language server, torb format (torb canon enforces the canon and
says it is temporary), Decimal, the REPL, milestones 9 and 10.
The registry: packages.torb.dev#
| Item | Where it is specified |
|---|---|
PROJECT.md slices 5 to 8: the specifier grammar, the static subset with its diagnostics, the manifest that reads (SCRIPTS.md slice 5) and the locked manifest with torb lock, torb pack and torb publish | PROJECT.md sections 8 and 12 |
The package manager commands: add, remove, update, publish, yank, login, owner, audit, vendor | milestone 8, section 7 |
Resolution: semantic versions, ^ by default, the highest compatible version, one version per major (CONCEPT, "Packages and the Supply Chain") | CONCEPT |
An HTTP client with TLS: the three planned natives of std/http (fact 12). The plan for std/http (docs/ROADMAP.md, milestone 8) is TLS through the platform (SChannel on Windows, the system's stack on macOS, OpenSSL where Linux has it) | milestone 8 |
SHA-256 and Ed25519 verification, as natives of runtime/ or a std package | built: std/digest and std/signature, TorbScript with no native (section 7.14) |
Reading and writing a deterministic tar and gzip (inflate is the part that must be fast; deflate may be simple) | fact 13, section 7.2 |
torb doc, which renders doc comments; the registry runs it for every version | built: docs/tooling/torb-doc.md. torb doc --json is the data a package's pages are rendered from, and torb doc --check --no-run the check that runs none of its code |
The table that says which std package is which capability (section 7.8) | CONCEPT, "Capabilities are visible" |
A decision about foreign in published packages: whether a registry package may declare foreign functions, and if so whether it may carry C sources. Foreign functions are a draft (CONCEPT) | CONCEPT, "Foreign Functions (Draft)"; open, owner decision (#84): options in FOREIGN.md section 5.3 |
| The registry's own server, section 7.11 | this record |
1.0#
| Item | Why it blocks a compatibility promise |
|---|---|
| The language server and a published editor extension (milestone 8) | a 1.0 without an editor story is not taken seriously, and the LSP is the compiler (docs/ROADMAP.md: incremental check, the checker's tables for hover, definition and completion) |
torb format and torb lint (milestone 8), torb lint --fix as the migration tool | section 3: every breaking change before 1.0 needs a migration, and after 1.0 a deprecation needs a fix |
deprecated and since — built (2026-09-28): deprecated("why", replacement: "name", since: "0.4"), a warning at every use, torb lint --fix --rule deprecated, torb doc and the language server (docs/design/DEPRECATION.md) | a promise without a deprecation mechanism can only freeze |
A way for a public enum to grow - resolved (the owner, 2026-09-29; CLOSED-TRAITS.md decision 7.3): a policy that such an enum never grows; the newtype pattern where growth is needed. A public type with cases gains no case within 1.x, and a type that must grow wraps a private type with cases and answers questions (HttpError is the model). Fact 18 stands: OperatingSystem and Architecture stay types with cases, so the targets of 1.x are the targets 1.0 ships | what is left is work: IoError, OsError, DnsError, UriError and the ReadError of std/binary become newtypes before 1.0, each a breaking change of its package with its matches rewritten to questions |
| Whether a new prelude name can break a program - resolved (2026-10-12): it cannot. The prelude ranks below everything a file wrote or imported, for names and for members (CONCEPT, "Imports"; TYPECHECKER 3.1), so a prelude addition never breaks a program or changes what it means | the answer decides whether the prelude may grow in a minor release: it may |
Decimal (DECIMAL.md) and Float32 as a Real (milestone 8), the full Unicode tables (milestone 8) | names in std that a 1.0 declares have to work; a declaration that answers "not implemented yet" cannot be promised |
| CONCEPT's open questions that would change existing programs | yield, for over a Source and _ as a type argument are additions and may stay open; anything that changes what an existing program means has to be settled |
Which std packages are in the promise | question 3 of section 12 |
| Two consecutive minor releases without a syntax change | the evidence that the language has stopped changing weekly (RUST-EXIT section 4.2 says it still does) |
Not needed for 1.0: milestone 9 (JS and PHP) and milestone 10 (the breadth of std), because both are additions a
minor release may bring; the .torbc file format (VM.md section 1), which is a cache and never promised.
3. Versions and the stability promise#
Decision: one number, MAJOR.MINOR.PATCH, for the toolchain, the language and std together, which is what
PROJECT.md section 10 already decided for language "0.3.0": "The language version and the std version always
coincide, so the number is the language's". The download, torb --version, the language line and the documentation
path /docs/0.4/ are the same number.
A pre-release carries a suffix after the number it is a candidate for (decided 2026-09-29): 0.1.0-rc.1, and before
a major only 1.0.0-alpha.1 and 1.0.0-beta.1 besides the release candidates. SemVer orders it before its release
(0.1.0-rc.1 < 0.1.0, rc.2 < rc.10, alpha < beta < rc), and torb upgrade, release-sync and the
package manager (compiler/src/package/version.trb) order versions that way. The manifests, torb --version and the
extension keep the number without the suffix: a release candidate is a build of that number, and where it becomes the
release, the same commit is tagged again. A package's language line is therefore compared with 0.1.0 under
0.1.0-rc.1 as well - a candidate is meant to be tried with the programs that will run on its release.
Before 1.0, 0.MINOR may break and 0.MINOR.PATCH may not. That is Cargo's reading of semantic versions below 1.0
(^0.4.2 means >=0.4.2, <0.5.0), and the one the registry's resolution uses for packages too. Every breaking minor
ships its migration: RUST-EXIT section 4.2 already requires the teaching commit to add the rewrite to torb canon (from
milestone 8, torb format), so the migration exists before the break by construction. Release notes name each break
and the command that migrates it. Only a minor breaks, so at most once a month (the cadence below), each break with
an automatic migration - torb format or torb lint --fix - named in the release notes; within 1.x nothing breaks.
What 1.0 promises, written in the style of Go's compatibility document:
| Surface | The promise within 1.x |
|---|---|
| Syntax and semantics | a program that checks under 1.x checks under every later 1.y and does the same thing. Exceptions, named in advance: a security fix, and a program the checker accepted although the documented rules reject it (a false negative of the checker is a bug and is fixed) |
std | additions only: a new package, type, member or trait implementation. A removal or a change of a signature waits for 2.0; a member is deprecated with deprecated and a torb lint --fix rewrite instead. A new case of a public type with cases is not an addition: such a type never grows within 1.x, and a type that must grow is a newtype (section 2). The prelude grows by additions in a minor release as well: the prelude ranks below what a file wrote or imported (section 2), so only a removal or a changed signature of a prelude entry breaks a program |
| The command line | commands, flags and exit codes of torb stay; the text of a diagnostic does not, because diagnostics get better |
| Project files | project.trb and project.lock.trb written by 1.x are read by every later 1.y; a newer lock format is gated by the language line |
| ABI | none is promised, and none is needed. Packages are source (section 7.2); every program is compiled from source by one toolchain, so there is no binary interface between packages. The only binary interface is C's, at foreign. The bytecode, the IR, the generated C and the runtime's C API are internal and change in any release |
| Targets | a tier 1 target stays tier 1 within 1.x; a target is dropped only in a major, or when its vendor ends support for it |
No editions. The language line stays a minimum version, which is what PROJECT.md section 10 decided, and not a
switch between dialects.
| Option | For | Against |
|---|---|---|
| Strict compatibility within 1.x, a rare 2.0 with a migration tool — the decision | one language at any time; the compiler carries no second semantics; what language means stays one sentence | a change that would break has to wait; a 2.0 is expensive (Python 3 is the cautionary tale, and the answer to it is the migration tool that must exist) |
| Editions (Rust): a package chooses its edition, editions mix in one graph | breaking changes without splitting the ecosystem | the compiler carries every edition forever, and the reader of a snippet has to know which edition it is in; PROJECT.md already rejected "a language edition next to a tool version" |
The language line gates semantics per package (Go since 1.21, which changed loop variables for modules that say go 1.22) | an escape hatch that needs no new setting | it is editions, one change at a time, and it is only honest if it is rare; kept as the documented fallback if a single semantic change ever cannot wait for 2.0 |
| No promise (Zig before 1.0) | freedom | it is the 0.x period, not a 1.0 |
The cadence — decided by the owner's delegation (2026-09-29), by the calendar; it replaces the six weeks the owner first chose (question 4 of section 12).
- A minor every month, on the first Tuesday. A week before - on the Tuesday before the first Tuesday - the branch
release/0.Nis made frommainandv0.N.0-rc.1is tagged on it;mainmoves on at once and carries the next number. Without a blocker the release candidate becomes the stable release unchanged: the first Tuesday tags the same commitv0.N.0. A blocker is fixed onmain, cherry-picked ontorelease/0.N, and the next candidate-rc.2is tagged there; the stable release is always a candidate that was out first, and only a blocker that is not fixed by the first Tuesday moves the date. A release candidate is the preview channel (section 5). - A patch (
0.N.1) whenever the fix of a regression or of a security problem is ready, fromrelease/0.N: fixes only, never a break and never a feature, and no candidate. - Alpha and beta exist only before a major (1.0, 2.0), and in that phase they replace the monthly minor: an alpha while breaks are still allowed, a beta once the surface is frozen, then a release candidate and the major.
- A nightly from every commit of
mainon which tier A and tier B are green, as before -nightly-YYYYMMDD, the tag installers, release-sync and the editor extension already rely on. - Support: no long-term support release, as Go and Rust have none. In 0.x only the newest minor is maintained:
release/0.Ntakes patches until0.(N+1).0is out. From 1.0 the newest minor gets every fix, and the one before it security fixes for one more month. - Semantic versions, not calendar numbers. A number such as
2026.10would say when a release was made; a semantic version says whether an update breaks, and the registry resolves packages by it (section 7). The date of a release is in its notes. - The first release:
v0.1.0-rc.1on 2026-09-29, right after the pipeline learnt pre-releases, andv0.1.0on the first Tuesday of October, 2026-10-06. Then0.2.0-rc.1on 2026-10-27 and0.2.0on 2026-11-03.
| Option | For | Against |
|---|---|---|
| A minor on the first Tuesday of every month, its release candidate a week before — the decision | a date anybody can plan with; a week in which a candidate meets real programs while main moves on; a small month's worth of change per release, and at most one break a month | a release when there is little in it; a release branch to cherry-pick fixes onto for a week, and for the patches of a month |
| A minor every six weeks when there is something in it (the first decision) | fewer releases, each fuller | no date to plan with - "when there is something in it" is a judgement made anew each time; no candidate phase, so the nightly was the only preview |
| A release train with a beta channel (Rust: six weeks, stable, beta, nightly) | every change is on beta for a whole cycle | a second maintained branch at all times, which a project of one maintainer does not carry; the preview channel gives the week that matters |
Calendar numbers (2026.10.0) | the date is the number | the number no longer says whether an update breaks, and the registry resolves by SemVer (section 7) |
4. What a download contains#
Decision: an archive per target with the binary, std/, runtime/ and the licenses, laid out so that torb finds
the rest beside itself — which is how compiler/src/project/toolchain.trb already looks for them, walking up from
the binary to a directory with std/prelude/project.trb and runtime/include/torb.h.
torb-0.4.0-linux-x64/
├ bin/torb the toolchain (fact 1: 16 MB today on Windows)
├ std/ every std package, as source: the checker reads it, the VM runs it, the C back end builds it
├ runtime/ the C runtime: include/, *.c, os/*.c, without its tests - `torb build` compiles it with each program
├ tools/build-slot.sh the machine-wide build slots of `torb build` (fact 6)
├ tools/build-units.sh the parallel compile of a large program's units and the object cache (BACKEND 4.1)
├ LICENSE, NOTICE section 9
└ README.md three lines and the link to torb.dev/learn- The archive is
.tar.gzeverywhere and.zipon Windows as well, with aSHA256SUMSbeside all of them and a signature (section 5). - Two artifacts beside the per-target archives: the source of the tagged commit, and the compiler's
program.c— the portable seed of RUST-EXIT section 4.1, "builds anywhere a C compiler exists". README.md already promises both ("A release publishes both artifacts"). - The Agent Skills (
torb docs skill, committed inskills/) are not inside the toolchain: they are documentation, and an agent installs them from the repository where its harness reads skills - a Claude Code or Codex plugin marketplace,npx skills, APM (docs/site/agents.md). - Nothing else. No bundled editor, no documentation in the archive (torb.dev has every version), no C compiler.
- The Windows
torb.execarries the Orb and its version (docs/design/BRAND.md section 11, "Binary icons"):tools/windows/torb.rcnamesbrand/icons/torb.icoand the product name TorbScript, andtools/windows/resource.shcompiles it with the version ofproject.trbinto an object file. The release step of.forgejo/actions/portablecompiles the bootstrap'sprogram.cagain with that object linked (TORB_OBJECTSoftools/build-seed.sh), so the bootstrap, its fixpoint - which compares the C - and every programtorb buildwrites stay without it. The resource compiler is GNUwindres, which MSYS2's gcc brings on the Windows runner; without one the binary ships without its icon and the step warns. A program built withtorb buildgets no TorbScript icon: it belongs to its author.
The C compiler#
Decision: the download bundles no C compiler; torb run, check and test need none once they run in the VM
(section 2); torb build finds the machine's compiler, and when there is none it says in one line how to get one,
including a managed one that torb can fetch on request.
The question is real on one platform. On Linux a C compiler is one package away and usually installed; on macOS
xcode-select --install gives clang; on Windows there is none by default, and MSVC's cl is not on the PATH
outside a developer prompt, which is why torb build finds it only when someone set that up.
| Option | For | Against |
|---|---|---|
| Require one, find it well — the decision for the download | the archive stays small (fact 1); the user's compiler is the one their other tools use; no second copy of a toolchain to patch for security | Windows users without Visual Studio or MinGW hit exit code 3. Mitigation: torb build asks vswhere for an installed MSVC (what Rust's cc crate does) and prints the one line that installs a compiler (winget install ... / xcode-select --install / the distribution's package) |
| Bundle TCC | a few hundred kilobytes; compiles very fast; LGPL is fine for a separate executable | code without an optimizer, which is the opposite of the performance goal (docs/PERFORMANCE.md); C11 support is partial; macOS on arm64 is not a solid target; no release since 0.9.27 (2017), development continues on a branch. With the VM as the fast path for run, the one thing TCC buys — a quick compile — is no longer needed |
| Bundle Clang with a sysroot per host | the best code; one compiler everywhere | tens to hundreds of megabytes per host; the sysroot is the problem: Apple's SDK may not be redistributed, and Windows needs either the MSVC SDK (not redistributable) or MinGW-w64 (llvm-mingw does this, as one download for Windows only) |
| zig cc, managed — the decision for "no compiler found" and for cross-compiling | Clang plus libc headers and stubs for every target of section 5 in one MIT-licensed archive per host; cross-compiles out of the box, including to macOS; the pinned version makes binaries reproducible | a second pre-1.0 project as a dependency, whose relation to LLVM is changing; tens of megabytes. Therefore not bundled: torb fetches a pinned, hash-verified version into its data directory only when asked (torb toolchain add c), and uses it when no system compiler is found or when --target names another machine |
| A machine-code back end | no C compiler at all | CONCEPT allows one "behind the same IR"; it is years of work and not a release question |
The release pipeline uses the managed compiler too. One pinned zig cc building torb for every target from one
kind of runner is what makes section 5's reproducible builds a property of the pipeline and not of whichever runner
image was current. The native runners of section 5 still run the gates, because a cross-compiled binary is not a tested
one. As built (2026-09-29), a pinned zig cc builds the release binaries of the tier 1 targets the forge has no
runner for, on its linux-x64 runner (section 13, "Cross-compiled archives"); linux-x64 and a target with a runner of its
own ship the binary of their own machine.
5. Installing, upgrading, channels and signing#
Targets#
| Tier | Targets | What the tier means |
|---|---|---|
| 1 | windows-x64, linux-x64, linux-arm64, macos-arm64 | CI runs tier A and tier B on native runners for every commit to main; a release is blocked by a red gate; binaries, installers |
| 2 | macos-x64, windows-arm64, freebsd-x64 | built for every release and smoke-tested (the conformance suite) on a native runner or in a virtual machine; binaries; a red gate is a bug, not a blocker |
| 3 | anything program.c builds on | no binary; sh tools/bootstrap.sh from the portable seed |
linux-x64 is built against musl and linked statically, so one binary runs on every distribution regardless of its
glibc; the programs it builds link against the machine's own C library, because they are compiled by the machine's
compiler. FreeBSD has no hosted runner on the common CI services and runs in a virtual machine on a Linux runner.
Installers#
| Target | At the preview | Later |
|---|---|---|
| Windows | irm https://torb.dev/install.ps1 | iex: per user into %LOCALAPPDATA%\Programs\torb, the user PATH extended; winget and Scoop, their manifests generated by the release workflow; the .zip | an MSI when an organisation asks |
| Linux | curl -fsSL https://torb.dev/install.sh | sh: into ~/.torb, a line for the shell's profile printed rather than written silently; the .tar.gz | Homebrew on Linux, Nix, an AUR package, .deb/.rpm repositories when someone maintains them |
| macOS | the same install.sh; the Homebrew tap torbscript/tap, its formula updated by the release workflow; the .tar.gz | homebrew-core once the project meets its popularity threshold |
| FreeBSD | the same install.sh | a port |
- Both scripts are short, readable and served from torb.dev, never from a vendor's URL (section 6, "Hosting").
They detect the target, download the archive and
SHA256SUMS, verify the hash, unpack, and write~/.torb/install.trb(channel "stable"by default,method "script"), whichtorb upgradereads. - No stable release yet, the default channel: both installers, and
torb upgradeleft onstable, fall back to the newest preview and, without one, to the newest nightly, print one line saying which (no stable release yet: installing the preview 0.1.0-rc.1), and write that channel instead - the toolchain then follows it, with nothing more to type. Since the preview channel offers the stable releases too, a toolchain that fell back to it moves on to0.1.0once that is out, and to each later candidate after that;torb upgrade --channel stableleaves it. An explicitly requested channel or version is never substituted: it stays the error it already was ("no version of the ... channel is listed"). - A target missing from a version's archives is decided from that version's
SHA256SUMS(downloaded first), never guessed from a failed download: "<version>has no<target>archive yet", naming both. - A download with
curlor PowerShell gets no quarantine attribute on macOS and no Mark of the Web on Windows, so the script path works without notarization or Authenticode. A binary downloaded in a browser is where section 5's signing matters.
torb upgrade#
Decision: torb upgrade exists, installs side by side, and never runs implicitly.
torb upgrade the newest release of the installed channel
torb upgrade 0.5.2 that version
torb upgrade --channel preview switch the channel: stable, preview or nightly
torb upgrade --list what is installed, and what is available- Side by side:
~/.torb/toolchains/<version>/holds each version, and~/.torb/bin/torbis the one in use. A project that sayslanguage "0.5.0"under an installed 0.4 gets PROJECT.md's diagnostic plus one note:torb upgrade 0.5.0. - Never implicit. Go 1.21 downloads the toolchain a module asks for; that is a fetch during a build, which PROJECT.md section 7 rules out for dependencies ("A fetch at compile time: never") and which this record rules out for the toolchain by the same argument.
- Only for a script install.
torb upgradein a toolchain installed by Homebrew, winget or a distribution readsinstall.trb, refuses, and names that package manager's command, as rustup does. - On Windows the running
torb.execannot be overwritten; it can be renamed, which is how the replacement is done.
Channels#
| Channel | What it is | Kept |
|---|---|---|
| stable | a tagged release, v0.N.P | forever: every archive, every documentation version |
| preview (2026-09-29) | the newest pre-release - the release candidate v0.N.0-rc.K a week before each minor, and before a major its alphas and betas - or the newest stable release where that is newer, so the channel is never behind stable | as stable |
| nightly | the last commit of main on which tier A and tier B are green on every tier 1 target | 30 days of archives; the documentation at /docs/nightly/ |
The preview channel replaces "no beta channel before 1.0", which the six weeks decided: a monthly minor needs a week
in which a candidate meets programs, and the channel is that week. The installers take it with
TORB_INSTALL_CHANNEL=preview, torb upgrade --channel preview follows it, and the images carry the tag preview.
Nothing else gets a pre-release: a version in the Visual Studio Marketplace or Open VSX is MAJOR.MINOR.PATCH and
nothing else, so 0.2.0-rc.1 would take 0.2.0 from its own release, and Homebrew, Scoop and winget follow stable
releases only; what is ahead of stable reaches an editor through the nightly's .vsix. The word preview also names
the first of the three launches (section 2); the channel is always written preview.
Reproducible and signed#
- The C is reproducible already: the fixpoint compares two
program.cbyte for byte, and the generated C carries "no path of this machine, no time and no build id" (PROJECT.md section 8). A release publishesprogram.candprogram.hash, so anyone can check that the released C is what the previous release'storbemits from the tagged source. - The binary is reproducible given the pinned compiler (section 4): CI builds every release twice on two runners and compares the archives, which also means archives carry fixed timestamps, sorted entries and fixed modes.
- Signatures:
SHA256SUMSplus a keyless Sigstore signature made in CI with the pipeline's OIDC identity, verifiable withcosign verify-blobagainst the identity of the release workflow; GitHub's artifact attestations are the same mechanism. A long-lived signing key held by a person is exactly what keyless signing avoids. Superseded on the forge (section 13, "Signing"): Sigstore's certificate authority trusts no identity git.torb.dev issues, so the forge signs with a cosign key of the project, held as a secret of the repository. - Platform signing is a question of money and not of design (question 7): macOS notarization needs a paid Apple
developer membership, Authenticode needs a paid certificate or signing service. Both matter only for browser downloads
and for the
.pkg/MSI installers; every arm64 macOS binary must carry at least an ad hoc signature, which the pipeline checks.
Installers, torb upgrade and the package manager channels, as built#
tools/install.sh (Linux, macOS, FreeBSD, POSIX sh) and tools/install.ps1 (Windows, PowerShell 5.1+) exist
and are release assets; both are short, read a target and a channel, download, verify and unpack, and nothing else -
they print the profile line rather than writing it (POSIX) and extend the user PATH themselves (Windows, since that
has no profile file to append to). Both take their base URL, channel and an exact version from an environment variable
(TORB_INSTALL_BASE_URL, TORB_INSTALL_CHANNEL, TORB_INSTALL_VERSION; TORB_INSTALL_TARGET skips detection),
which is what the tests below use instead of a second, test-only code path.
<base url>/versions.txt, decided here since section 7.4's format is for the package index and this is simpler:
one release per line, <version> <channel>, the channel stable for 0.1.0, preview for a pre-release
(0.2.0-rc.1, 1.0.0-beta.2: a - after the numbers) and nightly for nightly-YYYYMMDD. release-sync
(section 7.11) writes it by rewriting the whole file on every release: the previous line for a version, if any, is
replaced, and every channel's lines are kept newest first by SemVer's precedence - stable, then preview, then nightly -
whatever order the releases were placed in, because install.sh/install.ps1 pick the first line of their channel.
For preview they compare that line with the first stable one and take the stable release where its numbers are not
below the candidate's, which is all SemVer's precedence needs between a pre-release and a release. torb upgrade
(below) sorts every line of the manifest instead, by the package manager's Version, so it does not depend on that
order.
The layout both scripts write, and torb upgrade (compiler/src/cli/upgrade.trb) manages afterwards:
~/.torb/toolchains/<version>/ one full toolchain per installed version (%LOCALAPPDATA%\torb\toolchains\<version>\)
~/.torb/bin/torb a symlink to the active toolchain's bin/torb (POSIX only)
~/.torb/active the version the symlink, or the Windows copy, is at
~/.torb/install.trb channel = "stable" method = "script" (%LOCALAPPDATA%\torb\install.trb)Windows has no per-user symlink without a privilege most machines do not grant, so its "one in use" is a copy at
%LOCALAPPDATA%\Programs\torb (on the PATH) that torb upgrade mirrors from the new toolchain - every file copied
over, whatever the new toolchain lacks removed, what robocopy /MIR would do - after renaming the copy's torb.exe,
which may be the one running or another torb started from the copy, to torb.exe.replaced - section 5's "the
running torb.exe cannot be overwritten; it can be renamed", exactly as decided above. The renamed executable is
removed with the rest of what the new toolchain lacks, or, while it still runs, by the next upgrade renaming over it.
install.trb's fields are written key = "value", one per line - a format of its own, chosen because it is not a
project.trb (nothing here is evaluated, and it is read before a version of the toolchain is even chosen) and not the
lock's literal-call format either (PROJECT.md section 8), which is for a graph, not four settings.
torb upgrade is implemented exactly as decided above: torb upgrade [<version>] [--channel stable|preview|nightly] [--list],
never implicit, refuses naming the package manager's command when install.trb says another method (brew upgrade torb, winget upgrade TorbScript.Torb, scoop update torb), and --list shows what is installed (from
~/.torb/toolchains) beside what the channel offers (from versions.txt), each release marked once it is installed
and once more if it is the active one. Only downloading runs a program, curl/wget (Windows included: curl has
shipped with Windows since 1803, so torb upgrade needs no PowerShell at all) - the tool tools/fetch-seed.sh already
assumes on every target; std/http answers a task, which the compiler cannot wait for. Everything else is the
standard library since 2026-09-27, the same code on every target - the system is asked at run time, so the compiler's
C stays one file for every machine:
- Hashing is
std/digest'sSha256over the archive, read whole withFile.readBytes(File.chunks()answers tasks, and an archive is some megabytes), where it wassha256sum,shasumor PowerShell'sGet-FileHash. - Unpacking is
std/compression'sBytes.gunzippedandstd/archive'suntarred()andextract()(2026-09-28), where it was the system'star: the GNUtarof a release with its paths of more than 100 bytes, the executable bit ofbin/torbset withFile.setPermissions, and an entry that would land outside of the toolchain refused before anything is written. The archive is inflated whole, in memory: a release of 30 MB unpacked takes some seconds. - Placing: the archive is unpacked into
<home>/tmp-<version>/and renamed intotoolchains/<version>in one step, so a download or an unpack that fails never leaves half a toolchain the nexttorb upgradewould take for an installed one; the scratch directory is removed afterwards, and emptied first where a stopped run left one. - Switching on POSIX makes the new link as
bin/torb.nextand renames it overbin/torb(File.createSymbolicLink,File.rename), where it wasln -sfn: atorbstarted meanwhile finds the old toolchain or the new one, never none. On WindowsFile.renamesets the copy's executable aside andFile.walk,copyandremovemirror the copy, where it wascmd /c moveandrobocopy. activeandinstall.trbare written withFile.writeTextAtomically, so they hold the old value or the new one and never a part of either.
Package manager channels (winget, Scoop, a Homebrew tap - the preview's choice, section 5's table): rendered from
tools/homebrew/torb.rb.template and tools/scoop/torb.json.template by tools/render-package-manifests.sh (its
placeholders filled from the release's own SHA256SUMS, nothing recomputed), and pushed by the publish-packages job
of release.yml, after the release. Since the move to the forge (section 14) every URL in them is
https://torb.dev/download/<version>/..., where release-sync places each release, never a forge's; the tap and the
bucket are repositories of the forge (torbscript/homebrew-tap, torbscript/scoop-bucket), pushed with the token of
the Authorized Integration that publishes the release and mirrored to GitHub, where brew tap torbscript/tap finds the
tap without a URL. A manifest is rendered only when the release has every archive it names - the formula needs
macos-arm64, linux-x64 and linux-arm64, the bucket windows-x64, which every release has since the archives of the
targets without a runner are cross-compiled (section 13) - and a channel whose repository does not exist yet is
skipped: a channel must never hold back the release. winget stays a manual step while the forge has no Windows runner
(wingetcreate runs on Windows only); the job prints the command. Nightlies publish nothing here: nightly.yml does not
call this job. What the owner sets up once for these three is
docs/contributing/releasing.md.
Tested: sh -n tools/install.sh (shellcheck where the machine has it), a full run of install.sh against a
locally packed archive through TORB_INSTALL_BASE_URL=file://... and TORB_INSTALL_TARGET - the fallback to nightly
(no stable line), the missing-archive error (a target SHA256SUMS does not list) and success - and the same three
against the live torb.dev while there is no stable release. install.ps1 was run both piped into iex and as a
file, against a local fixture through TORB_INSTALL_BASE_URL=file:///... (Invoke-WebRequest reads file:// in
Windows PowerShell 5.1, so no HTTP server is needed): the fallback, the missing archive, a hash that does not match,
and success, each confirmed to leave the caller's $ErrorActionPreference and scope exactly as they were - the point
of running the whole script inside its own & { ... }. compiler/tests/upgrade. test.trb covers the pure parts of torb upgrade (parsing install.trb and versions.txt, comparing versions,
reading SHA256SUMS) and, each in a temporary directory, hashing a file, unpacking with std/archive - a path of
more than 100 bytes, the executable bit, an entry outside refused - and placing the toolchain, switching the link, and
setting aside and mirroring the Windows copy. A whole torb upgrade - --list,
an install, a hash that does not match, an upgrade run by the copy's own torb.exe, an installed version made the one
in use again, --channel nightly, and the refusal for winget - ran on Windows on 2026-09-27 against a file:// base
URL (TORB_UPGRADE_BASE_URL) with LOCALAPPDATA pointing into a scratch directory, which is how it is repeated. The
POSIX branch beyond its tests, and a release on torb.dev, wait for a packaged release and a second machine.
The preview channel, tested (2026-09-29): compiler/tests/upgrade.test.trb orders pre-releases by SemVer
(0.1.0-rc.1 < 0.1.0, rc.2 < rc.10, alpha < beta < rc), lets the preview channel offer the stable
releases, and chooses a release for every channel with and without the fallback of the default channel;
tools/release-sync's tests list a pre-release as preview between the stable lines and the nightlies, never point
latest at it, and have the periodic check place the newest one. install.sh and install.ps1 (Windows
PowerShell 5.1, and piped into iex) each ran 30 cases against a file:// fixture whose versions.txt held every
combination of stable, preview and nightly lines - the default channel falling back to the preview and then to the
nightly with its one line, stable, preview and nightly asked for explicitly, a candidate behind its stable
release, and each missing channel's error - with the installed version, the recorded channel and the unpacked
bin/torb checked each time; the copy of install.ps1 that ran had its one line that writes the user PATH to the
registry replaced by a message. A whole torb upgrade on Windows against the same fixture fell back to the preview,
moved on to the stable release of that candidate and then to the next candidate, listed the preview channel, and
refused an explicit --channel stable with nothing stable listed.
6. The website#
Decided (2026-09-26): the slogan is "One language, every layer.", and the category the site, the README and the repository description name is "a single-language ecosystem". The front page leads with the slogan; the category is the line under it and the text of the meta description.
The structure#
| Path | Content | Comes from |
|---|---|---|
/ | what the language is in one screen: one program, the three claims (value semantics, one language for code and configuration, interpreted and native), install line, links | written for the site, in docs/site/ |
/install | the one-liners, the archives, what each target needs, torb upgrade | docs/site/install.md, written for the site; the guide's docs/guide/installing-and-running.md links to it and builds nothing, and building from a checkout is docs/contributing/building-from-source.md |
/agents | the one line that installs the Agent Skills into each agent | docs/site/agents.md |
/download | every artifact of every release, hashes, signatures, the portable seed | generated from the release manifest |
/learn | the guide in order, then the tour | docs/guide/, examples/tour/ |
/docs/<version>/ | the language reference, std, tooling, how-to, explanation | docs/ of that tag |
/docs/<version>/std/<package> | one page per std package, written by hand, with a link to the package's generated reference | docs/standard-library/ |
/docs/<version>/reference/ | the generated reference of every std package: an index per package, a page per module, every public construct with its signature, doc comment and anchor, and a search | torb doc std --output <out>/reference of that tag |
/design, /internals, /contributing | the design records, the compiler's documents, how to write the documentation | docs/design/, docs/internals/, docs/contributing/ — public, out of the main navigation, each page with its status line |
/play | the playground | below |
/releases/<version> | the release notes | docs/releases/<version>.md, a new folder |
/security, /community, /governance | section 9 | docs/site/ |
/llms.txt, /llms-full.txt | the documentation for models | torb docs bundle, which exists |
/.well-known/security.txt | RFC 9116 | section 9 |
torb docs site#
Decision: the site is generated by the toolchain from docs/, as torb docs site <root> <out>, and is plain static
files. The documentation system is already a TorbScript program with a schema, generated indexes, checked links and
checked snippets (docs/contributing/); an HTML writer is one more output of the tree it already reads, beside
skill and bundle.
- It needs a Markdown renderer, which is
std/markdown(fact 15): CommonMark with tables and front matter, a document tree as a value, HTML output as its own step. That package is the first slice, and it replaces the reader incompiler/src/documentation/markdown.trb. - Highlighting at generation time, no JavaScript for it: every
trbblock is highlighted by the lexer andtorb highlight's semantic tokens, so the site colours a field and a case the way the editor does. - The navigation is the index tree:
docs/index.md, then each folder'sindex.md, ordered byorder, which is exactly whattorb docs indexalready computes. A page'sstatus: plannedordraftbecomes a banner. - Search is a generated index and a small script: title, summary, keywords and headings of every page, plus a
term index of the bodies, as one static file per version; no search service. The
keywordsfront matter every page already carries is what it ranks first. - Versioned:
/docs/0.4/is generated from the tag and never regenerated;/docs/latest/is the newest stable;/docs/nightly/the nightly. A page that exists in the newest version links to it from the old one. - Checked like everything else:
torb docs site --checkfails on a broken internal link of the output, and the site ofmainis built by the gates so it cannot rot between releases. - The standard library's reference is generated, and its guide is written (decided 2026-09-26, when
torb docwas built):torb doc stdwrites/docs/<version>/reference/- an index per package, a page per module, every public construct with its signature, its doc comment, its sections and an anchor, and its own search - and the hand-written page of a package under/docs/<version>/std/links to it. The link is the site generator's to write, from the package name in the page's front matter: a Markdown link fromdocs/into generated output would be a linktorb docs checkcannot follow, and it would reach the skill andllms-full.txt, which have no site around them. Both are generated from one tag, so they cannot disagree about a version. The## Declarationssection of a package page stays written by hand for now, because it is what the skill and the bundle show a model; generating it fromtorb doc's model instead is a follow-up (torb doc --jsonalready has every signature and comment it needs). - A small hand-written stylesheet and no framework. The output has to be hostable anywhere (below), and a page is text, code and tables.
| Option | For | Against |
|---|---|---|
torb docs site — decided | one tool reads one tree; snippet checking, links and indexes are already there; the language documents itself with its own program | a Markdown renderer and a templating step to write; the look is ours to build |
| mdBook, Docusaurus, VitePress, Hugo | mature, themes, search | a second reader of the same tree with its own ideas about front matter, links and structure; a Node or Rust toolchain in the release pipeline; nothing is checked the way torb docs check checks it |
As built (2026-09-28): torb docs site <root> --output <dir> [--version <v>] [--brand <dir>] [--check]
(docs/tooling/torb-docs-site.md) writes the front page from docs/site/home.md,
/install, /imprint and /privacy from the pages of the same name in docs/site/ where they exist, every page and
design document below /docs/<version>/ with the navigation of the index tree, previous and next links and an edit
link to the forge, search-index.json (the term index above), and one stylesheet and one script. trb blocks are
coloured by the lexer and the resolver at generation time; every internal link of the output is checked. The Markdown
is std/markdown's tree with a renderer of the site's own (anchors, callouts, the rewritten links), not its HTML step.
site/ pages have the kind site and stay out of the skill and the bundle. The site image runs it from the release's
source (tools/deploy/Dockerfile.site), and nginx serves it with clean URLs - a flat page's own trailing slash
(/play/, /de/play/) redirects to the page without it, which is the one the site writes. /play is site/play.md
with the playground (below).
docs/versions.json ([SiteVersions] of site.trb) is written once, at the root, the same for every language: today
it names only the one version this build is of, because the site image bakes in exactly one version per deploy and
nothing yet accumulates a running site's history across deploys (below) - so the switcher, seeing nothing to switch
to, hides itself instead of the 404 it was reading before. docs/latest/ is a symbolic link Dockerfile.site makes
after copying the built site in, one per language directory the site was written in (find ... -name "$VERSION"), so
/de/docs/latest/ resolves exactly as /docs/latest/ does.
Not built yet: /learn, /download, /releases/<version>, a site that accumulates more than the one version of its
own deploy (which is what a real docs/versions.json needs), and a version's link to the same page in the newest one.
The playground#
Decision: the whole toolchain compiled to WebAssembly and run in the browser — check, lower and run in the VM — with no server.
| Option | For | Against |
|---|---|---|
torb in WebAssembly, the VM runs the program — decided | no server, no abuse to contain, works offline once loaded; the VM is conformance-gated against the native back end, so what the playground prints is what a binary prints | a download of the size of torb (fact 1: 16 MB native, similar in wasm, a few megabytes compressed) cached once; needs VM slice 7 (tasks) for anything concurrent; runtime/ has to build for wasm32-wasi (threads with one worker, setjmp in runtime/machine.c needs WebAssembly exception handling) |
| A server that builds natively (the Rust and Go playgrounds) | the real native binary | containers, isolation, quotas and abuse handling forever; a service to run |
| Check only, in the browser | smallest | a playground that cannot print is not one |
- A target is needed for it. A wasm
torbis a new target, soOperatingSystemgains a case (fact 18), which is a breaking change ofstd/coreand therefore belongs before 1.0. What each capability does there follows the rule of the JS back end (docs/design/JAVASCRIPT-AND-PHP.md): real, simulated (std/fsover an in-memory file system), or a compile error at the import (std/process). - Sharing is the URL: the source compressed into the fragment (
/play#code=...), which never reaches a server. - Measured before it is built: the time
torb checktakes for a ten-line program with the prelude, natively, is the number the slice records first; if the prelude's check dominates, the playground ships the checked prelude as data.
The playground, as built (2026-09-28)#
playground/ holds it: build.sh builds the toolchain, playground.js, playground-worker.js, playground.css and
the committed editor (playground-editor.js, .css and playground-codicon.ttf, built from editor/) are the
page's side, examples/ is the gallery of /play, and smoke-test.mjs runs the result under node. The user's page
is docs/tooling/the-playground.md; /play is docs/site/play.md. The editor, the
language server and the gallery came a day later, below.
Measured first, natively (Windows x64, the torb.exe of dafd3138), a ten-line program with the prelude:
torb check takes 600 to 680 ms, of which 440 to 510 ms is lexing and parsing, because a loose file's check reads the
whole standard library; torb run --timings - what the playground runs - checks in 228 ms (144 ms of it parsing the
modules the program reaches), lowers in 102 ms and reaches the first instruction after 379 ms. The prelude's check is
about half of that and does not dominate the way the decision feared, so the playground ships no checked prelude:
in the browser a warm hello world prints after 0.3 s, and a snapshot of the checker would be a format of its own to
keep in step with the compiler. It is the first lever if the start ever needs to be faster.
The target is browser-wasm64. OperatingSystem gained Browser and Architecture gained Wasm64 (fact 18: a
breaking change of std/core, made before 1.0 as decided above), torb build --target browser-wasm64 --emit-c emits
C for it, and torb check --every-target lowers for it as its ninth target, as tools/agree.sh emits for it.
std/os answers in the browser from std/os/src/browser/, with no natives: the system questions are Unsupported,
pageSize is 65536, machineArchitecture is .Wasm64, Directories.temporary() is /tmp.
Browser, notWasiorEmscripten: the case names the system a program talks to, and the rule of the JS back end (docs/design/JAVASCRIPT-AND-PHP.md) is the browser's column; .NET names the same targetbrowser-wasm(OperatingSystem.IsBrowser()), Gojs/wasm. A later JavaScript back end for the browser has the same system.Wasm64, 64-bit pointers: the VM keeps a value in 64-bit words laid out as the runtime's own structs (VM.md section 2) - aStringis two words that are atorb_text- which holds only where a pointer is a word. The C is compiled for wasm64 and lowered to wasm32 by binaryen (-sMEMORY64=2), so it runs on every engine, memory64 or not, and it is faster than real memory64, whose bounds are checked on every access:fibonacci(25)in the VM took 250 ms lowered and 350 ms with memory64 under node 24.- The compiler's C for the target is the host's C everywhere but
hostTarget(), whose onematch OperatingSystem.currentanswersbrowser-wasm64in the browser without startinguname; the machines' C stays the same for every machine, and the fixpoint is untouched.
The toolchain is emscripten 6.0.10, pinned in playground/build.sh and run from the image emscripten/emsdk:6.0.10
where no emcc of that version is installed. Over wasi-sdk it has, in one toolchain, what the runtime needs: setjmp
over WebAssembly exception handling (-sSUPPORT_LONGJMP=wasm, the legacy instructions every engine since 2021 runs),
the pthread stubs of a pool of one worker, the clock, randomness, an in-memory file system with std/ embedded at
/torb/std, and the lowered wasm64. wasi-sdk would have needed a JavaScript host with a file system written by hand,
and it has no wasm64. runtime/ needed three things: os/browser.c, the poller of the IO core that never opens;
torb_platform_stack_low from emscripten's stack; one processor. TORB_HOSTS_MACHINE is set as for every torb.
(Two more came with the language server, below: standard input the page feeds, and a turn to the blocking pool taken
in place, because the pool's start panicked without threads - which had made every stream of std/io fail.)
The capability rule is enforced where the VM runs a program (compiler/src/project/capability.trb,
reportMissingCapabilities in compiler/src/vm/run.trb): for the browser, an import of std/process, std/network,
std/dns, std/http or std/tls in a file of the program is an error at the import, after the check and before the
lowering; std/fs is emscripten's MEMFS, empty but for std/, with /torb/work as the working directory; the
environment is empty and standard input at its end. Not in the lowering, because the compiler itself imports
std/process and is built for the target; its natives link there and fail at run time, which no program of the
playground can reach.
Size and speed, measured on the builds of 2026-09-28 and 29, on a machine that ran other gates at the same time:
torb.wasm | 13.21 MB raw, 4.02 MB gzip -9 (what nginx sends, torb.wasm.gz beside it), 2.79 MB brotli -11, on the main of 2026-09-29 (12.41 MB and 3.76 MB the day before: it grows with the compiler) |
torb.js, playground.js | 98 KB and 33 KB, 26 KB and 10 KB gzipped |
| hello world, headless Chrome, local server | first output 0.50 to 0.65 s cold (fetch, compile, instantiate, check, lower, run), 0.26 to 0.34 s warm, 0.50 to 0.56 s on a second visit from the browser's cache |
| the same over 50 and 10 Mbit/s | 1.3 to 1.5 s and 4.0 s cold, 0.8 s on a second visit over 10 Mbit/s |
| WebKit (Safari's engine), local | 1.4 to 2.2 s cold, 0.8 to 1.0 s warm, for a twelve-line program |
node 24 (smoke-test.mjs) | 0.59 to 0.88 s cold, 0.31 to 0.45 s warm |
| the VM | about 4.5 times slower than natively (fibonacci(25): 244 ms, 54 ms); the compiler's own work about 1.5 to 2 times |
The page's side is hand-written and small, and takes one pattern from each of the playgrounds it was measured
against, and nothing more: a textarea over a coloured copy of itself (the Gleam tour's editor), in the token classes of
the site so the colours are the site's; Run and Ctrl or Cmd+Enter (the Rust Playground); the output under the editor,
the checker's lines marked in it with the message beside the line (the Go Playground); Reset and Show solution for an
exercise, with a line that says whether it is solved (the Svelte tutorial); and the source in the address, deflated
with the browser's CompressionStream and base64url behind #code= (the TypeScript playground's #code/), written as
the reader types, so the address is the share button. A worker runs the toolchain, a fresh instance per run from the
module the page compiled once, so Run can become Stop and a program that never ends never freezes the page; output
beyond a megabyte stops the run. TorbPlayground.mount follows the contract of runnable blocks in
torb docs site; a mount inside an element with data-playground-page is the page
/play - the editor fills it and the address carries the source.
The site: torb docs site --playground <dir> (default build/playground beside docs/) copies the nine files
and the folder examples/ into assets/ where they were built; no page names them, so a site without them is still
whole. Every page then carries a hash of them (data-playground-version), the site's script loads
assets/playground.js?v=<hash>, and playground.js hands its query on to the workers, torb.js, torb.wasm, the
editor and the examples - so tools/deploy/nginx.conf lets a
browser keep all of them for a year, like the stylesheet, and sends torb.wasm.gz with gzip_static. The site's
labels of the playground are words of its interface (playground.* in site-strings.trb and each language's
strings.json), and the line that says how to run an exercise of Start locally stays below the mount. The site image
builds the playground itself, in the pinned emscripten image, from the release's source and with the release's torb -
the same inputs give the same wasm, and nothing but the release is downloaded for it. CI's playground job (.forgejo/workflows/gates.yml) builds it in that image from the
compiler of the run and runs smoke-test.mjs.
Not built: brotli (the stock nginx has no module for it; 30% smaller), a checked prelude (above), and keeping the
compiled module between visits in IndexedDB (browsers no longer store a WebAssembly.Module there; the HTTP cache and
the engine's own code cache do it).
The editor, the language server and the gallery, as built (2026-09-29)#
The owner asked for two things after the first playground: examples worth opening, shown sensibly, and a whole language server - completion, and the errors while typing rather than after Run. Both are built.
torb lsp runs in the page as it runs for an editor: over standard input and output, in a worker of its own. The
server is already a value without a transport (LANGUAGE-SERVER.md section 3), so the question was only how the page
reaches its standard streams, since nothing in a browser may block and a message reaches a worker only once its thread
is back in the event loop.
| Option | For | Against |
|---|---|---|
| The runtime's browser target feeds standard input from the page — decided | the server, its framing, its tick and its timers are the code every editor runs, and nothing of the compiler changes: no native, no flag, no seed; the tasks of the server are state machines in the heap, so nothing lives on the stack while it waits | the scheduler of the runtime has to be able to return to the page (a few lines in task.c and os/browser.c, compiled only by emscripten) |
| An exported entry point that takes one message and answers the replies | no scheduler work | the compiler can export no TorbScript function to C: a new native to register a closure, which is the two-commit seed dance, and a second transport of the server's |
A blocking read in the worker (Atomics.wait on a SharedArrayBuffer) | the runtime unchanged | cross-origin isolation (COOP and COEP) on every page of the site, and a worker blocked between messages |
| Asyncify or JSPI | the runtime unchanged | Asyncify doubles the size of a 13 MB module and slows every call; JSPI is not in every engine the playground supports |
The mechanism, in runtime/os/browser.c: the page's stdin callback answers undefined where it has nothing yet, so
a read fails with EAGAIN as on a non-blocking descriptor; a read of standardInput() that meets it waits as an
operation of the IO core (runtime/stream.c); where the main thread's scheduler would sleep while it waits, it returns
to the page instead (task.c) - the first time by giving up the stack of main (emscripten_unwind_to_js_event_loop),
with the program alive. torb_browser_resume(input), exported, wakes the read and runs the scheduler until it waits
for the page again, and answers the milliseconds to its first timer, which the worker waits for with setTimeout.
Natively a message waits for at most one step of the server's longer work (LANGUAGE-SERVER.md section 2), because the
reader is a task of its own; in a page the reader can only be handed a message while the scheduler is out of the way,
so tasks that run on and on return to the page after a slice of 50 ms as well, and the worker calls back at once,
behind the messages that came meanwhile (the answer 0, a MessageChannel rather than setTimeout, whose clamp would
cost a slice 4 ms). A hover sent in the middle of a rename, which takes 7 seconds in the browser because it checks the
program twice, is answered within the next slice. A page whose stdin answers null - every run - sees standard input
at its end, as before, and is never sliced. Process.exit from inside the server throws emscripten's ExitStatus to
the worker, which tells the page; the page starts a new server and opens its documents again, up to three times.
playground-worker.js has two roles now, a run and the language server, and smoke-test.mjs runs the worker itself
in a thread of node's: initialize, the diagnostics of a document opened and changed, completion at a member access,
hover, semantic tokens, a rename, and the exit.
The editor is CodeMirror 6 with @codemirror/lsp-client, 452 KB minified and 142 KB gzipped, loaded when an
editor is about to be used. (A day later it became Monaco, below: "Monaco in every runnable block".)
| Option | For | Against |
|---|---|---|
CodeMirror 6 and @codemirror/lsp-client — decided | modular and small; the client speaks LSP over any transport of { send, subscribe }, and has completion, diagnostics, hover, signature help, definition, rename and formatting; themed by CSS, so the site's tokens and both themes apply | no semantic tokens in the client (the editor adds them, 60 lines) |
| Monaco with a language client | the editor of VS Code | several megabytes, workers of its own, and monaco-languageclient brings much of VS Code's API; a look to fight rather than to theme |
| The textarea, with a hand-written client | nothing to vendor | completion lists, tooltips, the positions of a change and its undo are a text editor to write |
What the editor adds to the client is TorbScript's, in playground/editor/editor.mjs: the colours - the lexer's of
playground.js at once, sharpened by the server's semantic tokens where one covers the same name, so a parameter and a
mutable binding look as they do on the site - the indentation of the formatter, brackets and comments, definition on
Ctrl or Cmd and a click, and the Markdown of hover and completion sanitized to the elements of prose, because it comes
from doc comments and a doc comment of a shared link is anybody's. A mount starts as the light editor and becomes
CodeMirror at once on /play and at the first focus elsewhere, so a page of the documentation with ten runnable blocks
loads neither the editor nor the toolchain until a reader clicks into one; the language worker is one per page and
holds every mount as a document (file:///torb/work/main.trb on /play, block-<n>/<file> elsewhere). A run never
waits for the language server, and the language server never for a run: they are two workers over the one compiled
module.
The bundle is committed, with its source and its lock file, rather than built in the site image: building it
needs npm and the registry, and the site image downloads nothing but the release; sh playground/editor/build.sh
(npm ci --ignore-scripts, then esbuild) rebuilds it where the editor changes, the licenses of the fourteen packages
it holds stand at its end, and CI's playground job checks that the committed file is what the source builds.
The gallery is playground/examples/: a file per example and an index.json of id, file, title,
description, category, level, default and de (the German title and description), copied beside the toolchain
by build.sh and into assets/examples/ by the site. /play opens the address's program (#code=), the example it
names (#example=<id>), or the default example; Examples above the editor lists them by category in a panel
(the overlay's shadow, rising 4 px, a sheet on a phone), in German on the German site; choosing one asks first only
where the reader changed the code, and the address names the example until the code changes.
Measured under node 24 and in headless Chrome on a local server, on a machine running other gates: the language
server answers initialize in about 90 ms after the module is compiled, checks the first document in 190 to 370 ms
(it reads the standard library once), a keystroke's change and a completion after it in 50 to 150 ms, hover and
semantic tokens in under 10 ms; the check of the workspace after initialized takes about 0.6 s once, in the
background. On /play the editor replaces the light one about 0.1 s after the page loaded, and a typed error is
underlined within a second (half of it the client's wait for the typing to stop). A run's first output is as fast as
before: the run worker is untouched.
Monaco in every runnable block (2026-09-30)#
The owner's feedback on the live site a day later: the language server was on /play but not in the runnable blocks
of the documentation, where it belongs as much; the hover and completion documentation of CodeMirror's client was
badly laid out ("VS Code would do better"); and before its first click a block's text was larger than after it and sat
on the bottom edge of its frame. The last had a cause of its own: the light editor's coloured copy carried the class
code, so the rule of the front page's panels (.panel-code pre.code, 15 px on 24 px) sized it while the textarea over
it kept 14 px on 21 px; the editor that replaced it had its own.
The editor is Monaco, the editor of VS Code, on /play and in every runnable block, with a bridge of its own to the
language server. Its hover, completion and signature widgets render the server's Markdown the way VS Code renders it -
the declaration a hover opens with coloured by the language's grammar, links, lists, code in a sentence - which is
what the feedback asked for, and what CodeMirror's client would have had to be taught piece by piece.
| Option | For | Against |
|---|---|---|
| Monaco, and Monaco's providers speaking JSON-RPC — decided | the widgets of VS Code; the bridge is small (editor/protocol.mjs and the providers in editor.mjs), knows only the requests the server answers, and its protocol half runs under node, so smoke-test.mjs drives the page's own client against the worker | 3.3 MB minified, 0.85 MB gzipped - six times CodeMirror; not made for touch |
Monaco with monaco-languageclient (@codingame/monaco-vscode-api) | VS Code's own client | megabytes of VS Code's services beside the editor |
Monaco with @vscode/monaco-lsp-client, which monaco-editor 0.57 ships | Microsoft's | it imports every contribution of Monaco - the diff editor, find, folding and the rest the playground leaves out |
| CodeMirror, its client extended | small | the Markdown of the widgets and the grammar of their code are ours to write; not the editor readers know |
- The bridge registers, for the language
torbscript, completion (an item's signature and documentation resolved when it is shown, snippets, the documentation beside the list from the start), hover, signature help, definition within the document, rename with its check first, formatting, the quick fixes of a diagnostic, and semantic tokens, whose legend putsmutablefirst so the theme findsvariable.mutable; the server's diagnostics become markers. Monaco renders the Markdown and sanitizes it: no HTML, and no link that runs a command. One language worker per page holds every block as a document, as before; a change is sent a quarter of a second after the last keystroke, or at once when a request needs it, as the whole text. - A Monarch grammar (
editor/grammar.mjs) colours the code before the server has checked it and the```trbblocks of the documentation; it follows the extension's TextMate grammar, and the language configuration - comments, brackets, indentation - is the extension's file itself. - The theme is read from the site's tokens on the page (
editor/theme.mjs) whenever the site's theme changes, so the colours have one source; the widgets are raised surfaces with a hairline and the overlay's shadow, their prose in Chivo and their code in Geist Mono without ligatures and with the slashed zero, and the matched letters of completion are weight, not colour. Brackets keep the colour of punctuation, as on the site. - The swap moves nothing. A block shows the site's highlighted code in a frame of exactly Monaco's metrics - taken
from the
prethe site wrote, so the front page's larger panels keep theirs - and Monaco starts with the site's colours as its first semantic tokens, until the language server's replace them. Measured in headless Chrome: the frame and the first character stay at the same pixel, in both themes, for a block of the documentation, a panel of the front page and/playwith its line numbers. - When it loads: the editor when the pointer comes over a block or the focus into it, so the first click already
lands in Monaco; the 4 MB language worker at the first focus in the code, as before; on
/playboth at once. A device of touch alone ((hover: none) and (pointer: coarse)) keeps the light editor, without the language server, and never fetches the editor; a browser that cannot load it gets the light editor in its place. - Built like the editor before it:
playground/editor/package.jsonand its lock file (monaco-editor 0.57.0 and esbuild),build.mjsbundling only the contributions the playground uses intoplayground-editor.js, Monaco's stylesheet intoplayground-editor.cssand its icon font asplayground-codicon.ttf, all committed and rebuilt byte for byte by CI. Three changes to Monaco's sources are made on the way, each checked: the diff editors become empty classes (170 KB less), the warning of the editor worker's code running in the page is left out - the playground ships no editor worker; what it needs of one, the smallest edits of a formatting, is a moment's work in the page - and the font is added by the module with the build's?v=, which the stylesheet cannot know. The site copies the two new files and hashes them into?v=.
Measured in headless Chrome on a local server (gzip), on a machine running other work: the bundle is 3.31 MB, 849
KB gzipped, its stylesheet 19 KB and the font 75 KB, against CodeMirror's 452 KB and 142 KB; from the pointer entering a
block to Monaco in its place 0.35 to 0.6 s the first time; the first completion in a block 0.9 s after the first click,
the language worker's start and first check included; completion after a . in 0.2 to 0.3 s; a typed error marked 0.3
s after the keystroke.
/play is wide. The site's stylesheet gives the page the width of the screen up to 1840 px, the text around the
tool keeping its measure; the tool is a toolbar - the gallery, Run, Format - above the editor and the output, which
stand side by side from 1100 px, 3 to 2 and from 1700 px 7 to 4, as tall as the window. The gallery shows the
categories as columns with a heading each - a marker in the category's colour, taken from the syntax colours in order
and the brand's accent last, its name and its count - each example's title, its level as a small badge and its line
of description, the open example marked by its category's colour; the panel has the brand's accent as its top rule.
The benchmarks page, as built (2026-09-30)#
Decided: the site shows how fast TorbScript is, measured by the forge every night
(site/benchmarks.md). Six programs of the Computer Language Benchmarks Game are in
benchmarks/game/, each in TorbScript and in the Benchmarks Game's own C, Python and JavaScript; benchmarks/game.sh
builds and runs them all - TorbScript with torb build and in the VM - checks every output against the C's and writes
one JSON report (benchmarks/README.md, "The Benchmarks Game").
nightly.yml ── publish ── images ── benchmarks.yml: game.sh with the nightly's torb ──► release `benchmarks`
benchmarks.json (newest)
Dockerfile.site: tools/fetch-benchmarks.sh ──► build/benchmarks.json ──► torb docs site benchmarks-<version>.json- Measured on the forge's runner, never on a developer's machine. The same machine every night, every language in
the same run, the languages taking turns. A report from anywhere else can be rendered -
torb docs sitereadsbuild/benchmarks.jsonor the file--benchmarksnames - and the page then says it was not measured by the forge ("origin"is notci). - After the images. The runner runs two jobs at a time, and the image builds are hours of C compiles; measuring
beside them would measure them too. So a nightly's site image, built before the measurement of its night, shows the
report of the night before; the site of main (
edge) fetches the newest when it is built. - A release of its own,
benchmarks, not an asset of the nightly. release-sync refuses a release with an asset its signedSHA256SUMSdoes not list (verifyHashesoftools/release-sync), and the report is written after the sums are signed. A prerelease without a version in its tag is never placed by release-sync, likeseeds. The report of every night stays beside the newest, asbenchmarks-<version>.json, for a history the page does not draw yet. - Without a report the site is still whole.
tools/fetch-benchmarks.shexits 0 when there is nothing to fetch, and the page says that nothing is measured yet; a report that cannot be read is left out the same way. A report with fewer than C's and the native binary's times of every program is kept as the workflow's artifact and not published. - What it measures, honestly. The Benchmarks Game's plain single-threaded programs (its #8 family), not its fastest;
gcc -O2againsttorb buildin its release profile, the same compiler and optimization; the VM and Python on a smaller input than the binaries, each ratio against C on the same input; the VM as it is downloaded, a static musl binary. Where TorbScript is slower, the page says so and why - PERFORMANCE.md section 4.3 has the first numbers and the causes. - Go and Rust are left out for now: the question the page answers is TorbScript against C and the VM against the interpreters people know, and each more toolchain is minutes of every night on the one runner.
Hosting#
Decision: every public URL is under torb.dev and packages.torb.dev, and the output is files; the provider behind them is replaceable without changing a single URL a script or a lock file contains.
- The site: static files from CI to a static host — GitHub Pages (the repository is there already, fact 17), Cloudflare Pages, Netlify, or a small server with Caddy. All four serve a directory; the one that is chosen is a question of price and taste, not of design.
- The downloads: release archives are large and many; they live in object storage or GitHub Releases, and
torb.dev/download/<version>/<file>answers with them - a redirect while the repository is public and GitHub's own URL can be given out, and (decided 2026-09-25, section 7.11's "Public downloads while the repository is private") a synced copy on the server itself while it is not, since a private repository's release URL answers a login page to everyone else. Either way the install scripts andtorb upgradeonly ever see the torb.dev URL. .devis on the HSTS preload list as a whole top-level domain, so every page is HTTPS or nothing; every host above handles the certificate.- DNS and mail stay with the owner, not with the host:
security@torb.dev(section 9) needs mail for the domain.
7. The registry#
Decision: packages.torb.dev is two things with one boundary. Reading is static files — a sparse index, immutable archives, generated documentation — behind a CDN, which any mirror can serve. Writing is one small service — accounts, owners, tokens, publish, yank — that is the only writer of those files. A client needs the service only to publish; it resolves, downloads and verifies from files.
7.1 Owners#
owner/name is decided (CONCEPT, PROJECT.md section 6: segment = lowercase (lowercase | digit | "-")*, a dotted owner
reserved). An owner is a namespace on one registry, and a project binds an owner to a registry with
registry "acme", url:; an owner that no project binds resolves at packages.torb.dev.
| Kind of owner | How it is created | How it is verified |
|---|---|---|
| A person's | the account's owner name, chosen at sign-up, first come | the account: sign-in through GitHub, GitLab or Codeberg, or with an e-mail address and a passkey — several providers from day one, so an owner is not a GitHub account (crates.io is) |
| An organisation's | created by a person, with members and roles (owner, publisher) | the members |
| A verified domain | an organisation proves a domain with a DNS TXT record (_torb.acme.com) | the record, checked when the domain is claimed and once a day after, and shown as a badge; this is pub.dev's verified publisher without Google's console |
| Reserved | the registry's list: std, torb, torbscript, and names that would confuse (below) | — |
| Elsewhere | an organisation claims its owner and states that it publishes on its own registry | the public registry answers every request for that owner with "acme publishes at <url>; bind it with registry "acme", url: ..." and serves nothing |
The account page is in the registry's shell (built 2026-10-10, #295): /account and the pages around it (sign in,
sign up, claim, passkeys, authenticator app, step-up) are written by the write service through the same shell as the
package pages - the header with the packages label, assets/site.css, assets/registry.css, the footer, the stored
theme and the theme toggle of assets/registry.js - with the ink primary button, never a red one. Their
Content-Security-Policy is default-src 'none'; script-src 'self'; style-src 'self'; font-src 'self'; img-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none' (form-action also names a provider's authorization
endpoint on a page with a button to it): the files of the host itself and nothing inline - no inline script, no
style=, no handler, no unsafe-inline. The three pages that ask the browser for a passkey also load the one script
/account/passkey.js, the same policy. Every page is still Cache-Control: no-store, escaped and CSRF-protected.
The "elsewhere" owner is the answer to dependency confusion that a registry can give. PROJECT.md's binding already prevents it where the line is written; an organisation that forgets the line in one project would otherwise fetch whatever a stranger published under its name. Claiming the owner publicly closes that gap for free.
As built (2026-10-12, #19): an owner member of an organisation - with the owners action and a Torb-Code like
every owners call - or the operator names the registry with PUT /api/1/owners/<owner>/elsewhere {url} and takes it
back with DELETE. The URL is https:// and a host name - no IP literal, no localhost, not the registry's own, no
user, query or fragment, at most 200 bytes - kept in one form (the host in lower case, a name beyond ASCII as xn--, no
port 443, no trailing /), and is only ever written and printed, never fetched, so it gives the registry no request to
make. A person owner is a 400, an owner that has a package here (published, or reserved by its trusted publishers) a
409: a release is never moved (7.3). While the URL stands, a publish, a trusted-publishing exchange and a new package
name of the owner are a 409 that names it and the registry "acme", url: ... line that binds it. The owner's file
(7.4, 7.14) carries the URL, signed, so a mirror cannot invent one; the client prints it and never binds it by itself
(#342, 7.13), because a stranger's claim must not rebind anyone's build.
As built (2026-10-13, #341): an owner member of an organisation - the same call class, Torb-Code and operator
rule as above - proves a domain with PUT /api/1/owners/<owner>/domain {domain} and withdraws it with DELETE (the
domain in the body too, since an organisation holds up to 5). The record is a TXT record at _torb.<domain> with one
string torb-owner=<owner>: the owner's name binds it to one owner, and only whoever controls the domain's DNS can add
it, so no nonce is asked for. Any string of any TXT record of the name counts, so the record can sit beside others.
The registry looks it up in the request, through the std/network resolver (the system's name servers, or the
REGISTRY_DNS_SERVERS it is given) with the resolver's own timeouts, at most 2 lookups at a time and outside the write
lock; it stores nothing unless the record is there, and the 422 says which of four things it met: no such name, no
TXT record, a record that names another owner, or a failed lookup. The domain is a domain name in lower case, a name
beyond ASCII as xn--, with at least two labels, no IP address and at most 253 bytes (400); one owner holds a domain
(409 for another's), and an organisation at most 5. Held domains go into domain: of the owner's file, sorted and
separated by spaces, so the signed format is the one of #19 and its changed grows with each change, and the package
page of every package of the owner shows verified: <domain> in its head.
As built (2026-10-13, #343): a domain can be lost or sold, so the registry checks every verified domain again once
a day, with the same lookup and the same limit of 2 at once, from a task of the service beside the documentation worker.
Each domain's day starts at the time of day it was verified, so the lookups spread over the day, and one not checked for
a day - the registry was down - is checked at start; two checks are at least half a day apart. A lookup that is answered
without the record (no such name, no TXT record, only other owners') counts a missing day; one that gets no answer
(a timeout, SERVFAIL) counts nothing, because an outage of a provider is no loss of the domain, and a check that finds
the record sets the count back. The third missing day in a row withdraws the domain: its row goes, the owner's file
and package pages are written again without it, the audit log gets domain-withdrawn by registry, and the domain can
be claimed again at once. domains keeps checked and failures for it; a database from before gets both, as checked
when it was verified.
Names that would confuse are refused at creation, for owners and for package names: equal after removing hyphens
(acme-corp against acmecorp, which is JSR's rule), within one edit of an owner or a package with many dependents, or
one of the pairs ASCII cannot tell apart at a glance (l/1, o/0, rn/m). The segment grammar already rules
out every Unicode confusable. A refusal names the existing owner, and the moderators can grant the name by hand.
7.2 Publishing#
What is uploaded: source, and only source. An archive holds the package's files by PROJECT.md section 3's rule —
every .trb below the package directory that is not inside a nested package, skipping hidden directories, build,
target and node_modules — plus the resource files its code names (RESOURCES.md), project.trb,
project.lock.trb with its settings section and its graph as information, README.md and the license files.
No include or exclude list: it would be a second source of truth about which files a package has, which is the
disease PROJECT.md section 1 lists. No binaries, no generated C, and no C at all until foreign is decided (section 2; open, see FOREIGN.md section 5.3).
The archive is a deterministic tar.gz; the identity is the hash of the tree. Entries sorted, timestamps zero,
modes fixed, owner empty. The hash the lock pins is not the hash of the archive's bytes but sha256: over a
canonical listing of every file's path and SHA-256 — Go's h1: directory hash — so a mirror may recompress, an
archive: source (PROJECT.md section 7) is verified the same way, and a change of compression level never changes a
lock file.
$ torb publish --dry-run
acme/http 1.2.5
read by project.trb: file VERSION, variable CI_COMMIT_TAG
checked: 7 files, no problems (every target)
capabilities: network (src/client.trb), environment (src/proxy.trb)
files: 14, 38 KB, archive 11 KB
hash: sha256:6f1c...
nothing was uploaded (--dry-run)torb publish, in order:
- Read the static subset, and refuse without
name,version,descriptionorlicense(PROJECT.md section 10: "required to publish"). - Evaluate
project.trbin the sandbox, write thesettingssection ofproject.lock.trb, and read it back with the static reader — a setting that does not survive being printed is a refusal (PROJECT.md section 8). - Print the environment variables the manifest read, by name, which is PROJECT.md's first pitfall made visible.
torb check --every-targetof the package against its locked graph: a package that does not check, or does not lower on a target, is refused locally, likecargo publish's verification build — except that nothing is built and nothing runs.- Collect the files, refuse when the code names a resource the archive does not contain, write the archive, compute
the hash.
--dry-runstops here and prints the summary above. - Upload with a token (section 7.6).
The registry, on receipt, trusts nothing the client computed: it checks that the token may publish this
owner/name, that the version is new, that the archive has the shape above and is within the size limit, re-reads the
lock's settings statically — it never evaluates a project.trb — recomputes the hash, and re-derives the
capabilities from the imports (section 7.8). It then appends the release to the index, signs it, stores the archive and
queues the documentation (section 7.7). The server does not refuse a package that fails to check on its side: its
toolchain may be a different patch release, and a checker bug on the server must not block a publisher. It shows the
result instead.
Trusted publishing: a CI job publishes without a stored token, with a short-lived one exchanged for the CI system's OIDC identity (crates.io, npm, PyPI and JSR all do this since 2023-2025). An owner can require it and switch tokens off for a package.
7.3 Immutability and yanking#
- A published version is never replaced and never deleted (CONCEPT: "A version can be withdrawn for new
resolutions, never replaced or deleted"). There is no revert window — Hex allows one of an hour, npm 72 hours; a
mistake is fixed by the next patch, and
--dry-runis where a mistake is caught. torb yank acme/http 1.2.4 --reason "..."withdraws a version for new resolutions. A lock that already pins it keeps building, and prints the reason once per build.--undoreverses a yank.- The one exception is removal by the moderators, for malware or a legal order. The index keeps a tombstone line with the reason, the archive is gone, and a lock that pins the hash fails with that reason instead of a download error.
- Deprecating a whole package (
torb deprecate acme/http --use acme/http2 --reason "...") is a flag on the package, shown bytorb addfor the packages named on its command line and, once the page exists, on the page; it withdraws nothing, every version resolves as before, and--undotakes the flag back. A member with theyankaction may (built 2026-10-10, sections 7.4 and 7.13).
7.4 The index and the API#
Decision: a sparse index of static files, one per package, in TorbScript, append-only. A value is its constructor call (ENCODING.md), the lock file is a receiver script of literals (PROJECT.md section 8), and the index is the same kind of file: the static reader reads it, nothing evaluates it.
https://packages.torb.dev/index/config.trb the registry's settings and its public keys
https://packages.torb.dev/index/<owner>/<name>.trb one file per package, append-only
https://packages.torb.dev/index/<owner>/owner.trb the owner: kind, verified domain, or "elsewhere"
https://packages.torb.dev/archives/<owner>/<name>/<version>.tar.gz
https://packages.torb.dev/api/1/... publish, yank, owners, tokens, search// index/acme/http.trb - written by packages.torb.dev. Lines are only ever appended.
package "acme/http"
release "1.2.4" {
hash "sha256:0b7e..."
language "0.4.0"
dependencies {
runtime "acme/json:^2.0.0"
}
capabilities "network"
signature "ed25519:..."
}
release "1.2.5" {
hash "sha256:6f1c..."
language "0.4.0"
dependencies {
runtime "acme/json:^2.0.1"
}
capabilities "network", "environment"
signature "ed25519:..."
}
yank "1.2.4", reason: "sends the proxy password in clear text", signature: "ed25519:..."
deprecate "acme/http", use: "acme/http2", reason: "http2 keeps the API", signature: "ed25519:..."The client reads exactly this format since 2026-09-26; what this section left open is decided in section 7.13.
A deprecation is a line of the package, not of a version:
deprecate "<package>", use: "...", reason: "...", signature: "..."(useandreasonare optional) andundeprecate "<package>", signature: "...". The package is the file's own, or the file is refused; the last of the two lines holds, and a seconddeprecatereplaces the notice of the first. The index stays append-only. A reader from before the lines skips them as unknown words and verifies the rest (section 7.14, "the position").Only what resolution needs is in the index: the version, the hash, the
languageminimum, the runtime dependencies and the capabilities — the last so thattorb addandtorb updatecan show "this update gains the network" (CONCEPT) before downloading anything. Description, keywords, the README and the documentation are on the API and the page.Append-only, and the yank is a new line rather than an edited field (crates.io edits
yankedin place): a client or a mirror that has a prefix of the file fetches the rest with an HTTP range request, and the file can be cached by the CDN with a short lifetime and validated withETag.The owner directory is the fan-out:
owner/namealready spreads the files, so crates.io's prefix directories (ca/rg/cargo) are not needed.Every record is signed by the registry's key (Ed25519), whose public half ships in
torband is listed inconfig.trbwith its successor for rotation. Hex signs its registry the same way. It is what makes a mirror untrusted: a mirror can withhold a release, but it cannot change one. Built on 2026-09-27; section 7.14 decides what this left open.
| Option | For | Against |
|---|---|---|
| A sparse static index — decided | CDN-friendly; a client fetches only the packages of its graph; mirrors are wget -r plus a signature check; the format is the language's own | a request per package on a cold resolve (HTTP/2 multiplexes them) |
| A git repository as the index (crates.io until 2023, Julia's General registry) | history for free; publishing can be a pull request | every client clones a growing repository; crates.io moved away for that reason. Kept as an archival export: the index is mirrored to a public repository daily |
| Go's module proxy: the import path is the location, the proxy caches, a checksum database is the trust | no accounts at all | the checksum database is a transparency log to run; a name is a host (PROJECT.md section 7 kept option (C) as a reserved name form, not as the registry) |
A JSON API per package (npm's packuments, JSR's meta.json) | familiar | a second format for the same data the lock already writes in TorbScript |
7.5 Mirrors, vendoring and offline#
- A mirror is a directory of the same files, named in the user's own configuration (
~/.torb/config.trb,mirror "https://packages.internal.test") and never in a project: where a build machine downloads from is the machine's business, and the lock pins hashes and signatures, not URLs. Built 2026-10-09 (compiler/src/package/user-config.trb,docs/tooling/config-trb.md):- The file is
$TORB_CONFIG, else<torb home>/config.trb, and no file is no mirror. It is a receiver script of literalmirror "<url>"lines, read statically. A line that is anything else, a URL that is neitherhttps:,http:norfile:and a directory, and a mirror named twice are errors at their line. A relativefile:path is relative to the directory ofconfig.trb. - A registry on the network is asked through its mirrors in the order of the file, and at its own URL last. A
file:registry is never mirrored, andtorb publishnever goes through a mirror. - A 404 or 410, a
file:mirror without the file, or a mirror that cannot be reached passes the request on to the next source, and the first answer wins. Where no source has a file and one could not be reached, the error names every source. - Trust never comes from a mirror. A mirror's
index/config.trbis checked against the keys shipped or pinned for the registry. The keys of a registry with neither are fetched from the registry itself, so the first contact that pins them never goes through a mirror. A changed or unsigned record, or an archive of another tree hash, from a mirror is refused naming the mirror, and nothing it served is written into the cache. - The lock names the registry, so a lock written through a mirror is byte for byte the one written without it. The
cache keeps an index copy under the registry, whichever source served it, and
--offlinecontacts no mirror.
- The file is
- The cache is content-addressed:
~/.torb/cache/<hash>/, verified on every extraction, shared by every project. --offlineresolves from the cache and the lock only, and fails naming the first package it would have to fetch.torb vendorcopies the locked graph intovendor/and writes asource ... path:line per package, which is PROJECT.md's own mechanism rather than a new one.- As built (
compiler/src/package/vendor.trb,docs/tooling/torb-vendor.md):vendor/<owner>/<name>/holds the package as the cache unpacks it, and every tree hash is checked, from the cache or from the registry, before the first file is written. A second run changes nothing, and a package that left the graph leavesvendor/andproject.trb. The lock lists a copy as apath:package, which nothing hashes afterwards, andtorb publishrefuses a project that depends on one.tools/vendor.shbuilds the project with an empty cache.
- As built (
7.6 Security#
| Threat | Answer |
|---|---|
| Code runs on install | cannot happen by construction: no install scripts, no build scripts (CONCEPT); a registry package's project.trb is never evaluated, its lock is read (PROJECT.md section 8); checking and documenting a package runs none of its code (7.7) |
| Dependency confusion | owner bindings (PROJECT.md section 7), the registry pinned per package in the lock, "elsewhere" owners (7.1) |
| Typosquatting | owner namespaces, the similarity rules of 7.1, and torb add printing the owner, its verification and its age for a package it has never seen |
| A stolen token | tokens scoped to owners, packages and actions (publish, yank), with an expiry; trusted publishing needs no stored token; a mandatory second factor (TOTP or a passkey) for every account that can publish: a member of any owner as owner or publisher cannot make a token with the publish action without one, a token made with one stops publishing when the account loses its last, and the last factor of an account that can publish cannot be removed (the registry refuses it; it does not revoke tokens). A passkey is a factor by itself; a provider sign-in will need TOTP or a passkey on top (not built yet: the token rule is what is enforced today). Tokens made before this was asked for keep working until their expiry (at most 365 days); trusted publishing uses no stored token and is not asked. A stolen token also cannot mint a stronger one or change who owns what: a token with publish or owners, a new organisation, a member added, changed or removed and the trusted publishers each want a fresh code of the factor in the Torb-Code header (for a passkey, a step-up approved on the account page within five minutes, single use; a stolen token can start three but not approve them), and the first token of an account without a factor lives 30 days and cannot mint publish or owners tokens. Unbound tokens are only for an account that cannot sign in: the operator makes none for an account with a way to sign in and no factor, and an account the operator made is given to its person by a single-use claim link (an hour, kept as its SHA-256), whose provider sign-in links the identity, after which the person enrols a factor (issue #276) |
| A false badge | a verified domain is a TXT answer, checked at the claim and once a day after (7.1) and not signed, since DNSSEC is out of scope: whoever can forge the answer to the registry's resolver at that moment - a hijacked name server or recursive resolver on the path - can have the badge for a domain, and nothing more: the badge names a domain and lets no one publish under it. The registry asks the servers it is configured with, or the system's, so a deployment that wants less trust points REGISTRY_DNS_SERVERS at a resolver it runs itself; a domain whose owner changes keeps the badge until its record has been missing on three daily checks in a row, which withdraw it (#343) |
| A replaced archive | the lock pins the tree hash; the index is signed; the archive store is write-once |
| A malicious release | yank and removal (7.3), the advisory database and torb audit (CONCEPT), the capability diff on update (7.8) |
| Where did this come from | optional Sigstore provenance: a release published by trusted publishing carries an attestation that names the repository and the commit, shown on the page, verified by torb add --verify-provenance. Required for nothing at launch |
The advisory database is a public repository of TorbScript files, one per advisory, reviewed by pull request, with an export in the OSV schema so that osv.dev and the scanners that read it see TorbScript packages too.
The advisory file, as built (2026-10-24, #451). The file format and the local reader exist; the public repository,
the endpoint that serves it and the OSV export do not (steps of #309 that wait for the owner). A database is a
directory, and an advisory is one file in it, <directory>/<owner>/<name>/<id>.trb, so a package's advisories are
found by its name without opening the rest. <id> is TORB-<year>-<number> (four digits, then four or more). The file
is a receiver script of literals, read statically with the reader of the index and the lock (7.4, 7.14): nothing is
evaluated.
advisory "TORB-2026-0001"
package "acme/http"
summary "sends the proxy password in clear text"
severity "high"
affected ">=1.0.0 <1.2.5", ">=2.0.0 <2.1.1"
published 1760000000
aliases "CVE-2026-12345"
references "https://example.test/advisory"| Word | Required | Takes |
|---|---|---|
advisory | yes | the id, which is the file's name without .trb |
package | yes | owner/name, which is the file's directory |
summary | yes | one line of text |
severity | yes | low, moderate, high or critical |
affected | yes | one or more requirement texts of dependencies lines (PROJECT.md section 6a); a version is affected where any of them allows it, and a pre-release only where a text names one |
published | no | Unix seconds, as changed in owner.trb |
aliases, references | no | one or more texts: the ids other databases know it by, and links |
A file is refused, never skipped, where a word is none of these, a word is written twice or takes anything but plain
texts, a required word is missing, advisory or package disagrees with the path, severity is not one of the four,
an affected text is no requirement, or published is no number: a database is read whole or not at all, and a typo
must not turn into "no findings". A directory of a package holds nothing but <id>.trb files. There is no signature
in this step: the directory is the reviewed repository, and pull request review is the trust. Whether a copy served by
a registry is signed like the index (7.14) is decided with the endpoint.
torb audit --advisories <directory> (docs/tooling/torb-audit.md) reads the advisories of every locked registry
package, a path: package skipped, and reports each one that covers the pinned version, ordered by package and then
by id; a directory that does not exist or an advisory file that is refused is exit 2, because a database that cannot be
read must not look like a clean lock. Without the flag nothing changes. Code: compiler/src/package/advisory.trb.
7.7 Documentation per package#
packages.torb.dev/<owner>/<name>/<version>/docs is torb doc of that version, generated by the registry with the
toolchain the package's language line names, rendered by the same site generator as torb.dev. Generating it runs
none of the package's code: torb doc parses and checks; the # Examples of a doc comment are type checked, not run
(torb doc --check --no-run; the doc tests that run them are the author's torb doc --check),
and a constant is evaluated by the checker's operator folding and nothing else (CONCEPT: "No function calls and no
native calls"). That is a difference from docs.rs, which has to sandbox a build because building a crate runs its
build script and its procedural macros. The worker still runs under time and memory limits, because a pathological
input is a denial of service even when it is not code.
As built (2026-10-09): the worker is a task of the write service (tools/registry/src/documentation.trb) and
renders with the registry's own toolchain, the newest it has, not one per language line: a package whose line
asks for a newer language than that toolchain's waits, pending, and is looked at again daily and at every start. The
pages are at packages.torb.dev/docs/<owner>/<name>/<version>/. A publish queues the version in a documentation
table of the database; the worker unpacks the archive and every locked dependency from the registry's own storage,
each against the hash its lock names, refuses a path: dependency and a project.trb that names another package,
and runs torb doc --check --no-run, then
torb doc --output --escape-html, under timeout and prlimit (120 s, 1 GiB, 50 MiB of output and 512 processes by default, REGISTRY_DOC_*) with
an empty environment, inside bubblewrap (REGISTRY_DOC_ISOLATION, bwrap by default): new user, pid, ipc, uts, network
and cgroup namespaces, the user 65534, /usr, the system's bin and lib* and the toolchain's directory read-only, an
empty /tmp, and the scratch directory as the one writable path, so a render sees none of the service's files or
environment and has no network. The worker refuses to start where the --version probe fails inside bubblewrap, and
where its scratch directory lies inside the storage or the database; none exists for the tests' stand-in toolchain and
for development. Whether the container may create the namespaces is step 2 of #233 (the deployment). The image ships with the worker off (REGISTRY_DOC_TORB empty): an image pull alone does not start it, the operator opts in. A package that does not check is failed at once with what torb check says and gets no pages;
any other failure is tried again three times, after 1, 5 and 25 minutes, and is then failed: torb doc ends with 0 where it rendered, 1 where it found a problem in the package, and 74 (EX_IOERR) where it failed on the disk - a full disk, an output it cannot write, a file it cannot read -, and only 1 is the package's. The worker records its toolchain as the first line of torb --version, a +, and the first 12 hex digits of the SHA-256 of the executable, so that the start of a worker with another build of the same version queues the failed versions again. The state and the
problem are GET /api/1/packages/<owner>/<name>/<version>/docs, and the operator queues a version again with POST
on the same path. nginx serves the pages with a Content-Security-Policy that runs only the host's own scripts, since
their doc comments are a stranger's text, and --escape-html keeps that text from becoming markup in the first place:
the raw HTML of a comment is written as text and a link to a script as an empty one. The flag is part of the worker's
command line, not a setting, and it fails closed: a toolchain that does not know it refuses the command ("not a flag of
this command"), so the version is failed instead of served unescaped.
7.8 The capability summary#
CONCEPT's promise — "the compiler knows from the imports alone what a package can touch" — becomes a line on every package page, and it is the registry's selling point that no other registry in section 7.12 can offer, because none of their languages can make it.
acme/http 1.2.5
touches network src/client.trb, src/pool.trb
environment src/proxy.trb
through acme/json nothing
gained since 1.2.4: environment- The table of capabilities lives in
std: one list that says which package is which capability — files (std/fs), network (std/http, laterstd/net), processes (std/process), environment (std/environment), clock (Clockofstd/time), entropy (Entropyofstd/os/entropy), the operating system (std/os), loading scripts (std/sandbox), foreign functions (foreign). Astdpackage that is not on the list is pure, and a test asserts that everystdpackage with a native that reaches the machine is on it. A capability is a reading, not a type: the values a capability package answers in (Timestampofstd/time,OsErrorofstd/os) are pure, so the summary decides by each imported name, and a package with aTimestampin a signature touches no clock. - Direct and transitive are shown apart. What a package's own modules import is a fact of the archive; what it reaches through its dependencies depends on the resolution, so the page shows it for the graph the package was published with and says so.
- The lock records the capabilities of the whole graph (CONCEPT), and
torb updateasks before taking a version that gains one. - Built per version (2026-10-23, #51):
torb publishprints the summary of a package's own imports, the registry derives it again from the archive, writes it into the release row (releases.capabilities, comma-joined, empty for none) and the package page's table of versions shows it per version. A version whose set has a capability the nearest earlier version nobody yanked lacks readsgained: <names>; the first version, a version that lost one and a version beside a release that recorded nothing carry no flag. A release published before the column existed isnot recorded, nevernone: that would be a claim nobody checked, on a security display. The table of modules and pure names is the pagedocs/tooling/package-capabilities.md, and a registry test keeps it in step withcapabilityOf. Open: the table instditself, with the test that everystdpackage that reaches the machine is on it, and the transitive view ("through ...", which needs the resolved graph).
7.9 Search and ranking#
Search covers owner, name, description and keywords, which the vocabulary does not have yet: keywords is added
(not static, in the lock's settings, required by nothing). Ranking after text relevance: the number of packages that
depend on it (downloads are counted and shown, but not ranked on, because they are cheap to inflate), then the package
checks the registry computed — documented public declarations (torb docs source --statistics counts them already), it
checks and lowers on every target, it has a README and a license. The checks are shown as a list, not as a score out of
a hundred.
Built 2026-10-09 (tools/registry/src/search.trb): keywords "http", "client" is a setting of std/project, at
most five words of 2 to 24 characters of a-z, 0-9 and -, checked by torb publish and by the registry with the
same publishProblems. GET /api/1/search?q=<text>&limit=<1..50>&offset=<n> is public and answers
{query, total, limit, offset, packages}, each package with its latest release nobody yanked (the highest stable one,
else the highest pre-release), description, keywords, capabilities, dependents and publish time. A query is at most 8
terms of at most 64 characters, split at whitespace and lower-cased, and a package matches every term; a term scores
the best of the name exactly 16, a prefix of it 8, inside it 4, a keyword 4, the owner 2, the description 1 (a term
with a / is matched against owner/name), and the packages are ordered by the sum, then by how many packages' latest
releases depend on them at runtime, then by the newest publish. The rows are a derived table rewritten in the batch
that records a publish, a yank or an unyank, and filled at start where a package has none; the index stays the truth.
A scan with instr, not FTS5, since the sqlite3 shell's build options are the container's. Ranking on the package
checks waits until the registry computes them.
7.10 Moderation and policies#
Written before the launch and linked from every page:
- Names: first come; squatting (a name held without a package) is removed on request after an attempt to reach the holder; there are no transfers of an abandoned name except by its owner, because a transferred name is a supply-chain attack waiting for a victim.
- Content: malware, stolen code and illegal content are removed (7.3); everything else is the author's.
- Limits: an archive of at most 10 MB (crates.io's number), a rate limit on publishing per owner, both raised by request.
- Disputes and reports: an address on the site, an answer within a stated time, and a public log of removals with their reason.
7.11 Hosting and the server#
| Part | What runs it |
|---|---|
| index, archives, package pages, documentation | at launch a directory on the project's own server, served as static files; later S3-compatible object storage, through the same storage driver |
| the write service | one small program with a database; SQLite with continuous backup is enough at launch |
| the documentation worker | the same program, with the registry's own toolchain (built 2026-10-09, section 7.7) |
Decided (2026-09-25): the registry starts on the project's own server. A root server with Docker and Traefik runs three containers behind one TLS proxy:
Traefik (TLS, Let's Encrypt)
├─ packages.torb.dev/index, /archives, /packages, /docs -> a static file server over /srv/torb/registry
├─ packages.torb.dev/api -> the write service (TorbScript, SQLite)
└─ torb.dev -> the website, install.sh and install.ps1;
/download/<version>/<file> served from /srv/torb/download,
written by release-sync (below) - not a redirect to GitHub- The package pages are static files too (built 2026-10-10, #12):
packages/<owner>/<name>/index.htmlbelow the same storage, one per package, which the write service writes from its database - never a request of a reader. They are rewritten under the write lock after a publish, a yank and an unyank (and the pages of the packages whose count of dependents the change moved), and rewritten by the documentation worker once a version is rendered, which is when a page links its documentation. At every startfillPagescompares the stored page of each package with the one the database makes now and rewrites every page that differs (a missing one is written, an equal one is left alone), so a registry of a newer toolchain brings its shell to the pages already in the storage, with no marker in them. The shell around a page iscompiler/src/documentation/site-registry.trb: the site's header with the labelpackagesand the search field (a plain form that sendsqto/search/, see the next bullet), the brand's tokens, andassets/site.css,assets/registry.cssandassets/registry.js, which the registry host serves from the image's site root beside its own files. A page carries text strangers wrote (a description, a keyword, the reason of a yank), so every stored value is escaped, the page has no inline script or style, and the host answers it underscript-src 'self'. A page shows the name, the latest version nobody yanked, the description, the keywords and capabilities as chips, the command that adds it with a copy button, its runtime dependencies, its count of dependents, the publish date, a link to the documentation where it is rendered, and a table of every version with the capabilities of that version (7.8, #51) where a yanked one has the warning tint, the word and its reason; a package with every release yanked keeps its page, with a notice. The account pages the write service renders itself wear the same shell under a policy of the host's own files (section 7.1, #295). - The search page is a static file too (built 2026-10-10, #241):
search/index.htmlbelow the same storage, shown athttps://packages.torb.dev/search/. Unlike a package page, which is derived from the database and written where it differs from the stored one, the write service writes it at every start and overwrites what is there, so a registry of a newer toolchain brings its shell with it. It is the shell with a plain<form method="get" action="/search/">(the fieldq) and empty places for a status line, the results and the links to the previous and next page; it holds no result. The script,assets/registry-search.js(written bytorb docs sitebesideregistry.js, and loaded by this page alone), readsqandoffsetfrom the address, asksGET /api/1/searchof the same origin once (20 packages a page) and renders the answer withcreateElementandtextContentonly: the name, version, description, keywords and capabilities as chips and the count of dependents, each result linking to/packages/<owner>/<name>/where the owner and the name have the shape of a name, and shown as plain text where they do not. The states of an empty query, a search that finds nothing, a refused query (the API's message and problems, as text) and an API that does not answer (a sentence and a link to the API) each have their text, and a reader without the script reads a note that names the API. The host answers/search/under the policy of the package pages, so the page has no inline script, nostyle=and no handler. The capability summary is built per version (section 7.8, #51); the transitive view over the dependency graph is not. - A CDN in front caches an archive as immutable for a year and an index file briefly with an
ETag, so the server's bandwidth and a traffic spike are the CDN's problem, not the registry's. - Backups off the server: the database continuously (Litestream), the files nightly (restic), both to object storage at another provider; and every index file is mirrored into a public git repository, which doubles as a readable log of every release ever published.
- The write service stores through the storage layer of
docs/design/URI.mdsection 11 -file:at launch,s3:when the registry outgrows one disk - so moving the files is a change of configuration, and no URL a lock file or a script contains changes. The registry is the first real user of that driver layer. - Not a git repository for the archives, and not a container registry: git grows with every binary and crates.io left its git index for the sparse one because clones grew too large; an OCI registry needs a token dance per anonymous download and breaks "a mirror is a directory of the same files" (section 7.5).
The write service is written in TorbScript, as the first real server program of the language — which needs the
HTTP server of milestone 10 (docs/ROADMAP.md orders std/net, then std/http, early in 10), TLS terminated by the proxy in
front of it, and a database. That puts the registry launch after the first part of milestone 10; question 2 of section
12 is whether to accept a server in another language to open earlier. It is built (2026-09-27, "The root server and the
write service, as built" below): std/http's server had landed, and TorbScript it is.
Public downloads while the repository is private (decided here)#
The repository is private today (fact 3), but section 5's installers and torb upgrade have to work now, from a
public URL, without a token. A plain redirect to a GitHub release (the diagram above, before this decision) answers
a login page to anyone who is not a collaborator - the site would work for the maintainer and nobody else. Three ways
to close that gap:
| Option | For | Against |
|---|---|---|
| The root server pulls; CI never pushes — decided | one direction of trust (the server's read-only token, never a secret in CI that can push to the server); works unchanged the day the repository turns public, since the pull keeps working and can simply be pointed at the now-public release URL instead; no inbound access from CI to the server to secure | a small service to run and keep up, and a delay between "release published" and "on the server" (closed by the webhook, section 7.11 below) |
CI pushes over SSH or rsync at release time | no server-side service | a secret that lets a GitHub Actions run write to production, which is what a compromised action or a compromised dependency of one would reach for first; the exhausted Actions budget (this task's own instructions) makes it worse, not better, to add a job that must always run |
| Make the repository public now | the redirect keeps working, nothing to build | not this record's call, and orthogonal to shipping a preview - question 8 of section 12 |
tools/release-sync is that pull: a TorbScript program (std/http for both the webhook server and the forge's API
client, std/json for the payload and the API's response), built and run as its own container
(tools/deploy/Dockerfile.release-sync). Since 2026-09-27 it pulls from the forge, Forgejo at git.torb.dev (section 14),
and from GitHub only where RELEASE_SYNC_FORGE=github says so; the two differ in four places, which are the members of
Forge in tools/release-sync/src/forge.trb. It:
- Listens on
/webhookfor the forge's "release published" event, its signature checked -X-Forgejo-Signature, the bare hex of the HMAC-SHA256 over the raw body, or GitHub'sX-Hub-Signature-256,sha256=<hex>, which Forgejo sends as well - computed by shelling out toopenssl dgst -sha256 -hmacand compared without stopping at the first digit that differs, against a secret only this service and the webhook's configuration know. It answers202at once and syncs in a task of its own: the forge gives a delivery seconds, a download takes longer. - Never trusts the webhook's own asset list: it re-fetches the release from the API (
GET /api/v1/repos/<owner>/<name>/releases/tags/<tag>on Forgejo,/repos/...on GitHub), with a read-only token only while the repository is private. The workflows publish a release as a draft first and make it public once its last asset is uploaded, which is when the forge sends "published" - measured on Forgejo 16.0.5: creating the draft and uploading send nothing. The forge does not deliver a failed webhook again, so a periodic check - at the start and everyRELEASE_SYNC_POLL_SECONDS, default 300 - lists the newest releases (.../releases?draft=false&limit=50,?per_page=50on GitHub) and syncs every one that is not placed yet of the newest stable release, the newest preview (since 2026-09-29) and the nightlies of the thirty days ending with the newest one listed, the thirty days a nightly is kept (section 5, "Channels"). Until 2026-09-28 it asked.../releases/latest, which is stable releases only and a404while there is none: a nightly whose delivery was lost - the first production run lost one while the container was still starting and Traefik had no route to it - was never placed, and the check logged an error every five minutes. - Downloads every asset into a directory of its own below the scratch directory (
curl: Forgejo'sbrowser_download_url, GitHub's asset API URL withAccept: application/octet-stream), readable by its owner alone, verifies every archive againstSHA256SUMS(std/digestover the file's chunks) and verifiesSHA256SUMS.sigwithcosign verify-blob --keyagainst the project's public key, mounted at/etc/release-sync/cosign.pub(section 13, "Signing"; a release of GitHub:SHA256SUMS.sigstore.jsonagainst the identity of the workflow that made it) - refusing whencosignor the key is not on the machine at all, the same as a signature that does not verify: a release this program cannot check is not placed as if it had been. The scratch directory is<root>/download/.incoming(RELEASE_SYNC_SCRATCH): it has to be on the file system of<root>/download/for step 4's rename, and the site never serves a hidden entry there. It was the container's own/tmpuntil the first production run found the rename into the mounted download directory failing withInvalid cross-device link; a scratch directory on another file system now fails with a message that names the variable. - Only then renames the whole verified directory to
<root>/download/<version>in one step (File.rename), so a directory under a version's name is always complete - the same first run left an empty one behind, which the check, asking only whether the directory existed, took for placed. It then rewrites<root>/download/versions.txtwhole or not at all (File.writeTextAtomically; that version's old line, if any, replaced; each channel newest first - stable, then preview, then nightly - since the installers take the first line of their channel) and, where the release is the newest stable one, repoints the<root>/download/latestsymlink - a new link beside it renamed over the old one, solatestis never missing, whichln -sfncould not promise. A placement that fails after the rename is undone, the old directory andversions.txtput back, so the check tries it again; a version counts as placed once<root>/download/<version>/SHA256SUMSis there. A preview and a nightly have no "latest"; the nightly has/docs/nightly/on the site (section 6), which this program does not touch.tools/release-sync/README.mdhas the variables, the webhook's answers and how to run it locally. Whycurland notstd/http's body all the way to disk: the download is the one step left outside the language, andFile.write(path, response.body)is its replacement oncestd/http's client follows the redirects GitHub answers an asset with. Hashing, moving and linking moved intostd/digestandstd/fson 2026-09-27, whenstd/fsgainedrename, symbolic links and the atomic replacement; before that they shelled out tosha256sum,mvandln.curlandcosignare exactly the toolstools/fetch-seed.shalready assumes exist on every target, sorelease-sync's one target (its own container) is not a new assumption.
Tested: tools/release-sync/tests covers the pure decisions - which webhook actions to act on, which signature a
request claims and whether it matches, reading a hash out of SHA256SUMS, the release JSON of both forges, the
addresses and headers each is asked with, which releases the periodic check places, the order of versions.txt - as
ordinary TorbScript tests; tests/check.test.trb the periodic check against a fake forge over loopback, and
tests/place.test.trb the placement, the placements that fail and are undone, and the hashing in a temporary directory. The whole program ran once against a Forgejo
16.0.5 in a local container (2026-09-27): a release published the way release.yml publishes one, with SHA256SUMS
signed by a throwaway cosign key, was placed through the webhook, with versions.txt and latest; a release whose
archive did not match SHA256SUMS and one whose signature did not match were refused and placed nothing.
The root server and the write service, as built (2026-09-27)#
Decided by the owner (2026-09-27): the root server runs behind his existing Traefik, from ready-made images he pulls
from a registry - GHCR when this was built, the forge's own cr.torb.dev since the same day's move (section 14), in
the compose stack that runs the forge or beside it. Nothing is built on the server and nothing in the deployment opens a port or asks for a certificate: the
containers join Traefik's network and claim their host and path with labels.
existing Traefik (TLS, Let's Encrypt; its network, entrypoint and resolver named in .env)
├─ torb.dev, www.torb.dev -> cr.torb.dev/torbscript/site install.sh, install.ps1, /docs/<version>/reference,
│ /download/... from /srv/torb/download (read-only)
├─ torb.dev/webhook -> cr.torb.dev/torbscript/release-sync writes /srv/torb/download
├─ packages.torb.dev -> cr.torb.dev/torbscript/site /index, /archives, /docs from /srv/torb/registry (read-only)
└─ packages.torb.dev/api -> cr.torb.dev/torbscript/registry writes /srv/torb/registry, /srv/torb/database, the mirror
litestream (sidecar) /srv/torb/database/registry.db -> object storage, continuously
backup (restic, host cron) /srv/torb/{registry,download,index-mirror} -> object storage, nightlyThe images are built by .forgejo/workflows/images.yml, which release.yml calls once a release is published
(tags <version> and latest) and nightly.yml once a nightly is (tags nightly-YYYYMMDD and nightly), and which
runs by hand for a published release - and, since 2026-09-29, on every push to main that changes the site, for the
site alone (tags edge and main-<commit>-<time>, "The site of main, as built" below): three images, pushed to
cr.torb.dev/torbscript/ with the token of the Authorized Integration torbscript-images as the password of
docker login (section 14), with the OCI labels
source, revision, version, licenses (MIT) and url, and signed with cosign sign --key, the project's key
(section 13, "Signing"). They are linux/amd64; linux/arm64 joins once a release carries a linux-arm64 toolchain -
the build stage runs the toolchain of the image's own architecture - and QEMU registers in the runner's Docker, which
the workflow tries and reports. The images are public, so the server pulls them without a login:
cosign verify --key https://git.torb.dev/torbscript/language/raw/branch/main/tools/deploy/cosign.pub \
--insecure-ignore-tlog=true cr.torb.dev/torbscript/registry:0.2.0| Decision | Why |
|---|---|
The images are built from the release's own assets, the dist artifact: the build context is the Linux toolchain archives and torb-<version>-source.tar.gz, never a checkout | what runs on the server is exactly what was released and signed; the earlier Dockerfile.release-sync bootstrapped the compiler inside the image from the whole checkout, which took the seed, the fixpoint and every source into the build |
The build stage runs the released torb of the image's own architecture (QEMU for arm64 on the hosted runner) and compiles with Debian's gcc | the C the compiler emits is the same everywhere, but its runtime is compiled into the program, so the compiler and the architecture meet in the build stage; a cross gcc would save emulation time and is a later optimization |
site generates the standard library's reference with torb doc std into /docs/<version>/reference/, once, on the building machine | the reference is architecture-independent; the rest of the site waits for torb docs site (section 6) |
nginx.conf splits by host name: torb.dev is the site and the downloads, packages.torb.dev the registry's files | the old configuration served /docs/ from the registry's directory for both hosts |
cosign in the release-sync image is checked against the SHA-256 its release lists | closes the "TODO(owner)" the first Dockerfile carried |
| Images are signed, not attested | the same reason as section 13's SHA256SUMS: attestations need GitHub Enterprise Cloud while the repository is private, and can be added later without changing a pull |
| The build runs once without pushing, and is then pushed from the builder's cache with a token asked for right before | the token of an integration lives an hour (ID_TOKEN_EXPIRATION_TIME), and an arm64 build under QEMU can take longer |
tools/deploy/compose.example.yml (copied to compose.yml, never docker-compose.yml) runs site, release-sync,
registry, the litestream sidecar and a backup service in the profile backup. No Traefik service: the
external network is TRAEFIK_NETWORK (default traefik), and the routers' entrypoint and certificate resolver are
TRAEFIK_ENTRYPOINT and TRAEFIK_CERTRESOLVER from .env (.env.example lists every variable). The data lives in
TORB_DATA (default /srv/torb) as bind mounts: download/, registry/, database/, index-mirror/, each with
exactly one writer. Litestream (litestream.yml) replicates the SQLite database to S3-compatible storage at another
provider within seconds, with a daily snapshot kept 30 days; restic (backup.sh, run by the host's cron through
docker compose --profile backup run --rm backup) backs up the files nightly to the same storage - the database is
left out, a copy of a SQLite file being written is not a database - keeps 14 daily, 8 weekly and 12 monthly snapshots,
and reads a thirtieth of the repository back every night.
The write service, tools/registry (its README lists the API and the variables):
| Question | Decision | Why |
|---|---|---|
| The API | /api/1/: accounts, owners and members, tokens, PUT .../packages/<owner>/<name>/<version> with the archive as the body and its tree hash in Torb-Tree-Hash, yank and unyank, trusted publishers, and the exchange of an OIDC token; JSON in and out | the paths of section 7.4; the archive is the whole upload, because the registry re-reads everything from it (section 7.2) |
| Checking a publish | the compiler as a path: dependency: unpacked, readLock, publishProblems (moved to compiler/src/package/publish.trb for this), capabilitiesOf and the index writer | "validates exactly as torb publish --dry-run does" is the same function, not a copy |
| The database | SQLite through the sqlite3 shell of the image, in WAL mode, every value an SQL literal whose one escape is the doubled ' | std has no driver. A native binding to a vendored SQLite is a new native (two commits and a seed refresh) and a nine-megabyte C file in every build; an append-only file store would lose what Litestream gives a real SQLite file. A statement batch is one short process, which a service that sees a publish a minute does not notice |
| Running programs | through a Child's pipes, not Process.run | Process.run offloads to the blocking pool only when its closure captures nothing counted; with a command line built at run time it runs on the worker, and the registry's handler keeps every connection on one worker - found by the test that runs torb publish against the registry in the same process, which deadlocked |
| Storage | std/storage: Storage over a Uri, FileStorage for file: (a write replaces the file whole with File.writeBytesAtomically), Storage.registry choosing by scheme | the layer URI.md section 11 designed; it began in this program and moved to std on 2026-09-27, when std shipped a second driver (memory:), by that section's own rule |
| Concurrency | one writer of the storage at a time: a lock on the one worker, released by a using, which a cancellation closes too; one replica | an index file is read, appended to and written whole |
| Recovery | archive, then index, then database row; the index is the truth of what is published | a crash leaves at worst an archive nothing names, written over by the next attempt, or an index entry without its row, which the next attempt repairs |
| Accounts | signed up through GitHub, GitLab or Codeberg - OAuth 2.0's authorization code flow with PKCE S256 (RFC 6749, RFC 7636, RFC 9700), the provider's numeric user id as the identity, a single-use state bound to the browser by a flow cookie, a fixed redirect URI per provider, the owner name chosen at sign-up and the account, its person owner and the identity made in one transaction - or created by the operator's token (REGISTRY_ADMINISTRATOR_TOKEN); a session links further providers; an account is a person owner of the same name; organisations with owner and publisher members; an account links sign-in identities (a provider and the provider's id of the user, never an e-mail address) and has server-side browser sessions (__Host-session, 256 random bits, kept as their SHA-256, 12 hours at most and 30 minutes idle); the account page GET /account - served by the registry in the brand's shell, HTML with a strict Content-Security-Policy (the host's own scripts, styles, fonts and images, nothing inline) and a form secret bound to the session - shows the owner name, identities, passkeys, second factors and tokens, signs out, unlinks an identity but never the last way to sign in, and revokes a token | a database flow per sign-in, not a signed cookie, so consuming it is one DELETE; no OpenID Connect, since GitHub has none for apps and one access token used for one user call needs no ID token; a provider without its client id and secret in the environment is not offered |
| Tokens | torb_ and 256 bits of the system's randomness, stored as SHA-256; scoped to owners, packages (acme/*), actions (publish, yank, owners, tokens) and up to 365 days; a token never gives an action its maker lacks; a token with publish is made only by an account with a second factor (an authenticator app or an unmarked passkey) and records that it was (factor), and publishes only while the account still has one, so a token made before this, the first token of a new account, and a token the operator makes for an account with none, are not asked (an operator token for an account that has a factor is bound like any other); the operator itself is not asked for a factor; removing the last factor of an account that can publish is a 409, not a revocation (revoking would leave the account locked out of the app it has to re-enrol, and the use-time check already stops the tokens); trusted publishing exchanges an OIDC identity and is unaffected by the publish rule, but setting it up is not (below); minting a token with publish or owners and every owners call (a new organisation, a member added, changed or removed, trusted publishers) take a code of the account's authenticator app or a recovery code in Torb-Code once it has one, checked after the other validation so a refused request burns none; the first token and the operator's tokens for an account without a factor are unbound: 30 days at most, no minting of publish or owners while the account has no factor, the event marked unbound; the operator's only for an account that cannot sign in (no identity, no unmarked passkey), a 403 otherwise; such an account is claimed by its person through a claim link (POST /api/1/accounts/<name>/claim, 256 bits kept as SHA-256, an hour, single use, one per account), whose provider sign-in links the identity in one transaction and starts a session, never of another account and never a sign-up; a passkey gives the code through the API only by a step-up: POST /api/1/step-up, an approval with the passkey at the account page, and Torb-Code: step-up:<secret> once on one call of that class | section 7.6, "a stolen token"; a high-entropy token needs no slow hash |
| Trusted publishing | GitHub Actions, and since 2026-09-27 the Forgejo Actions of the forges REGISTRY_FORGEJO_ISSUERS names (git.torb.dev; section 14): the token's iss picks the issuer, whose key set is fetched for every exchange (GitHub's at /.well-known/jwks, a Forgejo's through its discovery document); RS256 verified in TorbScript (RsaPublicKey of std/signature, Montgomery over 16-bit limbs); issuer, audience packages.torb.dev, lifetime with a minute's leeway; then the package's publishers - owner/name, the workflow file (job_workflow_ref) and the environment on GitHub, <host>/owner/name and the workflow file (workflow) on a Forgejo, whose tokens name no environment, and a ref (refs/tags/v*) on either; the answer is a token of one package for 15 minutes, exchanged at /api/1/trusted-publishing. Owners may configure it before the first release, and required switches tokens off | crates.io's, PyPI's and JSR's model; no new native, and both back ends compute the same bits |
| Documentation | a task of the service, its queue the documentation table, a publish queueing in the batch that records the release; the task opens a storage and a database of its own | one image and one database; a spawn copies what it captures and no shared object crosses (section 7.7) |
| Confusing names | a skeleton - hyphens removed, 1, 0, rn folded to l, o, m - kept UNIQUE for owners and for packages | section 7.1's rule, enforced by the database |
| Yank | yank appends the line of section 7.4; unyank appends unyank "<version>", which the client's reader now understands | append-only either way |
| Deprecation | POST .../packages/<owner>/<name>/deprecate {use, reason} and DELETE of the same path, for a member with the yank action: a signed deprecate or undeprecate line, the flag in the columns deprecated, deprecated_use and deprecated_reason of packages (the index stays the truth); use is a valid package name other than the package, and need not exist; a second deprecation replaces the notice (no 409), an undoing of none is a 409, an unknown package a 404 | the notice is a fact about the package, and a registry that wants to show it needs it without reading the file |
| The mirror | after every change of an index file, the file is committed into a git working copy (REGISTRY_MIRROR), pushed where REGISTRY_MIRROR_PUSH=1; a failing mirror never fails the publish | section 7.4's archival export, per change instead of daily because it costs one commit |
The client: torb publish to an https:// registry uploads with curl (else wget) and prints the registry's
answer. The token is TORB_TOKEN; without it, in a GitHub Actions job with id-token: write, the job's OIDC token is
requested with the registry's host as audience and exchanged for the short-lived one (compiler/src/package/upload.trb,
docs/tooling/torb-publish.md) - and the same in a Forgejo Actions job with enable-openid-connect: true, whose token
endpoint answers the same request.
Tested: tools/registry/tests - RSA and JWT verification against a key and tokens made with Node's crypto, the
name and scope rules, every API call over loopback with a database and a file: storage in a temporary directory
(once as it works, once as it is refused), and torb publish driven against it; since 2026-09-27 also a Forgejo Actions
token of a second key, its issuer's discovery document and key set served over loopback, and the refusals a Forgejo
publisher has that a GitHub one has not; 75 tests, natively and in the VM, with sqlite3 on the PATH. The three images were built locally for linux/amd64 from a dist directory made the way
the release makes it (a Linux torb compiled from the fixpoint's program.c, the toolchain laid out, the source
packed): the registry's container answered, created an account and took a torb publish from the Windows host, writing
the index, the archive and a WAL-mode database; the site served install.sh and the reference read-only, split by host
name. linux/arm64 has not been built outside CI. Since 2026-09-28 the release binary's smoke test writes the
registry's C before a release or a nightly is published (section 14, "What the first nightly reported").
Not built yet: signing up through a passkey (signing in through one, and through GitHub, GitLab and Codeberg, is built), asking for the second factor at sign-in (a TOTP app, recovery codes and the factor before publishing tokens are built), ranking on package checks (search is built, 7.9), the similarity rule against packages with many dependents, and an
s3: driver. torb yank, owner and login speak to the API since 2026-10-09 (section 7.13), and torb reads an
owner's file and prints an "elsewhere" owner since 2026-10-13 (section 7.13).
What the owner sets up once is docs/contributing/releasing.md, "The root server": DNS, the .env and
cosign.pub, the forge's webhook, the first account, trusted publishers, the index mirror and the backup storage.
The site of main, as built (2026-09-29)#
Decided by the owner (2026-09-29): a change to the website reaches torb.dev fast, from main, without a nightly.
Until then a change to docs/, the playground or its editor was on the server only after a nightly (every gate of
every target, 15 to 30 minutes on the forge's one runner and up to an hour), a run of images.yml by hand, and a
pull request to the infrastructure that pinned the new digests. The root server moves to k3s with Flux, which follows a
tag of a registry by itself; what the repository owes it is an image of the site that follows main. Built on the
same day: every push to main that changes what the site is built from builds cr.torb.dev/torbscript/site from
that commit and pushes it as
| Tag | Moves | |
|---|---|---|
edge | with every such push | the newest site of main |
main-<commit>-<time> | never | the first eight hexadecimal digits of the commit and its committer time in seconds, main-3b6b8355-1790707145 |
never as latest, nightly or preview, signed with the project's key over the digest both tags name, and with the
labels of the other images (source, revision, version - the main-... tag -, licenses, url).
| Question | Decision | Why |
|---|---|---|
| Where it lives | a push trigger on main in .forgejo/workflows/images.yml and its job edge, no new workflow | the Authorized Integration torbscript-images admits the workflow files {release,nightly,images}.yml, the refs main and v* and the events push, schedule and workflow_dispatch (section 14): a run of images.yml on a push to main asks for its token with the claims images.yml, refs/heads/main and push, all of which the rules name, while a new file would need the owner to change the integration first |
| Which pushes | those that touch docs/, brand/, playground/ (with its editor), std/ (the reference), compiler/src/documentation/, compiler/src/reference/ and compiler/src/highlight/ (the generators and the colouring of code), tools/install.sh, tools/install.ps1, tools/deploy/Dockerfile.site, nginx.conf, cosign.pub and images.yml; the forge compares the whole push, not only its last commit | what torb docs site, torb doc std and playground/build.sh read and Dockerfile.site copies in. editors/ is not among them: the site shows no file of it. A push that changes only the rest of the compiler builds nothing - most pushes do, and each would hold the runner for minutes; the playground of edge stays as new as the last site push until the next one or a run by hand (Actions -> images -> Run workflow, channel edge, on main), and the nightly's images carry every change as before |
| One job | from the checkout to the signed image in one job, edge | the forge hands the runner the job that became ready first; a second job of this run would become ready minutes after the push, behind the jobs of ci.yml that became ready meanwhile - tier A and tier B take ten minutes and more each |
| The compiler | the cache entry of the bootstrap for these sources, which ci.yml writes on every push to main; else the published seed of this very commit (seed-of-commit of .forgejo/actions/bootstrap); else a bootstrap from the newest seed, which writes that entry for ci.yml in turn | a push that leaves compiler/src, std/ and runtime/ alone finds the entry in seconds. tools/land.sh publish publishes the seed of the commit it pushes before it pushes it, and that seed's program.c is the C of the landing's fixpoint: compiled, it is the compiler of the commit - three minutes, where the two steps of a bootstrap would add two and a half more to build the same compiler twice. The seed's binary is never saved as the bootstrap's entry, which holds only what a bootstrap proved |
ci.yml's artifact of the same commit | not read | Forgejo's download-artifact reads the artifacts of its own run; waiting for another run's job would hold one of the runner's two slots idle, and the shared cache gives the same reuse without either |
| The build | Dockerfile.site's stages, fed a context of a release's shape that the job makes: torb-main-source.tar.gz (git archive), torb-main-linux-x64.tar.gz (tools/package.sh around that torb) and VERSION=main | the site of main is built by exactly the steps a release's is; its documentation is /docs/main/, and /docs/latest/ leads there as in every image |
| The playground | torb.js and torb.wasm in the forge's cache, keyed on compiler/src, std/, runtime/, the two project.trb, playground/build.sh and Dockerfile.site: on a hit the context carries them in playground-compiled/ and playground/build.sh --compiled writes only the page's files beside them; on a miss the build stage compiles them, and the target playground of Dockerfile.site takes them out of the builder's cache for the next run | the compile is the slow part of the image (80 seconds on the runner, two seconds without it), and the same sources give the same wasm. The playground job of gates.yml does not fill the cache: it runs in the emscripten image, which has no zstd, and a cache entry written without zstd is a different entry for a reader that has it |
| emscripten | its image stays in the builder torb, which outlives the job on the runner's Docker | as for the other image jobs; nothing of it is downloaded twice |
| Gates | none but what the build checks - torb docs site fails on a broken internal link | main moves only through landings that ran both tiers (tools/land.sh), and ci.yml runs them again for the same push beside this job; a red ci on main is fixed on main, and the next push builds edge again |
| Concurrency | a newer push, and a run by hand of edge, cancel the run of edge they supersede (the group images-edge); every other run of images.yml has a group of its own | the forge reads cancel-in-progress as a plain boolean and never as an expression, so the group is what keeps a release's and a nightly's images out of it |
| The immutable tag | main-<commit>-<time> rather than main-<commit> alone | Flux's ImagePolicy picks the newest of several tags by a number it extracts from them (numerical), not by when they were pushed, and a commit's digits do not sort. The committer time grows along main, which only fast-forwards; a run of an older commit again keeps its older tag, where the time of the build would put the old site above the new one |
| Platforms | linux/amd64 | the root server's; an arm64 build stage runs under QEMU and would take the runner for an hour |
Expected minutes, from the push to the pushed image, measured from the forge's runs of 2026-09-29: the bootstrap
takes 4 to 6 minutes alone (compiling the seed 3, the seed building the compiler about 2 with mbedTLS, the compiler
building itself under 1) and 9 to 13 beside a second one; compiling the playground 80 seconds in the image job and 90
to 155 in the gates; torb docs site and torb doc std 24 seconds; checkout, caches, the context, the push and the
signature about half a minute more.
| The push changes | The job | Push to image |
|---|---|---|
only the site's files (docs/, brand/, playground/, the installers) | both caches hit | 1.5 to 2 |
std/ or a generator, landed by tools/land.sh publish | the seed of the commit, the playground compiled | 5 to 6, up to 8 beside ci.yml's bootstrap |
std/ or a generator, pushed without a seed of its own | a bootstrap, the playground compiled | 8, up to 15 beside ci.yml's bootstrap |
The job waits for one of the runner's two slots first: seconds, since ci.yml's first two jobs are short - longer
only while a nightly or a release holds both. Flux's own interval comes on top.
Flux follows the image by itself: an ImageRepository of cr.torb.dev/torbscript/site (public, no secret) and an
ImagePolicy whose filterTags pattern ^main-[0-9a-f]{8}-(?P<time>[0-9]+)$ extracts $time, with the policy
numerical, ascending; an ImageUpdateAutomation with the setters strategy writes the newer tag into the manifest,
and rolling back is pinning an older tag. docs/contributing/releasing.md, "The site of main", has the resources.
Following edge by its digest (filterTags ^edge$, digestReflectionPolicy: Always with an interval) works too,
and says less: the tag never names the commit that runs. Setting up Flux is the owner's.
Tested (2026-09-29): the workflows pass forgejo-runner validate (runner 13.2.0). On a local Forgejo 16.0.5, a push
of the change started images.yml with the three image jobs skipped by the server and edge waiting; a push that
touched only compiler/src/cli started none; the next push of docs/ cancelled the waiting run, and a run by hand with
the channel edge cancelled that one, while a run by hand of the channel nightly ran beside it with edge skipped;
images.yml called from a dispatched workflow on main and from the push of a tag expanded into the three image jobs
and a skipped edge. The image was built locally from a context made the way the job makes it, around the published
seed of 3b6b8355 compiled on Linux: 141 seconds with the playground compiled and 39 with it taken from
playground-compiled/, the target playground gave torb.js and torb.wasm in three seconds from the builder's
cache, both images held the same 894 files, and the image served /, /play, /docs/latest/ (linked to
/docs/main/), the reference and torb.wasm. Only the forge shows the rest: the cache entries that ci.yml and this
job share, the token for a push, the builder on the runner, and the minutes above.
7.12 Registries compared#
| Namespace | Identity and verification | Publishing | What is uploaded | Removal | Index | Signing and provenance | Documentation | Code on install | |
|---|---|---|---|---|---|---|---|---|---|
| crates.io | flat names | GitHub accounts only | scoped tokens; trusted publishing (GitHub, GitLab) since 2025 | source .crate; cargo publish builds it first | yank; deletion only under narrow conditions | sparse HTTP, one file per crate, JSON lines (default since 2023) | checksums in the index; no signatures | docs.rs builds every crate | build scripts and procedural macros |
| npm | flat, optional @scope/ | e-mail accounts, 2FA | granular tokens, trusted publishing (2025) | any tarball, built code included | unpublish within 72 hours or under conditions; deprecate | JSON documents per package | Sigstore provenance (2023) | the README | install scripts |
| JSR | @scope/name, mandatory; similar scopes refused | GitHub | OIDC from GitHub Actions, without tokens | TypeScript source | immutable, yank | static JSON per package | provenance only with OIDC publishing | generated from the source, with a score | none |
| pub.dev | flat names; verified publishers | Google accounts; a publisher verifies a domain through Google's Search Console | tokens, automated publishing from GitHub or Google Cloud | source archive | retract within 7 days; "a published package lasts forever" | JSON API | — | dartdoc, generated; "pub points" | recently added build hooks for native assets |
| Go | the import path is a host and a path | none: the host is the identity | none: a tag in a repository | the module zip, fetched by the proxy | the proxy keeps it forever; retract in go.mod | the GOPROXY protocol (@v/list, .info, .mod, .zip) | sum.golang.org, a transparency log | pkg.go.dev, generated | none (no build scripts) |
| Hex | flat, private packages in organisations (paid) | accounts | API keys | source tarball; documentation built by the publisher | revert within 60 minutes (24 hours for a new package); retire | a registry signed by Hex's key, whose public half ships with the client | the signed registry | hexdocs.pm, uploaded by the publisher | compile-time code runs when a dependency is compiled |
| PyPI | flat, normalised names; organisations | e-mail accounts, 2FA mandatory since 2024 | tokens, trusted publishing (2023) | source and built wheels | yank; deletion, but a file name can never be reused | the Simple API (HTML or JSON) | attestations (PEP 740) from trusted publishers | none (Read the Docs is separate) | building from source runs the build backend |
| packages.torb.dev | owner/name, mandatory; verified domains; "elsewhere" | several sign-in providers, passkeys, 2FA to publish | scoped tokens, trusted publishing | TorbScript source and named resources | yank; removal only by moderators, with a tombstone | sparse, static, append-only TorbScript files | every record signed; optional Sigstore provenance | torb doc, generated, running no code | none, by construction |
What is taken: JSR's mandatory scopes and its similar-name rule; Hex's signed registry, which makes mirrors cheap; crates.io's sparse index and its lesson that an index must not be a clone; Go's directory hash and its "a version lasts forever"; pub.dev's verified domains, without a dependency on one company's console; everybody's trusted publishing. What is left: install scripts (npm), built artifacts in a source registry (npm, PyPI), a single identity provider (crates.io, JSR), revert windows (Hex, npm), and a registry that is the version control host (Go).
7.13 The client, as built (2026-09-26)#
Slice 11 without its signatures, and slices 4 to 6 as far as the client needs them. The commands are torb add,
remove, update, install and publish (docs/tooling/torb-add.md and the pages beside it); the resolver is
PROJECT.md section 6a's PubGrub; everything is TorbScript, with no new native. What was open above is decided here:
| Question | Decision | Why |
|---|---|---|
| URLs of a registry | <registry>/index/<owner>/<name>.trb and <registry>/archives/<owner>/<name>/<version>.tar.gz; a 404 of an index is a package the registry does not have | the layout of the diagram at the top of this record, with nothing to negotiate |
| A registry for tests and offline use | file: and a directory of the same files, relative to the root project (registry "acme", url: "file:../registry"); torb publish writes one - the archive and one appended release - and never replaces a version | "a mirror is a directory of the same files" (7.5) made literal, and the write service's file: storage driver will do the same two writes |
| How the client fetches | curl, else wget, run to their end; TORB_FETCH=curl or wget chooses one. Not std/http, although its client speaks HTTPS with the platform's trust | torb cannot wait for a task: an await() at the top level of main.trb makes that top level a task, and the VM that torb run and torb test start refuses to run its scheduler from inside one (the scheduler was run from inside a task) - which the first version of this client did, and every VM test failed. Every request of std/http is a task. Besides, on 2026-09-26 std/network's name resolution crashed a program on Windows for a host that needs a DNS lookup (TcpStream.connectTo("example.com", 80); an address literal and localhost worked): the resolver threads called Winsock's GetAddrInfoW before anything had loaded Winsock, which only the first socket did. Fixed on 2026-09-27 - a resolution starts the IO core as a socket does - and checked on 2026-09-28 natively, in the VM and in a release binary, for ten names resolved at once and for std/http over HTTPS (NETWORK.md section 2); it is no reason against std/http any more. curl ships with Windows 10 and later, macOS and practically every Linux, and torb upgrade already relies on it |
| The cache | $TORB_CACHE, else ~/.torb/cache (%LOCALAPPDATA%\torb\cache on Windows): packages/<hex of the tree hash>/ with a .complete written last, and index/<registry>/ with the last copy of each index file fetched over the network | content-addressed as 7.5 says; the marker makes a half-written directory one that is unpacked again, since std/fs has no rename |
--offline | an index from the cache's copy, an archive only where it is unpacked; a file: registry is read either way | 7.5 |
| Reading and writing an archive | File.readBytes reads it and File.writeBytesAtomically replaces it whole, both synchronous (2026-09-27). Every other file of a package is text and goes through File.readText and File.writeText, so a package file that is not UTF-8 is refused | the same reason: torb cannot wait for a task at its top level. Until the two natives existed, an archive went through od -t x1 and printf on POSIX and PowerShell's Base64 on Windows - a branch on the system in code the compiler reaches, so its C differed per target - and they came in the two commits and the seed refresh of CLAUDE.md |
| The archive | the deterministic tar of std/archive inside the gzip of std/compression (one block of fixed Huffman codes); at most 10 MiB compressed, 64 MiB unpacked and 10 000 files; a link, an absolute path, a .. or a \ refused before anything is written | 7.2 and 7.10; the unpacked limit is what stops an archive of zeros |
| The hash | the tree hash of 7.2 with std/digest's SHA-256, checked on every install | 7.2 |
| The capability table | in the toolchain for now (capabilityOf and capabilityOfImport, compiler/src/package/archive.trb): files for std/fs and std/archive/extract, network for std/network, std/http and std/tls, processes, environment for std/os/environment, entropy for std/os/entropy, the operating system for the rest of std/os, clock for std/time, scripts for std/sandbox, foreign functions for a foreign block. Every imported name decides (2026-09-29): the values of a capability package reach nothing - Duration, Instant and Timestamp of std/time, OsError, SystemVersion, EnvironmentVariables and the target constants of std/os - and a name std/os re-exports (Environment, Entropy) reaches its own module's capability; use * reaches the package's | 7.8 wants it in std; until then one function is the one place, and docs/tooling/package-capabilities.md tabulates it (a registry test compares the two) |
| An update that gains a capability | refused, printing the gain, unless --accept-capabilities | CONCEPT: "needs an explicit confirmation"; a flag is the confirmation a script can give |
language of a release | compared with the toolchain's language version (languageVersion, 0.1.0); a newer one is not chosen, and the explanation says so | 7.4 |
| A token on the user's machine (2026-10-09) | torb login reads it from standard input - never from an argument - checks it with GET /api/1/tokens and stores it in credentials.trb in the torb home ($TORB_CREDENTIALS names another): token "<registry url>", value: "torb_...", one line a registry, read statically like config.trb and strict at its line, created with the mode 0600 before the token is written. torb logout removes the line. A request sends TORB_TOKEN, else the stored token of its registry - only over https: or to a loopback http: address, never to a mirror - else, for publish, trusted publishing (docs/tooling/torb-login.md) | 7.6: a token is a secret of the user, not of a project; apart from config.trb so that it can be shared, and a receiver script of literals so that no new reader and no new native are needed |
| The members of an owner, and a yank (2026-10-09) | torb owner list|add|remove <owner> [account] reads GET /api/1/owners/<owner> without a token and changes a member with PUT or DELETE of .../members/<account>, --role publisher by default, with --code or TORB_CODE as Torb-Code, or with --step-up the grant of a step-up: its user code and page printed, a line of standard input once a passkey approved it, the secret sent only in the header file and never printed. torb yank <package> <version> --reason <text> sends POST .../yank, and --undo (never with a reason) DELETE; at a file: registry it appends the yank or unyank line itself, refused where the index is signed, and login and owner refuse a file: registry. The registry is --registry, else the owner's registry binding, else the public one, never a mirror. A usage error exits with 2, a refusal with 1 (docs/tooling/torb-owner.md, docs/tooling/torb-yank.md) | the API of 7.11 had no client but publish; every request reads its headers from a file, so a token is never a process argument |
| A deprecation (2026-10-10) | torb deprecate <package> [--use <package>] [--reason <text>] sends POST .../packages/<owner>/<name>/deprecate, and --undo (never with --use or --reason) DELETE; at a file: registry it appends the deprecate or undeprecate line itself (replacing the notice of an earlier one), refused where the index is signed. torb add prints one notice, warning: <package> is deprecated: <reason> and use <package> instead, for each package named on its command line, from the index the resolution read and after it succeeded; the ones those need, torb update and torb install stay silent, and the lock is the same as without the flag (docs/tooling/torb-deprecate.md) | a notice for what a person chose is information; for what a package pulls in it would be noise nobody can act on |
| The owner of a package (2026-10-13, #342) | the client fetches index/<owner>/owner.trb like an index file - from the mirrors first, a copy in <cache>/index/<registry>/<owner>/owner.trb - only from a registry that lists keys, and checks it with verifiedOwner (7.14); a file that does not verify, or whose changed is smaller than the copy's, is refused, and --offline reads the copy (signedOwner, compiler/src/package/fetch.trb). Where a package file is a 404, torb add and every resolution read the owner's file, and an elsewhere: in it fails with the registry <url> has no package `acme/json`: `acme` publishes at <elsewhere> (verified domain acme.com); bind it in project.trb with `registry "acme", url: "<elsewhere>"`, the domain only where the URL's host is one of the owner's domains (domain: lists up to five, separated by spaces, #341) or under one of them, and then all of them (verified domains acme.com, acme.org); no file, or a registry that signs nothing, leaves "has no package". torb add prints owner acme (organisation, 3 years old, verified domain acme.com) (verified domains a.com, b.com for several) for each owner of a registry package named on the command line that the lock did not have, read before anything is written; nothing where there is no file. torb owner list prints the kind, the domains and a publishes at <url>, not at this registry line from the API | 7.1 and 7.6: an owner that moved is named, never followed - the URL is printed and bound only by the user's own registry line - and the age and the domain are what make a look-alike name of a new owner visible at the one moment it is chosen; the rollback check is the one the format's changed exists for |
Signatures and config.trb | a signature line is read and kept, and not verified: there is no Ed25519 in std yet, and a file: registry signs nothing. config.trb is not fetched. Verified since 2026-09-27: section 7.14 | verification belongs with the key of the registry, which the write service's round creates |
torb publish against 7.2's steps. Step 1 is built (every refusal at once). Step 2 evaluates project.trb in the
sandbox and writes the settings block into the workspace's lock; the archive's own lock holds that block and the
workspace's graph as information. Step 3 is not: the sandbox does not record which variables and files an
evaluation read, so there is nothing to print. Step 4 is torb check of the package against its locked graph, not
--every-target. Step 5 is built, with the summary above. Step 6 writes a file: registry, and since 2026-09-27 uploads
to a registry on the network with a token or through trusted publishing (7.11, "The root server and the write service,
as built").
Not built in this round, and why: the write service, accounts, tokens, trusted publishing and the compose
deployment (7.11, the next round - built 2026-09-27); signature checks and key rotation (no Ed25519 - built 2026-09-27, section 7.14); yank, owner, login (built
2026-10-09, in the table above), audit (built 2026-10-12: the lock against the yanks and deprecations of the indexes), vendor and deprecate; mirrors in ~/.torb/config.trb (built 2026-10-09, section 7.5); git: and archive: sources (both built 2026-10-12, PROJECT.md section 7 "As built"); two majors of one package in one
graph (built 2026-10-14, PROJECT.md section 6a: resolved and locked 2026-10-12, checked 2026-10-13, lowered and built
under name@major identities); HTTP range requests for an index that grew.
7.14 The signed index, as built (2026-09-27)#
Every record of an index file is signed with the registry's Ed25519 key, and torb checks every record it reads
before resolution sees it. std/signature - Ed25519 of RFC 8032 in TorbScript, over the Sha512 std/digest
gained for it - made it possible without a native. compiler/src/package/signing.trb is shared by the write service
and the client, so what is signed and what is checked is one function. What 7.4 left open is decided here:
| Question | Decision | Why |
|---|---|---|
| What a signature is over | not the bytes of the file but a text of the record (recordMessage, compiler/src/package/index.trb): torb index record 1, the package, the record's position among the records of its file counted from 0, the kind and the version, and every field resolution reads - the hash, the language, each runtime requirement, each capability, a yank's reason - each value written as the index writer quotes it | the layout of the file may change without breaking a signature, and quoting makes the text unambiguous; the package keeps a record from being moved into another file, the position keeps a mirror from appending an old signed unyank again after a later yank |
| Which records | every one: the signature line of a release block, the signature: label of a yank line, and of an unyank line, which had none; since 2026-10-10 also of deprecate and undeprecate | an unsigned unyank a mirror appends would bring back a version its owner withdrew, and an unsigned undeprecate would hide a notice |
| The position, and the sequence of a deprecation | position counts the releases, yanks and unyanks only, so a reader from before the deprecation lines, which skips the unknown words, still computes the position of every record it knows and verifies. A deprecate or undeprecate is signed over one more line, sequence <n>, after the position: n counts the deprecation records before it, and the old kinds carry no such line, so their messages are unchanged | a deprecation line cannot be replayed, moved or dropped without a signature failing, and no signature a registry made before is invalidated |
| How a key and a signature are written | ed25519: and 64 hexadecimal digits, ed25519: and 128 | beside the sha256: of the hashes; hexadecimal is what std has, Base64 it has not |
| Where the key comes from | REGISTRY_SIGNING_KEY, the seed as 64 hexadecimal digits, in the deployment's .env; registry signing-key prints a new one and its public key. Without it a development key: a seed in <database>.development-key, made from the system's randomness on the first start and read on every later one, and named as a development key in the start-up lines | the registry's secrets live in .env and never in the repository (7.11); a registry on a laptop needs a key that survives a restart, or its own index stops verifying |
| Where the keys are published | index/config.trb, written at every start where it changed and mirrored like an index file: key "ed25519:..." lines oldest first, each after the first with a signature: label - its predecessor's signature of torb index key 1 and the key | the path of 7.4; the file server serves it with the index, a mirror copies it with the index |
| Rotation | REGISTRY_SIGNING_KEY_SUCCESSOR names the next public key, and the registry appends it endorsed by the current key; the operator switches REGISTRY_SIGNING_KEY to it later and a release of torb ships it. Earlier keys stay listed, because the records they signed stay. A signing key the list does not name starts the list over, with a warning, and one successor is announced at a time | "listed in config.trb with its successor for rotation": a client that trusts the old key learns the new one from a signature of the old one |
| What a client trusts | the keys that ship in torb for the registry (shippedKeys, compiler/src/package/registry.trb), else the keys it saw at its first contact, pinned in <cache>/keys/ - trust on first use, as SSH trusts a host; a key the list adds later only where a trusted key endorsed it, and never one before the first trusted key. A file: registry is a directory of this machine and its list is taken as it stands | a key read from a mirror's own config.trb would make the mirror trusted; shipping the key is 7.4's plan and needs the production key, pinning is what protects every contact after the first until then |
| A registry that signs nothing | one without index/config.trb, or with no key in it, is read as before - the file: registry of a test, a directory copied onto a disk - unless this machine pinned keys of it: then a missing list is a refusal | a downgrade must not be the way around the check |
| When the client checks | every index file a resolution reads (torb add, torb update), record by record, before readIndex; the equation without the cofactor, an S below L. torb install reads no index: it checks the tree hash the lock pins | a resolution never sees a record the registry did not sign, and a lock stays the proof of what was chosen |
| The owner's file | index/<owner>/owner.trb, one line, owner "acme", kind: "organisation", created: 1760000000, changed: 1760003600 and elsewhere: and domain: where they are set, with a signature: label over ownerMessage (compiler/src/package/index.trb): torb index owner 1, then the owner, the kind, both times and both of the others, empty or not, quoted like a record's fields. It is the one file of the index that is replaced whole - it is state, not history - written when the owner is made, whenever where it publishes changes, and at every start where it is missing or not what its row makes, and mirrored like an index file; verifiedOwner (./signing) refuses a changed, unsigned or foreign one. changed grows with every change, by a second where two fall in one, so a client that keeps the largest one it saw refuses an older file a mirror hands back (#342). Since 2026-10-12 (#19); the package name owner is reserved, because its index file would be this one | the owner in the signed text keeps a file from being moved to another owner, and changed stands in for the position of a record: a file replaced whole has none; domain is in the text from the start, so verified domains (#341, built 2026-10-13: the domains an organisation proved, sorted and separated by spaces) change no format and invalidate no signature |
torb publish into a file: registry that has keys | refused: it cannot sign, and one unsigned record makes the whole file refused | the write service is the one writer of a signed index |
Speed: a native binary verifies about 1 000 records a second (docs/standard-library/signature.md), so a
resolution that reads a hundred index files of ten records each spends about a second on them; torb is native. The
VM verifies about 20 a second.
Tested: compiler/tests/packages.test.trb - the text of a record, a signed index that verifies, a changed, moved,
appended-again or unsigned record and another key that do not, trust along endorsements, and the shipped key of
packages.torb.dev trusted offline without ever being pinned;
tools/registry/tests/server.test.trb - index/config.trb at the start, every record the registry wrote verifying,
a successor announced once; tools/registry/tests/client.test.trb - torb add from the registry's files over HTTP
pins the keys, and a changed record and a replaced list of keys are refused.
Built (2026-09-28): packages.torb.dev opened with its production key,
ed25519:d0f14d661e7db9d83c2807b8fa2c27b034dc724f8f66ec3ad43fd5ae6d04d7c4, listed in shippedKeys of
compiler/src/package/registry.trb (docs/contributing/releasing.md, "The root server").
Not built: naming a registry's key in project.trb or ~/.torb/config.trb instead of trusting it on first use; a
command that forgets pinned keys (deleting the file under <cache>/keys/ is the way); the tombstones of 7.3.
8. Terminology#
Recommendation: keep "package". It is the word the repository already uses consistently for the unit a registry holds, and the question the owner asked — is it the right word — has a clear answer once the five neighbouring words are put side by side.
How the repository uses them today (counted as whole words in docs/, CONCEPT.md, compiler/src and std/):
| Word | Occurrences | What it means here | Defined in |
|---|---|---|---|
| package | 1 098 (+ 336 plural) | a directory with a project.trb, named owner/name; what use ... from "owner/name" imports, what a dependency names, what a registry stores | docs/glossary.md "Package", packages.md rule 1 |
| project | 1 034 (+ 24) | the same directory seen from the toolchain: project.trb, the receiver Project, "the project torb was invoked in"; a workspace root is one even when it has no src/ | project.trb, PROJECT.md |
| workspace | 405 | a root project.trb with workspace { members }, one lock for all | glossary "Workspace" |
| module | 1 909 (+ 296) | one file, or a package's src/lib.trb | glossary "Module" |
| library | 505 (+ 10) | what src/lib.trb makes a package produce; "the standard library" | PROJECT.md section 3 |
| crate | 11 | only in comparisons with Rust | — |
No other word for the unit was ever proposed: neither the documents, CONCEPT's decision log nor the commit
messages discuss one.
The one wobble is the glossary's "Workspace": "naming several member projects", where PROJECT.md says a member is a
package. The fix is one sentence in the glossary, and it is a slice: every package is a project; a project is a
package when it has a name and something to produce; a workspace root that only lists members is a project and not a
package. "Project" is the working directory and its file, "package" is the thing with a name that others depend on.
What other ecosystems call the unit:
| Ecosystem | The unit | The file that is shipped | The registry |
|---|---|---|---|
| Rust | crate (the compilation unit), package (what Cargo.toml describes) | .crate | crates.io |
| Ruby | gem | .gem | RubyGems |
| Python | distribution package (and the import package, a different thing) | wheel, sdist; egg historically | PyPI |
| Dart | package (pub is the tool) | archive | pub.dev |
| Elixir, Erlang | package | tarball | Hex |
| Go | module (the versioned unit) containing packages (directories) | zip | the module proxy |
| Swift, Objective-C | package (SwiftPM), pod (CocoaPods) | — | — |
| JavaScript | package | tarball | npm, JSR |
| Lua, Crystal | rock, shard | — | LuaRocks, — |
Why "package":
- It means exactly one thing here, and a full word that needs no gloss is the repository's naming rule. A themed word — crate, gem, pod — has to be learned and then explained next to the word it replaces in every error message, every page and every conversation with somebody arriving from another language.
- "Module" is taken, for a file. Go's use (module = the versioned unit) is the one alternative with a technical argument, and it would collide with 1 909 existing uses meaning something smaller.
- The owner already chose it for the host name: packages.torb.dev.
- The themed words that fit "Torb" collide. Rune (Norse, like the name) is a Unicode code point in Go and in
text handling generally, which is
std/text's territory; forge is a code-hosting word; crate and cargo are Rust's.
Themed alternatives, only as options for the owner's taste (none recommended): rune (Norse; collides as above), saga (Norse; long-running process in other vocabularies), cub (from "björn", bear; playful), kit (short, neutral, but a kit is usually a toolkit), parcel (a JavaScript bundler's name). A themed name for the registry alone — den, hoard — would leave "package" as the unit and only brand the site; that costs little, and it is a taste question.
9. Governance and legal#
- License — recommended: Apache-2.0 WITH LLVM-exception for the toolchain,
stdandruntime/. The runtime and the parts ofstda program uses are compiled into every user's binary; under plain MIT or Apache-2.0 that binary would technically have to carry the notice. The LLVM exception (used by LLVM and Swift) removes exactly that obligation for code embedded by compilation, and Apache-2.0 carries an explicit patent grant. Dual MIT OR Apache-2.0 (Rust's choice) is the alternative, and settles the embedding question by convention rather than by text. This is the owner's decision (question 5); before it, nothing may be published. - Contributions: the Developer Certificate of Origin (
Signed-off-by), no contributor license agreement — unless the owner wants to be able to relicense later, which is the one thing a CLA buys (question 6). - Name and trademark: a short policy on torb.dev, modelled on the Rust and Python ones: the name and the logo may be
used to refer to the language and in community projects; not for a fork that presents itself as TorbScript, and not
in a product name that suggests endorsement. Before registering anything, a search of the name in the EU and US
trademark registers and of
torb/.trbas existing product names and file extensions. - Security policy:
torb.dev/securityand/.well-known/security.txt(RFC 9116) withsecurity@torb.dev, an acknowledgement within a stated number of days, coordinated disclosure, advisories in the database of 7.6. The registry's own incidents are reported on the same page. - Code of conduct: the Contributor Covenant, current version, with the enforcement contact on torb.dev.
- Contribution flow once public: issues and pull requests on
github.com/TorbScript/language; CI runs tier A on every pull request and tier B where the change touches the IR, a back end orruntime/(compiler/CONTRIBUTING.md); the two-commit rule of RUST-EXIT section 4.2 anddocs/design/records stay how a larger change is proposed — a design record is the RFC process, and it already works. Planning happens in the open, in the issues and milestones of git.torb.dev,docs/ROADMAP.mdand the design records; there is no private channel in the repository (question 8).
10. Slices#
Each slice lands with the gates green, like every other slice in docs/design/. Slices 1 to 7 can be done now,
beside the work on milestones 7 and 8, without touching a file that work touches.
| # | Slice | Needs | Launch |
|---|---|---|---|
| 1 | std/markdown: CommonMark with tables and front matter, the document tree as a value, HTML output; the CommonMark specification's examples are its test suite; compiler/src/documentation/markdown.trb is replaced by it. Built (2026-09-25): all 652 examples pass, and the documentation tool reads through it | nothing | preview |
| 2 | torb docs site <root> <out> and --check: pages, navigation from the indexes, highlighting from the lexer and torb highlight, the search index, a stylesheet, llms.txt; the site of main built by tier A | 1 | preview |
| 3 | CI: tools/gates.sh a and b on every tier 1 target; the macOS and FreeBSD executablePath; the runtime/os/*.c bodies compiled on their systems for the first time (fact 4). Built, not yet run: section 13 | nothing | preview |
| 4 | The archive format and its hash: SHA-256 and a deterministic tar + gzip reader and writer, as std packages over thin natives; the tree hash of 7.2. Built (2026-09-26) as std/digest, std/archive and std/compression, in TorbScript with no native | nothing | registry |
| 5 | The index format in std/project (or a std/registry): release, yank, package, owner as receiver-script vocabulary with a static reader and a deterministic printer, and a --check over a directory of such files in the shape of | ||
canon --check; the capability table of 7.8 in std | 4 for the hashes | registry | |
| 6 | torb pack and torb publish --dry-run: the file set of 7.2, the archive, the hash, the capability summary, the printed environment variables. No network. Needs PROJECT.md slice 8 (the locked manifest), which needs slice 7 | 4, PROJECT.md 7-8 | registry |
| 7 | The release pipeline: archives, SHA256SUMS, Sigstore signatures, the portable seed, the install scripts, install.trb; run on every tag of a private test release until the preview. Built except the install scripts and install.trb: section 13 | 3 | preview |
| 8 | The preview's language gaps: milestone 7 finished, run/test on the VM by default, PROJECT.md slices 1-4 and language, git:/path: sources, a license | milestone 7, PROJECT.md | preview |
| 9 | torb upgrade and torb toolchain add c (the managed zig cc of section 4) | 7 | preview |
| 10 | The playground: torb for wasm32-wasi, the new OperatingSystem case, the page | VM slice 7 | preview or later |
| — | The preview: torb.dev live with downloads, the documentation of the tag, the policies of section 9 | 1-3, 7-9 | |
| 11 | The client: resolution, add/remove/update, the content-addressed cache, --offline, vendor, signature checks against config.trb — tested against a directory of index files, with no server at all. Built (2026-09-26) without vendor and the signature checks, with the index format of slice 5 and torb publish --dry-run of slice 6: section 7.13; the signature checks built 2026-09-27: section 7.14 | 5, 6, HTTP and TLS (milestone 8) | registry |
| 12 | The write service: accounts, owners, tokens, 2FA, publish, yank, trusted publishing, the documentation worker | the HTTP server of milestone 10, or question 2 | registry |
| 13 | The registry's site: package pages, capabilities, documentation, search | 2, 12 | registry |
| — | The registry: packages.torb.dev live, the policies of 7.10 | 11-13 | |
| 14 | The 1.0 list of section 2: the language server and the published extension, torb format, torb lint, deprecated, the newtypes of the growing std types, the prelude rule, Decimal, Unicode, the stability document | milestone 8 | 1.0 |
11. What this is not#
- Not a date for 1.0. The launches are gated by lists, and the lists are the repository's own milestones. The releases themselves follow the calendar since 2026-09-29 (section 3): the milestones decide what a minor carries, the first Tuesday when it comes.
- Not a registration. Nothing here was registered, published, announced or configured: not a DNS record, not an account, not a package name on another registry.
- Not a new build system. The registry stores source and runs nothing;
torb publishchecks, it does not build. - Not a second manifest format. The index, the owner files and the user's
config.trbare receiver scripts in the vocabulary the lock already uses; the only JSON is what a browser reads (the search index) and what another ecosystem's scanner reads (the OSV export). - Not a decision about foreign functions. Section 2 names the question the registry needs answered; the answer
belongs to the design of
foreign: open, owner decision (#84), see FOREIGN.md section 5.3.
12. Open#
Everything technical above is decided. These are the owner's: questions of direction, taste or money.
- The word for the unit. Section 8 recommends staying with "package" and lists themed options only for completeness. A themed name for the registry alone (den, hoard) is possible without changing the unit.
- The registry's server language. Decided: TorbScript, which waits for the HTTP server of milestone 10. The alternative is a small server in another language (Go, Rust) to open the registry right after milestone 8, rewritten in TorbScript later; the protocol of 7.4 is the same either way.
- Which
stdpackages 1.0 promises. The recommendation: only what is finished and used — the prelude's packages,fs,io,process,environment,time,json,test,project,sandbox,os,httponce it runs. The domain packages (linear,geometry, the plannedecsand machine learning packages of milestone 10) either wait for a later minor or live under a separate owner on the registry (torbscript/..., as Go keepsgolang.org/x) until they are ready. "The standard library is part of the language" (PROJECT.md section 10) makes this a question about what the language is. - The stability promise and the cadence. Section 3 proposes Go-style strict compatibility within 1.x, no editions, and a minor release every six weeks. Confirm, or say which surface should promise less. Decided by the owner: Go-style stability without editions, and a release every six weeks. Changed by the owner's delegation (2026-09-29): a minor on the first Tuesday of every month, its release candidate a week before on the preview channel, patches whenever a fix is ready, alpha and beta only before a major, no long-term support (section 3).
- The license. Apache-2.0 WITH LLVM-exception (recommended) or MIT OR Apache-2.0. Without an answer nothing may be
published. Decided by the owner: MIT, and MIT-0 for
runtime/andstd/, whose code ends up in every program; every release archive carries the threeLICENSEfiles. - DCO or CLA. DCO is recommended; a CLA only if relicensing later should stay possible. Decided by the owner: DCO.
- Money. Apple's developer membership for notarization, a Windows code signing certificate or service, the
object storage and CDN of the registry, and mail for torb.dev. Every one of them can wait for the preview except
mail, which
security@torb.devneeds. - What becomes public. The repository including
docs/design/,docs/internals/and the history is recommended. Decided: the private planning file left the repository, and what in it was still to be done moved todocs/ROADMAP.md, the design records and CONCEPT's "Open Questions". - Domain-verified owners with a dotted name. PROJECT.md reserved
acme.com/xas a name form. The registry could use it for owners that proved the domain (7.1). Recommended: not at launch; a verified domain is a badge on a plain owner, and the dotted form stays reserved.
13. The release pipeline, as built#
Status: written, not yet run. The workflows, the composite actions and the scripts below exist in the repository;
no workflow has run on GitHub yet, no seed and no release has been published, and the Linux and macOS halves of the
runtime have still never been compiled (fact 4) - among them the whole POSIX side of the network (runtime/os/posix_io.c,
the pollers epoll.c and kqueue.c, runtime/tests/io_test.c) and of std/os (posix.c, linux.c, bsd.c,
macos.c). The first CI run is the first compile of all of it, and every job that can reach them runs the runtime's
tests and the conformance suite. The Windows half of every script was run on the maintainer's machine;
what the first run on the other targets is expected to report is listed at the end of this section. How the owner
cuts a release is docs/contributing/releasing.md.
Since 2026-09-27 the pipeline runs on the forge (section 14): the workflows moved from .github/ to .forgejo/,
the seeds and the releases to git.torb.dev, the images to cr.torb.dev, and the signature from keyless Sigstore to the
project's key. The jobs and their reasons below are the same; where the forge changed one, this section says so, and
the first runs it reports were GitHub's.
The files#
| File | What it does |
|---|---|
.forgejo/workflows/ci.yml | every push to main and every pull request of the repository itself (a fork's runs nothing): the commit messages and the gates |
.forgejo/workflows/gates.yml | the reusable workflow every other one calls: bootstrap, tier A, tier B, the tests of tools/, the agreement of the C, the other targets where they have a runner, and on request the release binaries, the archives and the seed. Writes nothing but artifacts and caches |
.forgejo/workflows/nightly.yml | every night that main changed: the gates, then the seed, a prerelease nightly-YYYYMMDD, and its VS Code extension to both stores as a pre-release |
.forgejo/workflows/release.yml | a pushed tag v0.MINOR.PATCH, or a pre-release v0.MINOR.0-rc.N (vMAJOR.0.0-alpha.N, -beta.N): the checks of the tag, the gates, the signed release, the seed, and for a stable release the package manager channels of section 5 (publish-packages) and the extension's stores (publish-extension) |
.forgejo/workflows/seed.yml | Actions -> seed -> Run workflow: the seed of main, published without a release |
.forgejo/workflows/images.yml | called by release and nightly after publishing, or run by hand for a release: the root server's three container images from the release's assets, pushed to cr.torb.dev and signed (section 7.11) |
.forgejo/workflows/benchmarks.yml | called by nightly after its images, or run by hand for a nightly or a release: benchmarks/game.sh with that toolchain, and its report published to the release benchmarks (section 6, "The benchmarks page, as built") |
.forgejo/actions/c-compiler | the C compiler of a target on the PATH and in TORB_CC, and the Debian packages a job needs |
.forgejo/actions/bootstrap | build/release/torb from the cache, or from a published seed with the fixpoint |
.forgejo/actions/portable | what a target other than linux-x64 runs: bootstrap, check ., the conformance suite, the runtime's tests, the C of every target, the release binary |
.forgejo/actions/cross | the release binary of a tier 1 target without a runner, cross-compiled on linux-x64 by the pinned zig cc, checked, and smoke-tested under wine or qemu-user ("Cross-compiled archives", below) |
.forgejo/actions/token | the token of an Authorized Integration for the job (section 14) |
.forgejo/actions/cosign | the pinned cosign that signs, the release release-sync verifies with |
.forgejo/actions/node | the pinned Node.js that packs and publishes the VS Code extension, checked against the SHA-256 nodejs.org lists |
.github/workflows/portable.yml | the GitHub mirror's one workflow: .forgejo/actions/portable on windows-x64, linux-arm64 and macos-arm64, and their agreement on the C (section 14); every morning the newest nightly, installed from torb.dev on those three and on linux-x64, and run |
tools/forge.sh | the releases of the forge over its REST API with curl: create, upload, download, publish, delete, list - Forgejo, or GitHub |
tools/agree.sh | the compiler's C for every target as one host emits it, and whether the lists of several hosts agree |
tools/build-seed.sh | compiles a seed's program.c with its runtime - the one place that knows that command line |
tools/cross.sh | the compiler's C for another target, emitted by a torb of this machine and compiled by the pinned zig cc through tools/build-seed.sh; checks what it wrote |
tools/fetch-zig.sh | downloads the pinned Zig from Zig's community mirrors (ziglang.org last), checks its pinned SHA-256, unpacks it into build/zig/ |
tools/pack-seed.sh | packs a seed into torb-seed-<commit>.tar.gz and its .sha256, reproducibly |
tools/fetch-seed.sh | downloads a published seed - from the forge, and from GitHub for one the forge lacks - verifies it, compiles it into build/seed/torb |
tools/publish-seed.sh | uploads a packed seed to the seeds release of the forge and puts it on top of seeds.txt (tools/forge.sh) |
tools/migrate-seeds.sh | copies every seed of GitHub's seeds release the forge's lacks, once |
tools/package.sh | lays out and packs the toolchain of one target (section 4) |
tools/package-extension.sh | packs the VS Code extension of editors/vscode into torbscript-<version>.vsix with @vscode/vsce (pinned, through npx), after checking that its version is the toolchain's |
tools/smoke-test.sh | runs a laid-out toolchain from outside any checkout, with no variables; under an emulator (TORB_SMOKE_RUNNER) for a binary of another machine |
tools/fetch-benchmarks.sh | the newest benchmarks.json of the release benchmarks into build/, where torb docs site reads it; fetching nothing is no failure |
The seed, published#
Decision: a seed is published as torb-seed-<commit>.tar.gz on one release tagged seeds - of GitHub when this was
built, of the forge at git.torb.dev since 2026-09-27 - listed in seeds.txt beside it, and fetched over plain HTTPS
without gh.
torb-seed-1ae963fa1234/
├ program.c the compiler's C, emitted by the compiler of that commit from its own sources
├ program.hash what `torb run` keys its cache on
├ commit twelve hexadecimal digits
├ runtime/ the runtime of the same commit: include/, *.c, os/*.c - not its tests
├ build.sh tools/build-seed.sh: `sh build.sh` writes ./torb with the machine's C compiler
└ README.md- The runtime travels with the C.
program.ccalls the runtime of the commit it was emitted at; the runtime of a later commit may have renamed a native that C still calls.tools/pack-seed.shtherefore takesruntime/from git at the seed's commit, never from the working tree - which is also whyseed/program.cin the main checkout only works with a runtime that has not moved on. seeds.txtis one line per seed, newest first:<commit> <sha256> <archive>. An archive is found in the directory of the index, so a mirror, a fork or a local directory is one variable away (TORB_SEED_INDEX); the tests oftools/fetch-seed.shuse afile://index.- One release for every seed, rather than a tag per seed: no tag noise, one URL scheme, and a prerelease is never the
repository's "latest". The
seedstag points at whatever commit it was created on, which means nothing. Every seed is kept; at about 6 MB each (5.9 MB for the 106 MBprogram.cof653af8bb, gzip -9) pruning can wait. .tar.gz, not.tar.xz(4.1 MB): every machine that hastarhasgzip, and the archive is not downloaded often enough for the difference to matter.- Reproducible: entries sorted, owner 0, modes fixed, every timestamp the commit's,
gzip -n. Packing the seed of653af8bbtwice gave the same SHA-256. - Verification is the SHA-256 of the index, over HTTPS. The signature of a release (below) covers the seed as one
of its assets; a seed published by the nightly or the
seedworkflow carries none, because it is no release. - Two indexes.
tools/fetch-seed.shreads the forge's (public, no token), and GitHub's only when the forge's cannot be read or does not list the seed asked for - the seeds published before the move, untiltools/migrate-seeds.shhas copied them. GitHub's repository is private and answers the plain download URL with a login page; withGH_TOKENorGITHUB_TOKENset,tools/fetch-seed.shgoes through its REST API instead (the release of the tag, the asset's id, the asset withAccept: application/octet-stream) - still curl or wget and nogh. A private forge would takeTORB_FORGE_TOKEN.
Where tools/bootstrap.sh takes its seed from, in this order: $TORB_SEED (never falls back), seed/torb or
seed/program.c, the newest archive in ../torbscript-seeds/, build/seed/torb - and when there is none,
tools/fetch-seed.sh is run for the newest seed (TORB_SEED_FETCH=0 forbids it). When step 1 fails with a seed that
was fetched earlier, the newest is fetched once more and tried before the archives, because the fetched one may predate
the first commit of a breaking change.
When a seed is published: by the nightly and by every release, once linux-x64 has bootstrapped and passed tier A and
tier B - the other targets are not waited for, since the seed is C and the agree job shows the C is the same on
every target - and at once by the seed workflow. That workflow is the CI half of the two-commit rule
(compiler/CONTRIBUTING.md): the first commit teaches both forms, seed publishes its seed, and only then can the
second commit bootstrap in CI and in a fresh clone.
The first seed cannot come from CI, which needs a seed to build anything. On the forge it comes from GitHub
(tools/migrate-seeds.sh, which copies every seed GitHub's index lists) or from the main checkout:
sh tools/pack-seed.sh seed and TORB_FORGE_TOKEN=<token> sh tools/publish-seed.sh build/seed-archive/torb-seed-<commit>.tar.gz.
Both publish through tools/forge.sh - curl and the forge's REST API, no gh; the release is created as a prerelease
on first use, and seeds.txt is replaced by deleting the asset and uploading the new one.
CI: the jobs and why#
| Job | Runner | What | When |
|---|---|---|---|
bootstrap (linux-x64) | ubuntu-latest (the forge's runner), gcc | the seed compiled from program.c, seed -> torb -> torb with the fixpoint | always |
tier A (linux-x64) | ubuntu-latest | sh tools/gates.sh a with that compiler | always |
tier B (linux-x64) | ubuntu-latest | sh tools/gates.sh b (the fixpoint with the compiler as its own seed), and the runtime's tests with clang | every push to main; a pull request that touches compiler/src/ir, compiler/src/backend, runtime/, tools/ or .forgejo/ |
tools/release-sync and tools/registry (linux-x64) | ubuntu-latest | their tests, natively, with sqlite3; sh -n over the shell of tools/ | always |
bootstrap and conformance (windows-x64) | a runner labelled windows-x64, gcc of MSYS2 UCRT64 | bootstrap with the fixpoint, torb check ., the conformance suite, the runtime's tests, the C of every target | once the runner exists and TORB_RUNNERS names it; until then on the GitHub mirror after the push |
... (linux-arm64) | a runner labelled linux-arm64, gcc | the same | as windows-x64 |
... (macos-arm64) | a runner labelled macos-arm64, clang | the same | as windows-x64 |
cross-compiled on linux-x64 (<target>) | ubuntu-latest, the pinned zig cc | .forgejo/actions/cross: the release binary of windows-x64, linux-arm64 or macos-arm64 from the C --target emits, checked, smoke-tested under wine (windows-x64) or qemu-user (linux-arm64); macos-arm64 is not run | a nightly or a release (package), for each of the three that TORB_RUNNERS does not name |
every host emits the same C | ubuntu-latest | tools/agree.sh: linux-x64 emits the compiler's C for all nine targets (torb build --emit-c --target), --target linux-x64 has to be the bootstrap's C, and every other runner of the run has to emit the same C for every target | always |
the VS Code extension (editors/vscode) | ubuntu-latest, the pinned Node.js | tools/package-extension.sh: the .vsix packed with @vscode/vsce, its version checked against project.trb - a manifest the stores would refuse fails here, not in a release | always |
Why tier A and tier B run on one target only. Nearly every gate tests the compiler, and the compiler is the same C on
every machine - which the agree job proves on every run, and which is the strongest cross-platform statement the
repository can make cheaply. On the forge's one runner it is proven differently than on GitHub's four: the C a host
emits for a target is a function of the target, so the check compares what several hosts emit for every one of the
nine targets (the eight machines and browser-wasm64, the playground's), and with a single host it shows that every target lowers and that --target of the host itself
reproduces the bootstrap's C. The lists of the other runners, of the GitHub mirror's machines and of the maintainer's
own (sh tools/agree.sh emit build/release/torb build/hosts/mine.txt) are compared the same way; measured on
2026-09-27, the eight emissions take four to five minutes each on the maintainer's machine, and the C differs only
between groups of targets (windows; linux and macos-x64; macos-arm64 and the BSDs). What differs per machine is the C compiler, the C library and the runtime's platform
layer, and exactly those are exercised by the bootstrap (the compiler builds itself natively, twice or three times),
the conformance suite (every behaviour, built and run natively, byte for byte against its expectation) and the
runtime's own tests. Tier A on macOS would cost a macOS hour per push for torb test compiler/tests, which checks
what the agree job already shows is identical. linux-x64 carries both tiers because it is the cheapest runner and
the most common machine of the people who will send pull requests. Tier A and tier B run as two jobs after one
bootstrap, so the wall time is the longer of the two and not their sum.
Why the release scheme still meets section 5's "tier A and tier B on every tier 1 target" in substance: the gates that can differ between machines run on every one; the ones that cannot run once. When a difference turns up that only a full tier catches, the fix is to move that gate into the portable job, not to run everything everywhere.
Why the compilers are these. gcc on Linux is the compiler the maintainer's gcc 13 is closest to, so the first
Linux run tests the POSIX runtime and not a change of compiler; clang meets the runtime once more on Linux (tier B's
last step) and the generated C on macOS, where it is the only one. On Windows clang targets MSVC and torb build
passes -lm, which MSVC does not have, so the Windows job installs gcc from the MSYS2 that every Windows image has.
Memory and time. TORB_BUILD_SLOTS=1 in every job: one cc1 over the compiler's C takes 1.5 to 2 GB, a test
suite's C is larger, and the forge's runner runs two jobs at once beside the forge itself - its machine wants 8 GB for
them. Timeouts: 90 minutes for the Linux bootstrap, 120 for each tier, 60 for the agreement of the C, 150 for a portable
target (a first seed compile, the whole conformance suite), 60 for a cross-compiled one (about ten minutes, most of
them the emission and the compile of the C).
Caches (the runner's own cache server; a runner without one only loses the time), written only by a push to
main so a pull request reads them and never fills the cache:
build/release keyed by the target and the hash of everything torb is built from (compiler/src, the manifests,
std/, runtime/, tools/bootstrap.sh) - a hit skips the bootstrap, because those sources were bootstrapped on that
target before - and build/seed keyed by the target and the seed's commit, so an unchanged seed is compiled once. The
one exception is the archive of the pinned Zig, keyed by tools/fetch-zig.sh and written by any run that missed it: only
a nightly or a release reaches it, and it is checked against the pinned SHA-256 each time it is used, so an entry can
save a download and never slip in another file.
Concurrency and permissions. ci cancels the run a newer push to the same branch or pull request supersedes;
nightly, release and seed never cancel. On the forge no workflow has a permissions: key - Forgejo ignores a job
that has one - and nothing writes with the job's automatic token: the jobs that publish set enable-openid-connect: true and write with the token of an Authorized Integration, whose rules admit only the named workflow files, refs and
events of this repository (section 14). The signing key is a secret only publish of release and nightly and the
image jobs read. Every checkout has persist-credentials: false, every action is pinned by commit, a pull request
from a fork runs nothing, and no workflow uses pull_request_target.
Versions, tags and channels#
- A release is a pushed tag and nothing else:
v0.MINOR.PATCHfor a stable release, and since 2026-09-29 a pre-releasev0.MINOR.0-rc.N- before a majorvMAJOR.0.0-alpha.N,-beta.Nand-rc.N(section 3).release.ymlrefuses a tag of another shape or with a leading zero, a stable release that is not0.MINOR.PATCH- 1.0 is released by changing that check - a tag whose number without its suffix differs fromversioninproject.trb,compiler/project.trboreditors/vscode/package.json, or fromlanguageVersionincompiler/src/package/version.trb(section 3: one number, the editor extension's andtorb --version's included), a commit that is neither onmainnor onrelease/MAJOR.MINORof its number, and a repository without aLICENSE(section 9; the owner chose MIT, question 5) - the forge's repository is public. - A pre-release is a prerelease of the forge: never its latest release, listed by release-sync on the
previewchannel, its images tagged<version>andpreviewinstead oflatest, and neitherpublish-packagesnor the stores run for it -publish-extensionsays so in a notice. Its assets are named after the whole version,torb-0.2.0-rc.1-linux-x64.tar.gzandtorbscript-0.2.0-rc.1.vsix, whose manifest says0.2.0and marks it as a pre-release. Its notes aredocs/releases/<version>.md, or else those of its number,docs/releases/0.2.0.md, with a paragraph that names the channel. - A release of
release/MAJOR.MINOR- a later candidate or a patch, whose commitmaindoes not have - keeps its seed out ofseeds.txt: every clone ofmainbootstraps from the newest seed there, and the release branch's compiler may not read whatmainhas taught since. The seed stays an asset of the release.ciruns on every push torelease/*as onmain, and bootstraps frommain's newest seed, somainkeeps reading every form the maintained release branch uses until the next minor is out. - The VS Code extension follows the release, and the nightly follows itself. A release publishes
torbscript-<version>.vsix, the file among its assets, to the Visual Studio Marketplace (vsce publish, the secretVSCE_PAT) and to Open VSX (ovsx publish, the secretOVSX_PAT), each only where its secret exists, after the release is on the forge. A nightly carriestorbscript-nightly-YYYYMMDD.vsixto both stores the same way, marked as a pre-release, as VS Code's "Switch to Pre-Release Version" -vsce verify-patandovsx verify-patrun first, so a wrong publisher or namespace fails clearly. Its manifest is stamped with a store version of its own,MAJOR.(MINOR+1).YYYYMMDDofpackage.json's version and the nightly's date (tools/package-extension.sh,0.1.0becomes0.2.20260929) - not the toolchain's number, which belongs to releases: a later stable0.2.0is then lower than the nightlies of its cycle, so pre-release users stay ahead of release users and a release never collides with a nightly's number. A second nightly of the same day has the same store version and is skipped (--skip-duplicate). A release candidate'storbscript-0.2.0-rc.1.vsixis marked as a pre-release too, but reaches neither store: its version,0.2.0, would collide with the stable release that follows it. - stable: the release, marked latest, kept forever. preview: a pre-release, kept like a release.
nightly: a prerelease
nightly-YYYYMMDDofmain, made only whenmainchanged since the last one and every target is green, deleted with its tag after 30 days. A second run on one day replaces that day's nightly. - The release notes are
docs/releases/<version>.mdwhen it exists (section 6 plans that folder), plus the commands that verify a download.
What a release contains#
| Asset | Content |
|---|---|
torb-<version>-<target>.tar.gz | per target: bin/torb, std/, runtime/ without its tests, tools/build-slot.sh, tools/build-units.sh, LICENSE (MIT; runtime/ and std/ carry their own, MIT-0), README.md |
torb-<version>-windows-x64.zip | the same for Windows, as a zip as well |
torb-<version>-source.tar.gz | git archive of the tag |
torb-seed-<commit>.tar.gz and .sha256 | the portable seed, above |
torbscript-<version>.vsix | the VS Code extension of editors/vscode, the file the Marketplace and Open VSX get (a nightly: torbscript-nightly-YYYYMMDD.vsix, published as a pre-release with a store version of its own; a release candidate: torbscript-0.2.0-rc.1.vsix, marked as a pre-release too but never in a store) |
SHA256SUMS | the SHA-256 of every asset above |
SHA256SUMS.sig | the cosign signature of SHA256SUMS, made with the project's key (SHA256SUMS.sigstore.json, the keyless bundle, while releases were made on GitHub) |
- The seed is beside the archives, not inside each: it is the same file for every target and 6 MB.
- Every tier 1 target (since 2026-09-29): linux-x64 from its bootstrap; windows-x64, linux-arm64 and macos-arm64 from their own runner where the forge has one of that label (section 14), and cross-compiled on linux-x64 where not ("Cross-compiled archives", below). The release notes list the targets and say which were cross-compiled, and a package manager channel whose archives are missing renders nothing.
- The binaries: linux-x64 and linux-arm64 compile the fixpoint's
program.cwithmusl-gcc -static(section 5 asks it for linux-x64; linux-arm64 gets the same so that one Linux binary runs on every distribution of either architecture), and the job fails iflddfinds it dynamic. windows-x64 and macos-arm64 ship the bootstrapped binary itself; the Windows job fails iftorb.exeimports a DLL a Windows machine does not have, the macOS job runscodesign --verify(the linker's ad hoc signature, section 5). A cross-compiled binary is the same C of the same commit, compiled by the pinnedzig ccfor the same C library - static musl, MinGW-w64 on the UCRT - and put through the same checks by other means (below). - Every release binary is smoke-tested as a download: laid out by
tools/package.sh layout, started from a directory outside any checkout with noTORB_STDorTORB_RUNTIME, it checks, builds and runs a program. A cross-compiled one is started under wine or qemu-user, and macos-arm64's not at all (below). - The archives are packed on Linux, one job for all targets, with GNU tar and zip: sorted entries, owner 0, fixed modes, the commit's time. The binaries inside are not yet reproducible across runner images (below).
Cross-compiled archives (2026-09-29)#
Until this, the nightlies on git.torb.dev carried torb-<version>-linux-x64.tar.gz and nothing else a machine could
run: the forge has one runner, linux-x64, and an archive was built only on a runner of its target, so nobody on
Windows, macOS or an arm64 Linux could install TorbScript - install.ps1 found no .zip, install.sh no archive of
its machine, and the Scoop, Homebrew and winget steps of a release skipped themselves.
Decision: when a nightly or a release packages, the forge's linux-x64 runner cross-compiles the release binary of
every tier 1 target that has no runner of its own - windows-x64, linux-arm64, macos-arm64 - with one pinned,
hash-verified zig cc, section 4's managed compiler; each binary is checked, started under an emulator where one
exists, and packed by tools/package.sh exactly like a native one. A target's own runner, once TORB_RUNNERS names it,
replaces its cross job, and its binary is the one released.
- The C is the target's own. The job runs
torb build ./compiler --emit-c --target <target>with the linux-x64 compiler of the run (tools/cross.sh) - the path the playground takes tobrowser-wasm64with emscripten. The C a host emits for a target is the C that target's bootstrap emits, which theagreejob checks on every run, so the binary is the compiler a native bootstrap of the commit builds, compiled by another C compiler. The runtime is compiled for the target in the same command (tools/build-seed.sh, with$TORB_CCa wrapper ofzig cc -target <triple>and the TLS ofvendor/as always), and the archive gets thestd/andruntime/of the commit beside it. - The C libraries are the native builds':
x86_64-windows-gnuis MinGW-w64 on the UCRT, as MSYS2's UCRT64 gcc builds windows-x64 on its runner (the binary importsapi-ms-win-crt-*,kernel32,advapi32andshell32);aarch64-linux-muslis static musl, asmusl-gcc -staticbuilds linux-arm64;aarch64-macos.13.0linkslibSystemand nothing else and runs on macOS 13 and newer, Zig's default, named so a new Zig cannot move it.tools/build-seed.shasks the compiler for the machine it builds for (-dumpmachine) before it adds-pthread, which a Windows target must not get from a Linux host. - windows-x64 keeps its icon and version:
zig rccompilestools/windows/torb.rcinto a.resthatzig cclinks (tools/windows/resource.shwith an output ending in.res), with Zig's own preprocessor and<winver.h>- no MinGW on the runner at all. - Zig is pinned by version and by the SHA-256 of each host's archive in
tools/fetch-zig.sh(0.16.0; the x86_64-linux archive for the runner, x86_64-windows, aarch64-linux and aarch64-macos for people), each archive's minisign signature checked against the Zig Software Foundation's key when it was pinned. It downloads from Zig's community mirrors in a random order and from ziglang.org last, as ziglang.org asks of automated downloads, and keeps nothing whose hash is not the pinned one. The job caches the archive.
| Option | For | Against |
|---|---|---|
Debian's cross compilers (gcc-mingw-w64, gcc-aarch64-linux-gnu) | the runner's own packages; gcc, as the native builds | no macOS at all; Debian's MinGW links msvcrt by default where the native windows-x64 binary uses the UCRT; no aarch64 musl cross compiler in Debian, and a static glibc binary is not what linux-arm64's runner ships; windres needs MinGW's gcc as its preprocessor; three toolchains of whatever version the image carries |
One pinned zig cc — the decision | section 4's plan; one 55 MB archive for all three targets, pinned, so the three binaries depend on it and not on the runner's image; the C libraries of the native builds; the only way to macOS from Linux; zig rc for the icon | a pre-1.0 dependency; clang rather than gcc for these three - the generated C meets clang on macOS and in tier B already; Zig's headers and linker for macOS, not Apple's |
| osxcross for macOS | Apple's linker and headers | Apple's SDK may not be redistributed or fetched by CI |
What is tested before anything is published (.forgejo/actions/cross, a job per target, and tools/cross.sh):
| Target | Checked | Started |
|---|---|---|
| windows-x64 | a PE32+ x86-64 console executable; imports only DLLs every Windows has (the list of .forgejo/actions/portable); carries a resource section | under wine 9 (Ubuntu 24.04's): torb --version, then tools/smoke-test.sh - check, two programs run in the VM, the registry's C emitted - laid out as a download outside the checkout |
| linux-arm64 | a static aarch64 ELF | under qemu-user (qemu-aarch64, which the static binary needs nothing else for): the same |
| macos-arm64 | an arm64 Mach-O executable with a code signature (Zig's linker signs ad hoc, and an arm64 Mach-O without one is killed at its start), loading /usr/lib/libSystem.B.dylib alone, for macOS 13 and newer - read from its load commands with od | never: nothing on a Linux runner starts a Mach-O |
What is not tested before publishing, on any of the three: the conformance suite, the runtime's tests, a native
torb build with the machine's C compiler and the archive's runtime/, and anything wine or QEMU emulates rather
than Windows or Linux itself - and for macos-arm64 whether the binary starts at all. A cross-compiled binary is
smoke-tested, never gated. Two things cover it afterwards: the GitHub mirror runs bootstrap, the conformance suite and
the runtime's tests of each target's own native build after every push (portable.yml), and every morning its job
download installs the published nightly from torb.dev on real windows-x64, linux-arm64 and macos-arm64 machines (and
linux-x64) with tools/install.sh and tools/install.ps1, and checks, runs and natively builds a program with it -
the first time a cross-compiled binary meets its machine, and a report, not a gate. The release notes of a nightly and a
release name the targets that were cross-compiled. A red cross job stops the nightly or the release like any other red
gate.
The nightly's images stay linux/amd64: images.yml builds linux/arm64 images when a release has a linux-arm64
toolchain, every nightly has one now, and its three arm64 build stages under QEMU would take hours of the one runner
every night, for images the root server (x64) never pulls. A stable release still gets both.
Verified when this was built, 2026-09-29: on the maintainer's Windows machine, tools/cross.sh with the Windows
build of the same Zig built windows-x64's torb.exe from the C of the commit in five and a half minutes; that binary
passed tools/smoke-test.sh natively and the conformance suite natively (256 of 256 programs) and in the VM (259 of
259) - on its own machine, which the forge's runner cannot do. The same C
compiled by the machine's MinGW gcc 13 (x86_64-ucrt-posix-seh) through tools/build-seed.sh passed the smoke test
too, but that torb.exe - like every bootstrapped one on that machine - imports libwinpthread-1.dll, which a gcc of
the posix thread model links in although the runtime uses no pthreads on Windows, and which the check of
.forgejo/actions/portable refuses:
MSYS2's UCRT64 gcc has the same thread model, so a native windows-x64 runner is expected to meet that check red until
the release step links winpthreads statically. The Zig build imports no such DLL. In the
forge runner's image (catthehacker/ubuntu:act-24.04, under Docker, as root), with the linux-x64 compiler compiled
from the same commit's C: tools/fetch-zig.sh fetched from a different mirror on each of two runs, tools/cross.sh
built each of the three in five to seven minutes - emission included, and every program.hash the same as the
bootstrap's - windows-x64 passed the smoke test under wine in 30 seconds, linux-arm64 under qemu-aarch64 in two and a
half minutes, macos-arm64 passed its checks, and tools/package.sh packed archives of 6.7 to 7.5 MB, as large as
linux-x64's native one. Installing wine and qemu-user took 40 seconds. tools/install.sh installed the linux-arm64
archive from a file:// channel of those archives and the installed torb checked and ran a program under qemu, and
torb.exe of the unpacked .zip did the same under wine. The workflows pass
forgejo-runner validate of runner 12.13.2 (the one Forgejo 16.0.5 builds in) and 13.2.0, and portable.yml passes
actionlint. The first nightly on the forge is the first run of the jobs themselves.
Signing#
Decision (2026-09-27, the forge): cosign sign-blob over SHA256SUMS with the project's own key, in the publishing
job; the signature SHA256SUMS.sig is a release asset, the public half of the key is tools/deploy/cosign.pub in the
repository and https://torb.dev/cosign.pub, and the same key signs the container images. The private half is the
secret COSIGN_PRIVATE_KEY of the repository, with its password COSIGN_PASSWORD. Signing stays on the pinned cosign 2.4.1
(.forgejo/actions/cosign): 3.x refuses sign-blob --output-signature without --bundle, and only
--new-bundle-format=false --use-signing-config=false keeps the legacy .sig. Verifying in release-sync is cosign
3.1.4 (tools/deploy/Dockerfile.release-sync), because the Go modules of 2.4.1 and 2.6.5 carry fixable HIGH and CRITICAL
findings that 3.1.4 does not; it verifies the .sig of 2.4.1 (measured). Neither writes to nor reads from Sigstore's
transparency log.
cosign verify-blob --key https://git.torb.dev/torbscript/language/raw/branch/main/tools/deploy/cosign.pub \
--signature SHA256SUMS.sig --insecure-ignore-tlog=true SHA256SUMS
sha256sum --check --ignore-missing SHA256SUMSWhy the keyless signature of the first decision (below) could not move with the rest. Keyless signing needs a certificate from Sigstore's Fulcio, which issues one only for the OIDC identity of an issuer it trusts - GitHub's, among a handful - and git.torb.dev's Actions issuer is not one of them (an issue asks Sigstore to trust Codeberg's; a private instance never will be). The choices were a key of the project, a Sigstore of the project's own (Fulcio, Rekor and a trust root to run and back up), or signing on the GitHub mirror, which would make the mirror the signer of what the forge publishes. A key it is:
| Question | Decision | Why |
|---|---|---|
| Who holds the key | a secret of the repository on the forge, and an offline copy of the owner's | a secret is readable by any workflow of a push to the repository, so the key is as safe as write access - which only the owner has, and a fork's pull request runs nothing |
| The transparency log | not used (--tlog-upload=false, and --insecure-ignore-tlog=true to verify) | the log's value is a public record of what a keyless identity signed; with a key, the key is the identity, and a dependency on rekor.sigstore.dev would make every release wait for a service the project does not run |
| Rotation | a new key pair: the new cosign.pub committed and copied to the root server, the secrets replaced | releases stay verifiable with the key of their time, which the repository's history keeps |
The first decision, while releases were made on GitHub (kept for the releases made there, and for release-sync with
RELEASE_SYNC_FORGE=github): keyless Sigstore with cosign sign-blob over SHA256SUMS, in the publishing job,
with that job's OIDC identity; the bundle SHA256SUMS.sigstore.json was a release asset.
| Option | For | Against |
|---|---|---|
cosign keyless over SHA256SUMS — the decision | exactly section 5's plan; one signature covers every asset; verifiable with cosign alone, and later by torb upgrade from the bundle; works in a private repository | a tool to install in the job; the identity (repository, workflow, tag) goes into the public Rekor log, so a private test release makes the repository's name public |
actions/attest-build-provenance | first party, SLSA provenance per file, gh attestation verify | for a private repository it needs GitHub Enterprise Cloud; verifying needs gh or the attestation API; the same public Sigstore log for a public repository |
Attestations were to be added beside the signature once the repository was public; on the forge there are none to add.
Where this departs from sections 4 and 5, and why#
- Native runners, and one pinned
zig cconly where there is none. Section 4 builds every target with a managedzig ccso binaries are reproducible; that toolchain (torb toolchain add c, slice 9) does not exist yet. linux-x64 and every target with a runner are built by the runner image's compiler, so their binaries are reproducible for one image and not across images. Since 2026-09-29 the targets without a runner are built by a pinned Zig thattools/fetch-zig.shfetches ("Cross-compiled archives"): the pipeline's half of section 4, whose binaries depend on the pinned archive and not on the runner's image - but section 5's "built twice on two runners and compared" is still not done for any of them. The C (program.c) is reproducible already, and theagreejob checks it across four machines on every run. linux-arm64is static musl too, above.- FreeBSD (tier 2) is not in the pipeline; it needs a virtual machine on a Linux runner.
What the first run is expected to report#
None of this has been compiled on Linux or macOS; the pipeline is built to say where it breaks, one job per target
with fail-fast: false, so one target's failure never hides another's.
- Every job, until the first seed is published:
tools/fetch-seed.shfinds noseeds.txt(see "The first seed"). - macOS, the feature macros:
platform.c,os/posix.c,os/posix_io.candtests/console_test.cdefine_POSIX_C_SOURCE 200809L, which on macOS alone hides every Darwin extension -pthread_cond_timedwait_relative_np(the macOS branch oftorb_condition_wait),_SC_NPROCESSORS_ONLN(the processor count of the worker pool) andSO_NOSIGPIPE(without which a write to a closed socket kills the process withSIGPIPE, since macOS has noMSG_NOSIGNALeither). The four files now define_DARWIN_C_SOURCEbeside it on Apple, which shows the extensions again; that is the documented switch, but it has not met a macOS compiler yet, so this item stays until the first run confirms it. - macOS, finding the toolchain:
Process.executablePath()answersNone(fact 5), so the smoke test of a release fails - correctly, a downloadedtorbcannot find itsstd/today. The gates are unaffected: they run in the checkout, wherestd/is found above the working directory. - The generated C under clang:
torb buildcompiles with-Wall -Wextra -Werror, and the C has only ever met gcc 13. Any warning clang adds stops every macOS build. - linux-arm64:
charis unsigned there. A comparison of acharwith a negative number becomes a-Wtype-limitswarning (part of-Wextra), and with it an error; the same code is silent on x64. - Windows on CI: MSYS2's gcc is newer than the maintainer's 13; a warning a newer gcc adds to
-Wall -Wextra(gcc 15 added-Wunterminated-string-initializationthere) fails the generated C under-Werror. - The conformance suite on POSIX:
Process.rungoes through/bin/shthere (runtime/README.md), so a program that cannot be started is the shell's exit code 127 and not a failure; a program whose expectation was recorded on Windows and depends on that differs. - The static musl binary calls
popenandforklike the glibc build, but it has never run; the smoke test is the first thing that exercises it. Name resolution (getaddrinfo) in a static binary is musl's own resolver, which reads/etc/resolv.confand/etc/hostsand nothing of NSS. - The network on POSIX:
io_test.cand the network programs of the conformance suite open sockets on the loopback; a hosted runner allows that, but the epoll and kqueue pollers, the non-blockingconnectand the resolver thread have only been reasoned about, never run.
What the first runs reported#
The first runs on the four targets (September 2026) failed on three of the items above and on three things nobody had expected; all of them are fixed:
- Item 7:
filesandprocess-run-inputprintedtrueon Linux and macOS where Windows printsfalse. The POSIX half ofProcess.runisforkplusexecvpnow, with no shell, so a program that is nowhere is a failure on every target. - Item 3:
Process.executablePath()answers on macOS, through_NSGetExecutablePathandrealpath(runtime/os/macos.c); FreeBSD still answersNone. - Item 6: MSYS2's gcc 16 refused the generated C with
-Warray-bounds("array subscript 'torb_text[0]' is partly outside array bounds of '_Bool[1]'"). It is a false positive about gcc's own speculation:-fspeculatively-call-stored-functions, new in gcc 16, guesses one function for every call through atorb_closure, and the guessed branch, which is never taken, reads aboolresult as the call's own type.torb.hturns the warning off for gcc 16 and later. - Not expected: tier B's
torb test --vmof the std packages ran out of the VM's 64 slots for counted element types once the text formats had joined. A descriptor the VM makes is told which one it is now (torb_contextual_element,runtime/element.c), so the VM makes as many as a program needs. - Not expected: the tests of the worker pool (
runtime/tests/pool_test.c) failed now and then on Linux (6 of 40 runs with clang 19), which the Windows machine had never shown: three races ofruntime/task.clet a task run on two workers at once or be freed while its start still queued it. After the fix, 0 of 100 runs with clang 19 and gcc 16. - Not expected: the Windows runner's Git checks files out with CRLF (
core.autocrlf), which put\rinto every multi-line string literal:interpolationprinted it, and the compiler's own C differed from the other targets' in theagreejob..gitattributesturns the conversion off (* -text).
Items 2, 4, 5 and 9 did not come up: macOS builds with the feature macros as they are, clang and the unsigned char of
arm64 add no warning to the generated C, and the network programs pass on every target.
What the owner set up on GitHub#
While GitHub carried the pipeline, this was the owner's list; section 14 and docs/contributing/releasing.md have the
forge's, which replaces it.
- The first seed, once, as above.
- Actions minutes: on a private repository hosted runners are billed, macOS at ten times and Windows at twice the
Linux rate, and
ubuntu-24.04-armis a free standard runner only for a public repository (for a private one it needs the plan's arm64 runners).ci.ymlalready keeps macOS and linux-arm64 to the changes that can affect them while the repository is private; making the repository public (question 8) makes every standard runner free and turns them on for every change. - Workflow permissions: Settings -> Actions -> General: "Read repository contents" as the default token permission (every workflow asks for more where it needs it), and allow GitHub Actions to create releases.
- The
releaseenvironment: created by the first release run; add required reviewers to it to hold every release for one click after the gates. - Tag protection: a ruleset on
v*,nightly-*andseedsso that only maintainers (and the Actions bot fornightly-*) can create them. - Branch protection on
main: require the checksgates / tier A (linux-x64),gates / bootstrap (linux-x64),gates / bootstrap and conformance (windows-x64)andgates / every target emits the same Cbefore a merge. - The package manager channels (
publish-packagesofrelease.yml, section 5): createTorbScript/homebrew-tapandTorbScript/scoop-bucket, each with an emptyFormula/orbucket/directory; submit the first winget manifest by hand withwingetcreate new(updateonly ever edits an existing one); addHOMEBREW_TAP_TOKEN,SCOOP_BUCKET_TOKENandWINGET_TOKENas repository secrets, each a fine-grained personal access token limited tocontents: writeof the one repository it pushes to (winget's token needs to open a pull request againstmicrosoft/winget-pkgs, so a classic token withpublic_repois whatwingetcreateitself documents). None of the three is required: the job skips a channel whose secret is absent (docs/contributing/releasing.mdhas the full list of one-time setup, includingrelease-sync's).
14. The forge#
Decided by the owner (2026-09-27): the repository, its CI, its seeds and its releases live on the project's own Forgejo; GitHub becomes a mirror and is not deleted. Built on the same day; the server side - the forge, its runner, the compose stack - is the owner's and existed before it.
git.torb.dev (Forgejo 16.0.5, SSH on port 2223) cr.torb.dev (the forge's container registry)
┌───────────────────────────────────────────────────────────┐ ┌──────────────────────────────────────────┐
│ torbscript/language the repository, .forgejo/workflows/ │ ─────> │ torbscript/site, release-sync, registry │
│ releases: v*, nightly-*, seeds (seeds.txt + archives) │ images └──────────────────────────────────────────┘
│ torbscript/homebrew-tap, torbscript/scoop-bucket │ │ pulled by
│ runner torb-runner-1: ubuntu-latest, ubuntu-24.04 (DinD) │ v
└───────────────────────────────────────────────────────────┘ the root server: torb.dev, packages.torb.dev
│ push mirror │ webhook "published" ──────────> release-sync -> /srv/torb/download
v
github.com/TorbScript/language .github/workflows/portable.yml: windows-x64, linux-arm64, macos-arm64What moved, and where it went#
| What | Before | Now |
|---|---|---|
| The repository | github.com/TorbScript/language (private) | git.torb.dev/torbscript/language (public); GitHub a push mirror |
| CI | .github/workflows/, GitHub's hosted runners | .forgejo/workflows/ on the forge's runner; the GitHub mirror runs portable.yml only |
| Seeds | the release seeds of GitHub | the release seeds of the forge; GitHub's is read only for a seed the forge lacks |
| Releases and nightlies | GitHub releases | releases of the forge, drafts until their last asset is there |
| Images | ghcr.io/torbscript/* | cr.torb.dev/torbscript/*, public |
| Signatures | keyless Sigstore, the workflow's identity | the project's cosign key (section 13, "Signing") |
| Downloads | release-sync pulled GitHub's releases | release-sync pulls the forge's; installers and manifests name torb.dev/download only |
| Trusted publishing | GitHub Actions | GitHub Actions and the Forgejo Actions of git.torb.dev (section 7.11) |
| Landing | tools/land.sh publish pushed to origin | it pushes to forgejo and publishes the seed to the forge |
Writing from a workflow: Authorized Integrations#
Forgejo ignores every job whose job or workflow has a permissions: key, and the token it gives a job
automatically (forgejo.token) is limited. A workflow of the forge that writes asks for an Authorized
Integration's token instead: the job sets enable-openid-connect: true, requests an ID token from
$ACTIONS_ID_TOKEN_REQUEST_URL with the integration's audience, and sends it as Authorization: Bearer - or as the
password of docker login and of git over HTTPS, which the forge accepts in both places. The forge checks it against
the integration's claim rules (the repository by its id, the workflow file, the ref, the event), and the request may do
what the integration's permissions allow, as the user who created it. .forgejo/actions/token does the request; the
audience is not a secret and lives in a repository variable. Forgejo 16 has no API to create an integration: the owner
creates both in the web interface (docs/contributing/releasing.md, "One-time setup"):
| Integration | Workflow file | Git reference | Events | Access | Permissions | Variable |
|---|---|---|---|---|---|---|
torbscript-publish | {release,nightly,seed,benchmarks}.yml | {refs/heads/main,refs/tags/v*} | push, schedule, workflow_dispatch | specific repositories: torbscript/language, torbscript/homebrew-tap, torbscript/scoop-bucket | repository: read and write | TORB_PUBLISH_AUDIENCE |
torbscript-images | {release,nightly,images}.yml | {refs/heads/main,refs/tags/v*} | push, schedule, workflow_dispatch | public only | package: read and write | TORB_IMAGES_AUDIENCE |
- Two integrations, not one: an integration limited to specific repositories may only hold the repository and issue permissions, and pushing an image is a package permission.
- The workflow file of a reusable workflow's job is the calling run's:
images.ymlruns insiderelease.ymlandnightly.yml, which is why those two are in the image integration's rule, andimages.ymlitself for a run by hand and for its own push trigger - the site ofmain(section 7.11), whose token namesimages.yml,refs/heads/mainand the eventpush, which the rule admits as it stands. - A reusable workflow says
enable-openid-connect: trueat its top level, not on its jobs, and the calling job says nothing. A calling job withoutruns-onis expanded by the forge into the called workflow's jobs, each a job of the calling run; the jobparser (runner 12.13.2, which Forgejo 16.0.5 builds in) re-reads each expanded job from a one-job workflow whose job type has no field for the key, so a job-levelenable-openid-connectof the called workflow is dropped, and the calling job's own key is read for the calling job alone, which runs nothing. What survives is the called workflow's top-level key. The first nightly's image jobs failed on exactly this ("this job cannot ask for an ID token") whileimages.ymlrun by hand had pushed its images the same day; parsingnightly.yml's call with that jobparser gives the three expanded image jobsfalsewith the key on the job andtruewith it at the top, and the top-level key serves the run by hand as well. The alternative -runs-onon the calling job, which the forge then leaves unexpanded (expandForJob, the behaviour from before the expansion) and whose runner runs the called workflow inside that one job, under the calling job's key - would put three image builds of up to two hours each into one job of the calling run. - The patterns are
gobwas/glob's, which the forge compiles when the integration is saved;{a,b}is an alternative,*crosses/. - An hour: a token lives
[actions] ID_TOKEN_EXPIRATION_TIME(an hour by default), so every job asks for its token right before the step that needs it, and the image job builds first and pushes from the build cache afterwards.
Runners#
The forge has one runner, torb-runner-1 (labels ubuntu-latest and ubuntu-24.04, the image
catthehacker/ubuntu:act-24.04, Docker in Docker, two jobs at a time). Decision: the jobs of the other tier 1
targets are declared in gates.yml and run only for the targets the repository variable TORB_RUNNERS names
(windows-x64 linux-arm64 macos-arm64, or any of them), on a runner whose label is the target's name. A job whose
label no runner has would wait for ever - "your pipeline will halt", the forge's documentation says - so each such job
reads the variable through a first job and has an if: on its output alone, which the forge evaluates itself and skips
before any runner is asked. Until those runners exist:
- A release carries their cross-compiled toolchains (section 13, "Cross-compiled archives", since 2026-09-29;
until then a release carried the linux-x64 toolchain only): the forge's runner builds them with a pinned
zig cc, checks them and starts them under wine and qemu-user, so the Homebrew formula and the Scoop manifest are rendered and winget's update is a command the release job prints. A stable release buildslinux/arm64images from its linux-arm64 toolchain; a nightly buildslinux/amd64ones only (images.yml). - The GitHub mirror runs the three targets after every push (
.github/workflows/portable.yml): bootstrap,check ., the conformance suite, the runtime's tests and the agreement of the C among them - as a report, not a gate, and only while the mirror is public, where GitHub's standard runners cost nothing. Every morning it also installs the newest nightly on the three machines and on linux-x64, withtools/install.shandtools/install.ps1from torb.dev, and checks, runs and builds a program with it: the first run of a cross-compiled binary on its own machine. - Cross-compiling was first considered and left (2026-09-27): windows-x64 with Debian's MinGW-w64 and linux-arm64 with a musl cross compiler would build binaries on the one runner, but nothing could run them there, and a binary no gate has run is not one to release; QEMU would make the compiler's 100 MB of C a matter of hours. What changed the decision two days later is section 13's "Cross-compiled archives": the binary is not bootstrapped under QEMU but cross-compiled, and only started there - wine and qemu-user start it in seconds and minutes - and the mirror runs the published nightly on the real machines.
GitHub as the mirror#
Decision: the forge pushes every change to GitHub through a push mirror, and .github/workflows/ keeps one
workflow, portable.yml; the six workflows that built, published and signed on GitHub are deleted, since two
pipelines publishing the same release would race. portable.yml reuses .forgejo/actions/portable (a composite
action works on both), reads no secret, publishes nothing and fetches the seed from the forge. Recommended over deleting
.github/ entirely because it is the only place windows-x64 and macos-arm64 run at all until the forge has runners -
and, since the forge cross-compiles their released binaries, the only place those run on their machines - and it costs
nothing once the mirror is public; recommended over keeping more because a job that could publish on the mirror would
need a secret there, which is what the mirror should never hold. The mirror's setup - a token with
Contents and Workflows write access, the forge's Settings -> Repository -> Mirror settings - is
docs/contributing/releasing.md. Releases are not mirrored; GitHub's old seeds release stays as an archive that
tools/fetch-seed.sh falls back to.
Security#
- No run of untrusted code on the runner. The runner has Docker, so a job can do almost anything to the runner's
machine: a pull request from a fork runs nothing (
ci.ymlskips every job when the head repository is not this one; a maintainer who wants CI for such a change pushes its branch into the repository), and the forge's own rule that a fork's workflow gets no secrets and no ID token stays as a second line. - Runners never touch the registry's data. No runner mounts
/srv/torbor joins the Docker network of the registry and release-sync; the root server pulls releases and images (section 7.11, "the root server pulls, CI never pushes"), and nothing of CI can write/srv/torb- a compromised job could at worst publish a release, which release-sync refuses unless its signature verifies. - Tokens are short and narrow. An integration's token lives an hour, admits one repository's named workflows, refs and events, and holds the repository or package permission only. The signing key is the one long-lived secret, read by the jobs that sign.
- Protected refs:
mainagainst force pushes and deletion, the tagsv*andseedsso that only the owner - and the integration, which acts as the owner - creates them. - Pinned actions: every
uses:names a commit.actions/checkoutandactions/cacheresolve through the forge's default actions URL (data.forgejo.org mirrors them with the same commits as github.com), the artifact actions are Forgejo's own forks atcode.forgejo.org-actions/upload-artifactv4 refuses to run anywhere but github.com.
What the forge's first runs reported (2026-09-27)#
The forge picked up .github/workflows/ci.yml first, since .forgejo/ did not exist yet: every job with a
permissions: key was ignored; with the key removed, checkout, the commit check and the change scope passed, the
runner reached the forge at https://git.torb.dev, and the bootstrap failed where tools/fetch-seed.sh asked GitHub's
private API for the seed (401) - the reason seeds come from the forge now - and actions/upload-artifact v4 refused
with "not currently supported on GHES", the reason for Forgejo's forks. The workflows of .forgejo/ pass
forgejo-runner validate (runner 13); they had not run on the forge when this was written.
What the first nightly reported (2026-09-28)#
Run 21 of nightly.yml (main at f67f6c34) published nightly-20260928 and failed all three image jobs, for two
reasons:
siteandrelease-sync: no ID token. The keyenable-openid-connectstood on the job ofimages.yml, which the forge drops when it expands a reusable workflow into the calling run (the bullet in "Writing from a workflow" above). It stands at the workflow's top level since, and.forgejo/actions/tokennames that place when it fails.registry: the releasedtorbcrashed in the image's build stage,torb build tools/registry/src/main.trb, about three seconds in, with a segmentation fault - the previous nightly's as well. Under gdb indebian:bookwormit died in musl'sfmt_fpwith the stack pointer 17 KB below the top of the main thread's stack, writing past it: the digit loop ofprintf("%g")never ended, because itslong doublewas NaN. Atfmt_fp's second call the x87 status word held a stack fault and all eight x87 registers were marked in use. The one function of the binary with MMX instructions was the compiler's ownlowerComparison(movq (%rsp),%mm0and twomovq2dq), which gcc 13.3 (Ubuntu 24.04,musl-gcc -static, asgates.ymlbuilds the release binary) chose for a 64-bit move and never followed withemms. An MMX instruction marks the x87 stack full, the next x87 load reads NaN, and musl formats every float withlong doubleon the x87 - so any order comparison of a type withCompare(<,>=on a record), followed by printing a float, killed the static binary; glibc'sprintfdoes not use the x87, which is why no bootstrappedtorbever showed it, andtorb doc std, the site image's one command, never took that path. Fifteen lines reproduce it intorb run. The fix is#pragma GCC target("no-mmx")at the top ofruntime/include/torb.h, for gcc on x86-64: it reaches every translation unit the compiler emits and every one of the runtime, whichever script compiles them -build-seed.sh,torb build, a user's program on a musl system - where a flag in one build script would have covered the release binary alone. Built again from the same seed with it, the release binary holds no MMX instruction and builds the registry indebian:bookworm. Three checks keep it: the release-binary step ofgates.ymlfails on any MMX instruction in the binary, andtools/smoke-test.shruns that comparison with floats and writes the C oftools/registrywith the release binary, before anything is published.