# Weft — full documentation (generated) # Weft documentation Everything here is real git. **Mirror** stops your CI waiting on clones. **Repos** gives you a repository per user or per agent session over REST. **Review** and **Workflows** land and check changes on either. Public repositories are free forever. ## Start here Three steps, in order. The first two take a minute each. 1. **Create an account.** Free, no card. Your namespace holds public repositories; a team gets an organization. [Sign up](/login?mode=signup) 2. **Make an organization and a token.** An organization is where a team or a private repository lives; a public mirror or repository can live in your own namespace. A token is what your CI and scripts sign in with. [Authentication](/docs/authentication/) 3. **Pick a quickstart.** Point CI at a mirror in five minutes, or make your first commit over REST. Both end with a clone. [Mirror in 5 minutes](/docs/quickstart-mirror/) · [First commit over REST](/docs/quickstart-repos/) ## Understand the system - [How serving works](/docs/how-serving-works/): why empty-disk nodes are fast - [Changes, OWNERS and landing](/docs/code-review/): per-commit review and the fast-forward land queue - [The freshness contract](/docs/freshness-contract/): never a silent stale miss - [Organizations and billing](/docs/billing/): what a seat is, and what a failed payment does not do ## Operate - [Webhooks](/docs/webhooks/) — inbound origin events, outbound push events - [Workflows](/docs/workflows/) — CI from a `.weft/*.yml` file, on our runners or yours - [Running a self-hosted runner](/docs/self-hosted-runners/) — the operator's side: the binary, a systemd unit, and how to isolate it - [CI integration](/docs/ci-integration/) — bring your own CI: sign a verdict onto the Checks tab - [Export & escape hatch](/docs/export/) — bundles, org-wide - [Metrics & usage](/docs/metrics/) — p50/p99, CSV, Prometheus - [Service limits](/docs/service-limits/) — the honest v1 envelope ## For agents Machine-consumable surfaces, kept current with the docs build: - [`/llms.txt`](/llms.txt) — index of this documentation - [`/llms-full.txt`](/llms-full.txt) — the full documentation as one file - [`/openapi.json`](/openapi.json) — the REST API, OpenAPI 3.1 # Point CI at a mirror in 5 minutes Weft Mirror is a read-only, provably-fresh copy of your origin repository, served from object storage. You point your CI **read** path at it; developers keep pushing to your origin exactly as before. ## 0. Before you start Three things, once, and each takes about a minute: 1. [Create an account](/login?mode=signup). Free, no card; your personal namespace holds public repositories. 2. Create an organization from the dashboard if the origin is private or the mirror is shared with a team; a public origin can be mirrored into your own namespace. Creating one saves a card and charges nothing, and stays free while everything in it is public; see [organizations and billing](/docs/billing/#creating-one). 3. [Mint a token](/docs/authentication/#minting-and-revoking-tokens) for your CI. It is the `$WEFT_TOKEN` in every example below. ## The short version: paste a URL In the dashboard, **New repository → Mirror an existing one**, paste `github.com/acme/widget`, and press *Check origin*. - **A public origin** mirrors immediately. No credentials, no app to install, nothing to configure — this is the whole flow. - **A private one** answers *"this looks private"* and offers **Connect GitHub**. You install the Weft app on the account, choose which repositories it may read, and come back to a list you pick from. The installation id is never shown or typed, and the connection is per organization — you do it once, not once per repository. The screen then follows the first sync — refs discovered, objects ingested — and ends on the clone command. If the sync fails it says why, on the same screen, instead of leaving a repository that just looks broken. The rest of this page is the same flow over the API, for CI and for scripting. ## 1. Check the origin first A mirror registered against a typo answers `202` and then fails minutes later, on a repo that looks broken. Ask first — it is one request and it takes about as long as the round trip to your forge: ```bash curl -X POST https://api.weft.sh/v1/orgs/acme/origins/probe \ -H "Authorization: Bearer $WEFT_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "origin": "github.com/acme/widget" }' ``` ```json { "reachable": true, "private": false, "default_branch": "main", "refs": 214, "reason": null } ``` `origin` takes whatever you have: a full git URL, `owner/repo`, a `git@host:owner/repo` remote, or the browser URL with `/tree/main` still on the end. It is normalised. The answer is `200` whether or not the origin turned out to be reachable — the *probe* worked either way, and the finding is in the body: | Answer | What it means | | --- | --- | | `reachable: true` | Mirror it. `refs` and `default_branch` are what we saw. | | `private: true` | It exists but wants credentials — connect GitHub and mirror it through the App. | | `reachable: false`, `private: false` | `reason` says why: not a git repository, no such host, an origin we will not fetch from. | **What this endpoint will not do.** It fetches a URL you supply, so it is `org:admin` only, `https` only, follows no redirects, refuses IP addresses, and refuses any hostname that resolves to a private, loopback, link-local or cloud-metadata address — checked on every address the name answers with, not on the name. It is rate-limited per org. If you are self-hosting and need to mirror from inside your own network, configure that origin with the operator CLI; this endpoint is deliberately not the way in. ## 2. Register the mirror With an org token (`repo:write` or admin): ```bash curl -X POST https://api.weft.sh/v1/orgs/acme/mirrors \ -H "Authorization: Bearer $WEFT_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "widget", "provider": "github", "origin": "acme/widget", "installation_id": "12345678" }' ``` `installation_id` is optional, and you can get one without ever reading a number off a settings page — see [Connecting GitHub](#connecting-github) below. Leave it out for a public origin. **Creation checks the origin.** Without an `installation_id`, the origin is probed before anything is created, and an unreachable one answers `422` with the probe attached rather than `202` and a failure minutes later: ```json { "error": "that origin is not reachable as a git repository", "probe": { "reachable": false, "private": false, "refs": 0, "reason": "…" } } ``` With an `installation_id` the check is skipped — a private origin refusing an anonymous probe is the expected answer, not a reason to refuse creation. Otherwise the response is `202 Accepted` and the initial ingest runs in the background. Follow it (a 10 GB repo completes in well under 30 minutes): ```bash curl -H "Authorization: Bearer $WEFT_TOKEN" \ https://api.weft.sh/v1/orgs/acme/repos/widget/sync-status ``` ```json { "state": "syncing", "origin": "acme/widget", "commit": null, "error": null, "clone_url": "https://api.weft.sh/acme/widget.git" } ``` `state` is `syncing` until the first sync finishes, then `ready`, or `failed` with `error` saying why. A mirror that synced before and failed since stays `failed`: it is serving stale content and somebody should know. For any other git host (or a public URL), use `"provider": "generic"` with a fetchable `origin` URL. ## Connecting GitHub A private origin needs a GitHub App installation. You connect one per organization, and the id stays out of sight. ```bash curl -X POST https://api.weft.sh/v1/orgs/acme/github/install \ -H "Authorization: Bearer $WEFT_TOKEN" ``` ```json { "url": "https://github.com/apps/stratum/installations/new?state=stinst_…", "state": "stinst_…", "expires_in": 600 } ``` Open `url` in a browser and install the app. GitHub sends you back to `/v1/github/setup`, which binds the installation to the organization that started the flow and redirects into the dashboard. That `state` is the whole security of the round trip: GitHub's callback carries no other proof of who began it, so the state is random, single-use, expires in ten minutes, and is stored only as a hash. A callback without a live one binds nothing — and every way of being wrong answers the same, so it cannot be used to probe which flows exist. Then list what you can mirror: ```bash curl -H "Authorization: Bearer $WEFT_TOKEN" \ https://api.weft.sh/v1/orgs/acme/github/installations curl -H "Authorization: Bearer $WEFT_TOKEN" \ "https://api.weft.sh/v1/orgs/acme/github/installations/4001/repos?per_page=100" ``` ```json { "repositories": [ { "full_name": "acme/widget", "private": false, "default_branch": "main", "description": "the public one", "size": 16384 } ] } ``` Pass the `full_name` as `origin` and the installation as `installation_id`, and the mirror is created against a repository you know that installation can read. **An installation belongs to exactly one organization.** Another organization asking about yours gets a `404`, and one trying to claim it is refused — an installation is a key to somebody's source, and two claimants would mean one organization reading another's code. ## 3. Install the webhook Point your origin's push webhook at: ``` POST https://api.weft.sh/webhooks/github ``` with your webhook secret. Pushes land on the mirror within seconds (p50 under 10 s); a 60-second poll is the loss-recovery floor, so a missed webhook never strands the mirror. ## 4. Switch the CI checkout ```yaml # before - run: git clone https://github.com/acme/widget.git # after - run: git clone https://x:$WEFT_TOKEN@api.weft.sh/acme/widget.git ``` Everything stock git does works: full clones, incremental fetches, `--depth 1` (served from a precomputed snapshot). Pushes to the mirror are rejected with a message naming your origin, so a misconfigured job can never fork your write path. ## What you get - **Provable freshness.** A fetch for a commit the mirror lacks triggers a synchronous origin sync before the response. See [the freshness contract](/docs/freshness-contract/). - **Outage behavior you can put in a runbook.** Origin down → last-known state serves, with `X-Weft-Staleness` on every response. - **The renewal artifact.** Per-repo clone p50/p99, bytes served, and requests absorbed at [`/v1/orgs/acme/repos/widget/metrics`](/docs/metrics/), JSON or CSV. # First commit over REST Weft Repos gives every user, session, or agent its own real git repository — created in under 100 ms, written and read entirely over HTTP. ## 0. Before you start Three things, once, and each takes about a minute: 1. [Create an account](/login?mode=signup). Free, no card; your personal namespace holds public repositories. 2. Create an organization from the dashboard if the repositories will be private or shared with a team. Creating one saves a card and charges nothing, and stays free while everything in it is public; see [organizations and billing](/docs/billing/#creating-one). 3. [Mint a token](/docs/authentication/#minting-and-revoking-tokens) for your scripts. It is the `$WEFT_TOKEN` in every example below. ## 1. Create a repo ```bash curl -X POST https://api.weft.sh/v1/orgs/acme/repos \ -H "Authorization: Bearer $WEFT_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "session-8412" }' ``` Response: `201` with the repo and its `clone_url`. Need a fleet? Batch up to 1,000 per call at `/v1/orgs/acme/repos/batch/create`. ## 2. Commit ```bash curl -X POST https://api.weft.sh/v1/orgs/acme/repos/session-8412/commits \ -H "Authorization: Bearer $WEFT_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "branch": "main", "message": "agent step 1", "context": { "agent_run": "r-42", "prompt": "p-991" }, "operations": [ { "op": "put", "path": "src/app.js", "content": "console.log(1)\n" }, { "op": "put", "path": "README.md", "content": "# session\n" } ] }' ``` The response carries the new `commit` id. The `context` blob lands in the immutable [audit trail](/docs/audit-and-undo/) alongside the acting token — that's how you answer "what did the agent change and when" months later. **Concurrency:** pass `expected_parent` with the commit you built against. If the branch moved, you get `409` with the current tip — rebase and retry. Commits are durable at acknowledgment. ## 3. Read anything at any version ```bash # newest version (ETag = content hash; If-None-Match gives you 304s) curl -H "Authorization: Bearer $WEFT_TOKEN" \ https://api.weft.sh/v1/orgs/acme/repos/session-8412/files/src/app.js # the same file two commits ago curl -H "Authorization: Bearer $WEFT_TOKEN" \ "https://api.weft.sh/v1/orgs/acme/repos/session-8412/files/src/app.js?at=$OLD_COMMIT" ``` Also available: `/tree` listings (each entry carries a `size`, `null` for directories), `/diff?from=…&to=…`, paginated `/log`, `/refs` for everything at once, and `/branches` and `/tags` when you want one kind, sorted, with the default marked. ### One file's history `/log` takes a `path`, and then returns only the commits that changed it: ```bash curl -H "Authorization: Bearer $WEFT_TOKEN" \ "https://api.weft.sh/v1/orgs/acme/repos/session-8412/log?path=src/app.js" ``` ```json { "entries": [ { "commit": "9f2c…", "message": "fix the parser", "change": "modified", "author": "Ada 1766000000 +0000", "parents": ["7b1a…"] } ], "next_after": null } ``` `change` is `added`, `modified` or `deleted` — what that commit did to that path. It appears only on a filtered request, because an unfiltered walk says nothing about any particular file. Do this rather than asking for the whole log and dropping rows yourself. Both give the same answer on a small repository; on a real one, a file touched once near the start means downloading an entire history to find a single commit. A filtered request examines at most 500 commits and then hands back `next_after`, so a long search is several bounded requests instead of one unbounded scan. A `/files` response says what it is rather than leaving you to guess: `X-Weft-Binary` is `true` or `false`, `X-Weft-Commit` is where the content came from, and `ETag` is the blob oid — send it back as `If-None-Match` and an unchanged file costs a 304 and no bytes. `Content-Type` is sniffed, and deliberately narrow. Anything textual is `text/plain; charset=utf-8` whatever it is called: this endpoint returns whatever somebody committed, and answering `text/html` for a file named `index.html` would let a repository serve script from this origin. Images and PDFs get their real type, because a browser can display those and cannot be tricked by them; everything else is `application/octet-stream`. **All of this is in the dashboard too.** Open a repository and press *Browse files* — directory listings, a file view with line numbers, the commit log and a branch switcher, all on real URLs, so a link to a line of code is a link you can send somebody. A file page carries its own history: who last touched it, what each commit did to it, and a click to read any earlier version — which puts that revision in the URL, so an old version is as sendable as the current one. ## 4. Undo ```bash # put the branch back where it was before the agent went sideways curl -X POST https://api.weft.sh/v1/orgs/acme/repos/session-8412/reset \ -H "Authorization: Bearer $WEFT_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "branch": "main", "to": "'$GOOD_COMMIT'", "expected_head": "'$BAD_COMMIT'" }' ``` The undone commits stay reachable by SHA until garbage collection — undo never erases the record. See [audit & undo](/docs/audit-and-undo/). ## 5. It's still git ```bash git clone https://x:$WEFT_TOKEN@api.weft.sh/acme/session-8412.git ``` Clone it, push to it, or [export it as a standard bundle](/docs/export/) any time. Adopting Weft is not a lock-in decision. # Authentication There are two kinds of caller, and the difference decides everything else. A **person** signs in with an email address and a password and gets an HttpOnly session cookie. That is how the dashboard works; no script on the page can read the credential. A **machine** — CI, a script, `git` itself — sends a bearer token of the form `weft__`. Only a hash of the secret is stored; the plaintext is shown exactly once at mint time. Both resolve to the same authority model, so every endpoint accepts either. When both are presented the bearer token wins, so a developer with the dashboard open in the same browser can still test a token by pasting it into a request and get *that* token's authority. ## People, roles and orgs A person belongs to one or more orgs, at one role in each: | Role | Can | |------|-----| | `viewer` | read every repo in the org | | `member` | everything a viewer can, plus push, commit and create repos | | `admin` | everything, including managing people and credentials | | `owner` | the same as admin; an org must always have at least one | An org can never be left without an owner: removing or demoting the last one answers `409`. A **per-repo grant** replaces the org role on one repo — in either direction. Granting `member` to a viewer opens exactly that repo; granting `viewer` to an admin holds them down on exactly that repo, and nowhere else. One call can name several people at once: ```bash curl -X POST https://api.weft.sh/v1/orgs/acme/repos/widget/grants \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "user_ids": ["01hx…", "01hy…"], "role": "member" }' ``` A batch is all-or-nothing. If any id in it is not a member of the org, nothing is granted — a half-applied change is one you cannot reason about afterwards. ## Teams "The payments squad can write here" is one statement about the org. Saying it person by person means it drifts the moment somebody joins, so a **team** can be granted a role on a repo directly: ```bash curl -X POST https://api.weft.sh/v1/orgs/acme/teams \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "payments", "description": "the squad" }' curl -X PUT https://api.weft.sh/v1/orgs/acme/teams/$TEAM/members/$USER \ -H "Authorization: Bearer $ADMIN_TOKEN" curl -X POST https://api.weft.sh/v1/orgs/acme/repos/widget/grants \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "team_id": "'"$TEAM"'", "role": "member" }' ``` **A team grant only ever raises.** Being in a team is how people get more access on a repo; it is never how they quietly lose some, because nobody reads a team's grant list before adding a colleague to it. Lowering somebody stays a deliberate, per-person act. So three rules decide what you can do on a repo, in this order: 1. A grant naming **you** — that role, outright, up or down. 2. Otherwise the **highest** of your org role and every team grant on that repo. Two teams disagreeing takes the higher. 3. No org membership at all — no access, whatever the grants say. Team membership requires org membership, so a grant can never outlive it. Deleting a team withdraws its membership and every grant it carries, on the very next request. Teams are named per org, case-folded, so `Payments` and `payments` cannot both exist. Which rule applied to whom is a question worth answering directly, and `GET …/repos/:repo/access` answers it — every person who can reach the repo, their role there, and where it came from: ```bash curl -H "Authorization: Bearer $ADMIN_TOKEN" \ https://api.weft.sh/v1/orgs/acme/repos/widget/access ``` ```json { "people": [ { "email": "dev@acme.dev", "role": "member", "source": "team", "team_name": "payments" } ], "teams": [ { "team_name": "payments", "role": "member", "member_count": 4 } ] } ``` In the dashboard the same two things are **Settings → Teams** and the **Access** panel on a repo. People join by invitation, which is **emailed** when a mail transport is configured. The link works once and expires after seven days: ```bash curl -X POST https://api.weft.sh/v1/orgs/acme/invites \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "email": "dev@acme.dev", "role": "member" }' ``` ```json { "id": "01hx…", "email": "dev@acme.dev", "role": "member", "expires_at": 1787428539000, "invite_link": "stinv_01hx…_…", "mail": { "sent": true } } ``` The link comes back **as well as** being sent. A relay that is down, or a server with no transport configured, must not stop you onboarding somebody — so `mail.sent` tells you whether to deliver it yourself, and `mail.error` says what went wrong when something did. `sent: false` with no `error` means no transport is configured. The link lands on a screen that says what is being joined. It asks the server first: ```bash curl -X POST https://api.weft.sh/v1/auth/invite/preview \ -H "Content-Type: application/json" \ -d '{ "invite": "stinv_01hx…_…" }' ``` ```json { "org": "acme", "role": "member", "email": "dev@acme.dev", "expires_at": 1787428539000 } ``` No credentials: the token in the body **is** the credential, so this tells its holder nothing they were not already sent. It is a question rather than an action — previewing does not spend the link — and every dead shape (malformed, unknown, expired, already accepted, wrong secret) answers the same 404, so a link cannot be used to ask which invitations exist. It is a POST, not a GET, because a token in a path or query lands in every access log between the browser and here. ### Configuring mail `STRATUM_MAIL_TRANSPORT` picks one of four, and `STRATUM_MAIL_FROM` is the sender address for all but the first two. | Transport | What it does | Also needs | |---|---|---| | `null` (default) | drops every message | — | | `capture` | writes each message to a file, for local development and tests | `STRATUM_MAIL_DIR` | | `smtp` | delivers through a relay | `STRATUM_MAIL_SMTP_HOST` (`host` or `host:port`) | | `ses` | Amazon SES v2, signed with the instance's own credentials | `STRATUM_MAIL_SES_REGION` (defaults to `AWS_REGION`) | A name that is none of those is a **boot failure**, not a silent fallback to dropping mail: a typo in a deployment variable must not look like a working system. **SMTP has no STARTTLS.** Nothing in the server links a TLS client library, so this transport speaks cleartext — which is the normal self-hosting arrangement (a relay on `localhost`, or a sidecar on a private network) and fine until credentials are involved. Setting `STRATUM_MAIL_SMTP_USER` and `STRATUM_MAIL_SMTP_PASSWORD` for a **non-loopback** host is refused at boot unless you state that the link is already private with `STRATUM_MAIL_SMTP_ALLOW_CLEARTEXT_AUTH=1`. For hosted deployments use `ses`, which is HTTPS. ## Signing yourself up ```bash curl -X POST https://api.weft.sh/v1/auth/signup \ -H "Content-Type: application/json" \ -d '{ "email": "you@example.dev", "name": "Your Name", "password": "a long enough password", "handle": "you" }' ``` The **handle** is your personal namespace — the `you` in `/you/repo`. It is asked for rather than derived from your address, because it appears in every clone URL you ever hand out. It is validated and its refusals are plain (`400` for a bad shape or a reserved word, `409` for one already taken): a namespace name is a public URL, so "that one is taken" is not a secret. Everything after the handle is **uniform**. Signup always answers `202` with the same body, whether a confirmation message was sent, the address already has an account, or you have asked too many times — any difference would be a way to ask who has an account here. When the address is already registered, its owner gets a message saying so and that nothing was created; that way somebody who forgot they had an account is not left staring at "check your email" with an empty inbox. Rate limits are per address and global, not per source: behind a proxy this server does not see a peer address it can trust, and a limit that `X-Forwarded-For` can bypass is worse than an honest global one. ### Confirming, and what is blocked until you do ```bash curl -X POST https://api.weft.sh/v1/auth/verify \ -H "Content-Type: application/json" -d '{ "token": "weftv_…" }' ``` Confirming signs you in — somebody holding a link from their own inbox has proved as much as the sign-in form asks for. An unconfirmed account **may sign in, look around, and read whatever its role allows. It may not create a repository or a mirror.** That is the line: everything cheap stays open, everything that costs storage or an outbound fetch does not. `POST /v1/auth/resend-verification` sends another link, and issuing one spends the previous one. **Service tokens are exempt.** A token with no user behind it was minted by somebody who is confirmed, and every token minted before addresses were proved at all is one of these — CI does not stop working because a colleague has not read their email. ### Forgotten passwords ```bash curl -X POST https://api.weft.sh/v1/auth/forgot-password \ -H "Content-Type: application/json" -d '{ "email": "you@example.dev" }' curl -X POST https://api.weft.sh/v1/auth/reset-password \ -H "Content-Type: application/json" \ -d '{ "token": "weftrs_…", "new_password": "a different long password" }' ``` A reset link lives for an hour (a confirmation link, for a day), works once, and **ends every other session on the account** — whoever asked for it may have done so because somebody else was signed in. It also counts as proof of the address, so it finishes a signup that was abandoned. A verification link presented to the reset endpoint is refused, and the reverse too: the token's row says what it is for. A disabled account gets no reset link. Recovering an account an operator switched off would undo the switching off. The very first account is created from the command line, because there is nobody yet to invite it: ```bash stratum-server admin user-create --org acme \ --email you@acme.dev --name "Your Name" --password '…' --role owner ``` ## Minting and revoking tokens A signed-in person mints a **personal access token** for themselves — no administrator needed: ```bash curl -X POST https://api.weft.sh/v1/orgs/acme/tokens \ -b "$COOKIE_JAR" -H "Content-Type: application/json" \ -d '{ "scopes": ["repo:write"], "label": "laptop" }' ``` A personal token carries your authority *now*, not the authority you had when it was minted. The scopes you mint it with are a **ceiling**; what it actually does on a given repo is that ceiling intersected with your effective role there. So: - Demote yourself to viewer and the token in your hand stops writing on the next request. Nothing has to be hunted down and revoked by hand. - Get granted `member` on one repo and the same token starts pushing to that repo — and to nothing else. A grant that could only ever take access away would be a one-way ratchet. - Get promoted to admin and the token stays what it was minted for. The ceiling never rises. You may mint any scope you can exercise *somewhere* in the org — your org role, a per-repo grant, or a grant to a team you are in, if one gives you more. A viewer with a `member` grant on one repo can hold a `repo:write` token; it writes there and reads everywhere else. Asking for more than that is refused at mint time, because a credential that silently does less than it says is worse than no credential. An org **service token** belongs to nobody and is the right shape for CI. Minting one needs `org:admin`: ```bash curl -X POST https://api.weft.sh/v1/orgs/acme/tokens \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "scopes": ["repo:read"], "repo": "widget", "label": "ci-runner" }' ``` Revocation is instant — every request verifies against the control plane, so a revoked token fails on its very next use: ```bash curl -X DELETE https://api.weft.sh/v1/orgs/acme/tokens/$TOKEN_ID \ -H "Authorization: Bearer $ADMIN_TOKEN" ``` `GET /v1/orgs/acme/tokens` lists tokens without their secrets — every token in the org for an admin, your own for a member. Somebody else's token answers `404` rather than `403`, so a member cannot use revocation to discover which token ids exist. ## Scopes | Scope | Grants | |-------|--------| | `repo:read` | clone/fetch and all read endpoints | | `repo:write` | everything in `repo:read`, plus push, commits, refs, repo create/delete | | `org:read` | listings, metrics, usage, audit queries | | `org:admin` | everything, including token management | A token minted with `"repo": ""` is **bound to that repo**: it can't touch any other repo and can't perform org-level operations. This is the right shape for per-runner and per-agent credentials. ## On the git wire git sends credentials over HTTP Basic; put the token in either field: ```bash git clone https://x:weft_…@api.weft.sh/acme/widget.git ``` Unauthenticated requests to private resources answer `401` (so git retries with credentials); valid credentials without access answer `404` — one org can never learn what exists in another. Repos created with `"public": true` allow anonymous reads, and reads with **any** valid credential: a token minted in your own namespace clones and fetches a public repository in somebody else's, which is how a [fork](/docs/forks/) is kept current with its upstream. The REST API keeps the same promise, and a *person* reads as themselves: a browser session or personal token with no role in the org still holds `repo:read` on a public repository, so what they do there — open a change from their fork, comment on it, tick the files they have read — is attributed to them rather than to nobody. A service token from another organisation reads a public repository the way anyone does, anonymously. A push by somebody who can read a repository but not write to it is refused with the reason — `you can read acme/widget but not push to it; fork it and open a change from your fork, or ask an owner for write access` — as an in-band error on the advert (git prints it as `remote error:`) and as `403` on the RPC itself. Only a repository you cannot read at all is masked as `404`. ## SSH keys Deployments that expose the SSH front door also accept `git clone ssh://git@host:port/acme/widget.git`, authenticated by public key instead of a pasted token. A key never carries permissions of its own; it names something that does, and the row says which. A **personal key** names you. Add it from the dashboard's Settings → SSH keys, or over the API while signed in — no token id anywhere: ```bash curl -X POST https://api.weft.sh/v1/orgs/acme/ssh-keys \ -b "$COOKIE_JAR" -H "Content-Type: application/json" \ -d "{ \"public_key\": \"$(cat ~/.ssh/id_ed25519.pub)\", \"label\": \"laptop\" }" ``` Its authority is re-resolved from your role on every connection, per-repo grants included. An administrator changes your role and the next `git push` from that laptop obeys it — there is no key to re-issue and nothing cached to expire. A **deploy key** names a token instead, which is what an unattended machine wants. It inherits that token's scopes and repo binding, and creating one needs `org:admin`: ```bash curl -X POST https://api.weft.sh/v1/orgs/acme/ssh-keys \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d "{ \"public_key\": \"$(cat deploy.pub)\", \"token_id\": \"$TOKEN_ID\", \"label\": \"ci\" }" ``` Either way, revoking the key (`DELETE /v1/orgs/acme/ssh-keys/$KEY_ID`), the token, the membership or the account cuts SSH access on the very next connection — the fingerprint is resolved against the control plane every time, never cached. `GET /v1/orgs/acme/ssh-keys` lists keys with their OpenSSH SHA-256 fingerprints (compare with `ssh-keygen -lf`): every key in the org for an admin, your own for a member. Accepted key types: ed25519, ECDSA (P-256/384/521), and RSA. # Org settings vs repo settings Weft has two rings of authority, and every setting lives in exactly one of them. **Org settings** shape the organization: who is in it, what they may do everywhere, and which credentials exist. **Repo settings** shape one repository: where its trunk is, what is fenced, who is raised or held down *here*. Nothing is configured in both places, so there is never a question of which copy wins. ## The roles, and where they reach An org role is the default answer everywhere; a repo grant rewrites the answer for one repo only. | Role | Org settings | A repo, by default | |---|---|---| | **Owner** | everything, and cannot be removed last | admin | | **Admin** | everything except removing the last owner | admin | | **Member** | read the org, mint personal tokens | read + write | | **Viewer** | read the org, mint read-only tokens | read | Two override rules, deliberately different: - A **direct grant** naming a person on a repo **replaces** their org role there — up *or down*. An org admin held to `viewer` on one repo cannot delete it; a viewer granted `admin` on one repo runs its settings. - A **team grant** only ever **raises**. Joining a team never takes access away, so team membership is safe to automate. "Repo admin" below always means *effective role at that repo* — an org admin anywhere, or whoever a grant raised to admin right there. ## Who controls what | Setting | Ring | Who changes it | Audit action | |---|---|---|---| | Members, org roles, invites | org | org admin | `member.*`, `invite.*` | | Teams and their rosters | org | org admin | `team.*` | | Service + personal tokens | org | admin; anyone for their own personal token | `token.*` | | SSH keys | org | admin; owners of the key's token | `sshkey.*` | | Plan and billing | org | org admin | `billing.*` | | Repo create / delete | repo (born in the org) | writer creates; repo admin deletes | `repo.create`, `repo.delete` | | Visibility (public) | repo | set at creation | `repo.create` | | **Default branch** | repo | repo admin | `repo.default_branch` | | **Branch protections** | repo | repo admin | `repo.protect`, `repo.unprotect` | | Access grants (people, teams) | repo | repo admin | `grant.*` | | Webhooks | repo | repo admin | `webhook.*` | | Mirror origin & sync | repo | repo admin; default branch follows origin HEAD | `mirror.*` | | OWNERS rules | repo, in the tree | whoever review lets touch `OWNERS` files | it's a commit — reviewed like code | The last row is the point of the design: *who must approve what* is not a setting at all. It lives in `OWNERS` files inside the repository, so changing the rules takes a change, with a diff, through the same review it governs — see [Changes, OWNERS & landing](/docs/code-review/). ## Where each ring lives in the dashboard - **Settings** (top right) is the org ring: Members, Teams, Tokens, SSH keys, Activity, Password. - **A repo's overview** is the repo ring: clone URLs, metrics, the Access panel (grants), and Branch policy (default branch and protections). Mutating forms appear only for people whose effective role at that repo is admin — everyone else sees the fence, not the gate. ## The three rules underneath 1. **Masking**: a credential that cannot touch a thing cannot see it — settings answer `404` to the wrong ring, never "forbidden", so existence leaks nothing. 2. **Every authority move is audited**, in the same transaction that makes it, under the actions in the table — "who unprotected main on Thursday" is one filtered query. 3. **Half-configured is refused loudly.** A setting that must exist to act (a default branch that isn't a real branch, a protection on a branch that doesn't exist) is rejected at the door, in words. # How serving works Weft's nodes hold no repositories. Everything lives in S3-compatible object storage as immutable, precomputed artifacts; serving a clone is choosing byte ranges, not building packs. ## The layout At ingest, a repository's history is packed offline into: - **Cold segments** — the bulk of history, ordered *path-major* (each file's versions adjacent), cut into ~64 MB segments, each self-contained: deltas never cross a segment boundary. - **Hot emissions** — one thin pack per recent mainline commit (~1,024-commit window), pre-deltified against the past. The manifest's *spine* records where each commit's bytes start. - **A snapshot artifact** — the tip commit plus its full tree, self-contained, so `--depth 1` CI clones need no graph work at all. - **A locator** — a sorted table mapping any object id to its exact byte range plus a precomputed delta-resolution plan, so single-file reads take a handful of parallel range GETs. - **The manifest** — one small JSON object that is the single source of truth for refs and stream composition. It changes only by compare-and-swap. ## Serving A **clone** is `concat(pack header, segment byte ranges, sha1 trailer)` — streamed straight from object storage through the node. No delta search, no pack-objects, no lock: this is why one clone costs a third (up to 1/73rd on pathological repos) of the server CPU stock git needs, and why any node can serve any repo. A **fetch** finds the newest commit you already have on the spine and streams the byte *suffix* after it — pre-deltified against exactly the objects you hold. Single-commit fetches are routinely 40× smaller on the wire than what bitmap-serving git ships. Every produced clone must pass `git fsck --full --strict`; that gate runs in our CI on every change and has never been waived. ## Writes A push (or API commit) is verified in quarantine, appended to a write-ahead log as an immutable object, and committed by one conditional PUT on the manifest — concurrent writers serialize on the store's compare-and-swap, and readers always see a complete state. A background compactor folds the log into fresh segments; epochs left behind are garbage-collected after a grace window longer than any running clone. ## What this buys you - **Dormant repos cost object-storage pennies** — no hot replica, ever. - **Nodes are stateless and interchangeable** — capacity is a scaling group, not a data migration. - **Bursts don't serialize** — immutable data plus per-request state means a hundred simultaneous CI clones of one repo behave like one. # Changes, OWNERS & the land queue Review on Weft is per commit, not per branch. A **change** is one commit's review identity; its content moves through numbered **patchsets**; approvals attach to a patchset; and landing goes through a queue that only ever fast-forwards your target branch — through the same compare-and-swap every push uses, so a landing and a racing push cannot corrupt each other. ## Change identity: the Change-Id trailer A change is keyed by a `Change-Id` trailer in the commit message's last paragraph, the same convention Gerrit's commit-msg hook writes: ``` adjust the fee schedule Change-Id: I8f3a2c94e1b7d605 ``` Install the standard hook once and every commit gets one: ```bash curl -Lo .git/hooks/commit-msg https://gerrit-review.googlesource.com/tools/hooks/commit-msg chmod +x .git/hooks/commit-msg ``` Amend or rebase the commit and the trailer rides along, so the change keeps its identity and the new commit becomes the next patchset. A commit **without** a trailer still gets a change — keyed `g` — but that identity dies the moment the commit is rewritten: an amend mints a new change. If you revise your work, use the trailer. ## Registering and revising Push a branch, then register its tip: ``` POST /v1/orgs/{org}/repos/{repo}/changes { "from": "feature", "target": "main" } ``` `201` carries the change and its patchset. Re-registering the same commit acks with `200` instead of duplicating; a new commit under the same Change-Id becomes patchset N+1, and the change's title follows the newest message. A change that is landing, landed or abandoned refuses new patchsets with `409` and the state in the error. ### What changed since I last looked Coming back to revision 4 of a forty-file change, the question is never "what does this change do" — you read that last week — it is "what moved since". Ask for it directly: ``` GET …/changes/{change}/interdiff?from=1&to=3 ``` `from` and `to` are patchset **numbers**. Each is resolved to the commit it was recorded at and the two trees are diffed, which is not the same thing as diffing the newest patchset against its parent: a file touched in patchset 2 and put back in patchset 3 is in *that* diff and is correctly absent from this one. Entries come back in the same shape as [`…/diff`](/openapi.json) — `{status, path, old_oid, new_oid, …}` — so whatever renders one renders this, and the commit oids are echoed as `from`/`to` beside the numbers you asked for as `from_patchset` / `to_patchset`. Reversed ranges are fine; `from=3&to=1` shows what going back would undo. Unknown patchset numbers are `404` and a range from a patchset to itself is `400`, both in words. The left-hand side does not have to be guessed. The viewed-marks read carries it: ``` GET …/changes/{change}/views → { patchset, since, viewed: [...] } ``` `since` is the newest patchset you have marked *anything* viewed at — the revision your last pass was actually against — so the natural request is `from=since&to=patchset`. It is `null` when you have marked nothing, and that means "no last pass", never "patchset 1": with no `since`, show the whole change rather than inventing a range over code nobody read. **Be clear about what this is not.** It is a two-commit tree diff between the patchsets as they were pushed. If a patchset was rebased, everything trunk picked up in between is in the result too — this is not a rebase-aware three-way interdiff in Gerrit's sense, which subtracts that move. And nothing migrates comment anchors across the range: comments stay pinned to the patchset they were written against, which is exactly the property that keeps them honest. ## OWNERS: who must approve what Put an `OWNERS` file in any directory: ``` # payments needs a payments person alice@acme.dev @payments # a team, resolved to its current roster set noparent # stop inheriting owners from parent directories ``` | Entry | Meaning | |---|---| | `person@example.com` | this org member owns the subtree | | `@team-name` | any current member of the team owns it | | `*` | anyone with write access to the repo may approve | | `set noparent` | do not inherit entries from parent directories | | `# …` | comment, full-line or trailing | Rules inherit by default: the owners of `a/b/f.rs` are the union of `a/b/OWNERS`, `a/OWNERS` and the root `OWNERS`, walking deepest-first and stopping at `set noparent`. Entries resolve against the org's live membership at evaluation time — someone joining a team changes the next verdict, with no file edit. A path no `OWNERS` file governs needs one approval from anyone with write access. A malformed `OWNERS` file fails closed: the paths it governs are blocked until the file is fixed, and the verdict names the file and line. The rules are read from the **target branch's tip**, not from the patchset. The paths under judgement are the patchset's diff; the `OWNERS` files that judge them are the ones already on trunk. A patchset that deletes or rewrites an `OWNERS` file therefore needs the approval of the owners it is removing, and the same reading decides who is notified of the change. Only when the target branch does not exist yet is there no trunk to ask, and the patchset's own files are used. `GET …/owners?path=

&at=` shows the effective rule chain for one path; `GET …/owners/check?from=&to=&approvers=a@b.c` previews a diff — "would these approvals suffice?" — before anyone clicks anything. ## Approvals and the verdict Approvals attach to the **latest patchset**. Push a revision and the count starts over: an approval of yesterday's patchset says nothing about today's. Approving takes a person (session or personal token); a service token is refused with `403`. Anyone who can read the change may approve it, including somebody with no role in the org reading a public repository — but sufficiency counts write access, so an outsider's approval is recorded and moves nothing. ``` POST …/changes/{change}/approve approve the latest patchset DELETE …/changes/{change}/approve take your approval back GET …/changes/{change}/verdict landable, and exactly why or why not ``` The verdict is landable when, for every changed path, at least one approver satisfies that path's rule. Every answer comes with an explanation per path, in words: ``` blocked: needs an owner of /payments/gateway.rs (owners: alice@acme.dev, @payments) ok: /payments/gateway.rs approved by alice@acme.dev ``` ### Who the change is waiting on The verdict also names the people OWNERS requires, resolved through teams to actual accounts, with whether each has already approved the latest patchset: ```json "reviewers": { "required": [ {"user_id": "01…", "name": "Alice", "email": "alice@acme.dev", "approved": true}, {"user_id": "01…", "name": "Casey", "email": "casey@acme.dev", "approved": false} ], "anyone_with_write": false } ``` Two empty results mean opposite things, so the flag is not decoration. `required: []` with `anyone_with_write: true` means a `*` rule governs the path: anybody with write access satisfies it, so nobody in particular is required. `required: []` with `anyone_with_write: false` means no OWNERS rule governs what this patchset touches at all. **Nobody nominates this list.** There is no "request a review from" call, and there will not be one — the whole point is that the set is derived from the OWNERS files on the target branch, so it cannot be quietly wrong about who is required. A change's reviewers are a fact about the paths it touches, not a guess somebody made when they opened it. That is also why the list expands teams: `@payments` in an OWNERS file is a rule, and the people it resolves to are the answer. A client cannot do that expansion, so the server does it here. ## Finding the changes that matter A busy repository's change list is a wall. Both lists take a `?q=` query — the same text the dashboard's query bar holds, so every filter is a URL you can paste into a review thread: ``` GET …/repos/{repo}/changes?q=is:open author:@me GET /v1/orgs/{org}/changes?q=needs:my-approval repo:api ``` Terms are whitespace-separated `name:value`, and there are four: | Term | Means | |---|---| | `is:open` `is:landing` `is:landed` `is:abandoned` | the change's state | | `author:@me`, `author:` | who opened it | | `needs:my-approval` | it cannot land, and **you** are why | | `repo:` | one repository, org-wide list only | **An unrecognised term is a `400` that names it.** Nothing is silently ignored, and that is deliberate: a filter that quietly does nothing is how somebody reads an unfiltered list of forty changes, concludes none of them is theirs, and closes the tab — and the same URL, shared, would then mean something different to whoever opened it next. A word with no colon is not a term either; there is no free-text search here to fall back on. Two terms are about a person, so `author:@me` and `needs:my-approval` need one: anonymous is `401` and a service token is `403`. An address that names no account here is an empty page rather than a refusal — this list is readable by strangers, and it is not an address oracle. `?state=` still works and is the same filter as `is:`; given both, they must agree. ### `needs:my-approval` This is the term no other forge can answer, and the reason is structural. On GitHub a reviewer is *nominated*, so "waiting on me" can only mean somebody typed your name. Here the reviewer set is **derived** from the OWNERS files governing the paths the patchset touches, so the question has an answer before anybody has done anything at all. It means all four of these, and each one drops changes that would otherwise make the list untrustworthy: - the change is still `open`; - it is **not yet landable** — one that already has what it needs has stopped waiting for you; - OWNERS *requires* you for a path it touches. Not "you have write access": where OWNERS says `*`, or governs nothing, anybody with write may approve and **nobody is required**, so those changes are not here. Otherwise every change to an ungoverned file would land in front of every writer in the organisation, which is the difference between a list people keep and one they filter away; - you have not already approved this patchset. A change you have signed off but that is still blocked on somebody else has stopped waiting for you too. It is the expensive term — it resolves the target branch's OWNERS tree once per change examined — so its page is capped at 50 rows examined per request, and it is the one query where a page can come back short, or empty, with `next` still set. Keep walking. ### Paging Both lists are keyset-paginated. The response carries a `next`; pass it as `?after=`: ```json { "changes": [ … ], "next": "01JB…" } ``` ``` GET …/changes?q=is:open&limit=50 GET …/changes?q=is:open&limit=50&after=01JB… ``` `next` is `null` at the end. It is a cursor and not an offset, and the difference is correctness rather than speed: changes arrive at the *top* of this ordering, so `offset=50` after somebody opens one walks past a row that has shifted down into it and you never see that change at all. A cursor names a position in the data, so a change opened mid-walk displaces nothing — it simply sorts above where you already are. Treat the value as opaque; one this list did not mint is a `400`. With `needs:my-approval` the cursor is where the *query* stopped, not where the surviving rows did — which is why a short page still hands you one. ## Landing ``` POST …/changes/{change}/land 202 — queued GET …/land-queue what is landing right now POST …/changes/{change}/abandon close without landing ``` The land request prechecks the verdict (`409` with the explanation when blocked) and enqueues. The lander then re-verifies sufficiency at claim time, proves the patchset fast-forwards from the target branch's current tip with a bounded ancestry walk, and promotes the ref by compare-and-swap. Landing a stack's top lands the whole stack: open changes whose commits are now ancestors of the new tip are marked landed by inclusion. Landing takes `repo:write`. Abandoning takes `repo:write` **or** being the change's author: somebody who proposed from a [fork](/docs/forks/) holds only read access on the repository they proposed to, and a change they could open but never close would be theirs to leave lying around. Anyone else without write access gets the same `404` a stranger does. A change that is a member of a [changeset](/docs/changesets/) — one review unit spanning several repositories — refuses to land or be abandoned on its own; it moves with the changeset. Every outcome is recorded on the change as a verdict: | Verdict | Meaning | |---|---| | `landed` | trunk now points at the patchset commit | | `landed: included in ` | landed by inclusion when a descendant landed | | `ejected: sufficiency lost — ` | an approval went away before the claim | | `ejected: not fast-forward from ` | trunk moved; rebase and re-register | | `ejected: not fast-forward (trunk moved)` | pushes kept winning the CAS | | `ejected: history walk exceeded bound` | ancestry too deep to prove; a human should look | | `ejected: the landing was given up after attempts; the last failed with: ` | the store or the database refused the landing the same way `n` times running (`STRATUM_JOB_MAX_ATTEMPTS`, five); fix the cause and land again | A landing whose driver fails is retried under a fresh job after `STRATUM_LAND_RECHECK_SECS`, and the retries are counted across those jobs; a change that is *waiting* on a check is not failing and is never counted, however long the check takes. The queue never force-pushes, never merges silently, and never lands a mirror — a mirror's trunk belongs to its origin, so review is refused there at change creation. ## Protecting trunk A verdict is advice until nobody can push around it. Protect a branch and it moves **only** through the land queue — every other write door refuses with the same sentence: ``` branch 'main' is protected: land through review ``` That sentence comes back from `git push` over HTTP and SSH (in-band, as the push report), from `POST …/commits`, and from reset, revert and branch deletion. The one writer left is the lander, which re-checks approval sufficiency at claim time — so "landable" is not just the verdict's opinion, it is the only road. ``` GET …/protections the protected set POST …/protections { "branch": "main" } (admin) DELETE …/protections/{branch} (admin) ``` Protecting takes a repo admin and an existing branch; protecting twice is an ack, not an error. Both directions are audited — the fence moving is an authority change, and the trail says who moved it. The **default branch** — where clones start and changes land when no target is named — is repo policy too: ``` PATCH …/repos/{repo} { "default_branch": "trunk" } (admin) ``` The branch must exist, mirrors follow their origin's HEAD instead, and the move is audited. Protect the default branch and you have the whole discipline in two calls: work lands through review, and review is where the work actually lands. ## The conversation Approvals say whether; comments say why. ``` POST …/changes/{change}/comments { "body": "…", "path": "file.rs", "line": 12 } GET …/changes/{change}/comments the conversation, oldest first POST …/changes/{change}/comments/{comment}/resolve POST …/changes/{change}/comments/{comment}/unresolve ``` Comments pin to the patchset they were written against — after a revision, "this was about patchset 1" stays visible — and can anchor to a file the change touches, or to a **line** of that file as of the patchset (`line` is 1-based and needs `path`). A comment may name a **range** with `line_end`, and may sit on either side of the diff: `side: "old"` puts it on a line the patchset deleted, which is where "why did this go?" belongs. People and service principals can both comment (CI saying "the perf suite regressed" is review too), and every comment is attributed honestly: a person as themselves, a service principal as `service`, never borrowing a human name. Bodies are bounded at 4,000 characters; anonymous readers of public repos may read a conversation, not join it. Signed in is enough: a person with no role in the org comments on a public repository's change as themselves — that is the outside contributor answering a review of their own fork's change — while a service token from another organisation reads as anonymous and is refused. In the dashboard, line comments sit inside the diff under the exact line, and the conversation records the anchor as `path:line`. A comment can also be **drafted** rather than posted — see [A review is one act](#a-review-is-one-act-and-it-can-say-no). ### Threads, and closing them A comment may reply to another with `parent_id`, and a reply inherits its root's anchor — it is part of that thread, not separately attached. Threads are **one level deep**. A reply cannot itself be replied to, because a tree is a forum and a forum is a different product; the refusal says so. A thread is resolved on its **root**: ``` POST …/comments/{comment}/resolve { } POST …/comments/{comment}/unresolve { } ``` **Who may resolve** is the interesting part, and it is the same question the verdict asks. A thread may be closed by the person who opened it, or by anyone who satisfies that path under OWNERS — the identical judge that decides whether an approval counts, asked with one candidate. On a path governed by `*`, or by no rule at all, write access is what OWNERS asks for, so write access is what closes a thread there. Not two rules for one file: somebody who may approve a change should not be unable to close a nit on it. The change's author does not get to resolve unilaterally. "I have read your objection and I am closing it" is not a thing the person being reviewed can say about a path they do not own. Resolving takes a person. A service token is refused — resolution is a judgement, like an approval, and unlike a check it is not a fact anybody can report. **Unresolved threads do not block landing.** They are rendered, counted and impossible to miss, but the land gate turns on checks and on the verdict — things somebody decided deliberately. A stray unresolved nit holding up a landing is how "resolve everything before merge" becomes ceremony that people learn to click through. ### Suggested changes Say it in code. A reviewer writes a fenced `suggestion` block in a comment anchored to a line or a line range, and the author applies it: ```` POST …/changes/{change}/comments { "body": "name it:\n```suggestion\nlet total = subtotal + tax;\n```", "path": "pay/gateway.rs", "line": 41 } POST …/changes/{change}/suggestions/apply { "comments": ["01hx…", "01hy…"] } ```` **A suggestion is not a field.** It is a fenced block inside the comment body, parsed where it is read. There is no `suggestion` column and there will not be one: a column beside the body would be a second, ungoverned way to say the same thing, and the two would disagree the first time somebody edited one of them. It also means an imported or mirrored comment carries a suggestion exactly as one written here does, with nothing to migrate. The block replaces the lines the comment is anchored to — `line`, or `line` through `line_end`. An **empty** block means *delete these lines*, which is distinct from a comment with no block at all: one is a suggestion, the other is a remark, and the API says two different things about them. The file's own line endings and its final newline are kept, so applying a suggestion to a CRLF file does not come back as every line changing. **Several suggestions, one patchset.** `…/suggestions/apply` takes a list of comment ids and makes **one** commit on top of the latest patchset. A reviewer leaves five remarks and the author takes them together — one revision, one CI run, one notification — where a commit per click would put five revisions on the change for a single act. The commit goes through the same door a push does, so it is pinned, given its CI and announced to the people OWNERS names, exactly like any other patchset. Nothing is rewritten. The new commit's parent is the patchset the reviewer read, the previous patchset stays pinned and readable, and the comments written against it stay pinned to it — which is why this is clean under fast-forward-only landing: there is no history rewrite to reason about. The message is the patchset's own, so the `Change-Id` trailer keeps it a patchset of the same change. A change whose commit carries no `Change-Id` is refused for exactly the reason [above](#change-identity-the-change-id-trailer): a commit built from it would open a new change rather than a revision of this one. **Applying commits, so it takes write access.** A reader of a public repository is told, in words, that the change's author applies it. A "commit suggestion" button in front of somebody who cannot push is a control that leads nowhere. Refusals are in words, and the call is all-or-nothing — the change is left exactly as it was: - **overlapping anchors.** Two suggestions over one line are two reviewers disagreeing, and quietly picking either would commit a hybrid neither of them proposed. The refusal names both comments and both ranges; apply one, then the other against the patchset it makes. - **a stale anchor.** A comment's line numbers are line numbers *of the patchset it was written against*. If that file has changed since — or the latest patchset no longer has it, or the anchor runs past its end — the suggestion is refused and the sentence names the file and the patchset. A file nobody has touched is still applicable from patchset 1; the rule is "this file moved", not "you are late". - **nothing to apply.** A comment that is still an unsubmitted draft, one with no line anchor, one on the `old` side of the diff (there is no such line to replace), one carrying no block, or one whose lines already read exactly as suggested. An info string we do not understand is not a suggestion. GitHub's anchor-moving `suggestion:-0+2` form is deliberately *not* read as a plain suggestion: applying it as one would put the reviewer's text on lines they were not talking about. The response is `{ change, patchset, applied, paths }` — one patchset, whatever the number of suggestions. ## A review is one act, and it can say no Every comment above is published the instant you write it. That is the right default for a passing remark and the wrong one for a review: a reviewer working through forty files publishes their half-formed first reaction, argues with it eleven comments later in public, and the author watches the whole thing happen. So a review can be **drafted** and submitted as one act. ``` POST …/changes/{change}/comments { "body": "…", "pending": true } POST …/changes/{change}/review start or save the pending review GET …/changes/{change}/review your draft, and the comments in it DELETE …/changes/{change}/review throw it away POST …/changes/{change}/review/submit { "verdict": "…", "body": "…" } POST …/changes/{change}/review/withdraw take back a standing "no" ``` A comment posted with `pending: true` is **yours alone** until you submit. Not visible to the author, not to another reviewer, not to an admin, not to a service token, and not to an anonymous reader of a public repository. `GET …/comments` shows everybody the published conversation and shows *you* your own drafts, flagged `pending: true`; that filter lives in one query, deliberately, because a leaked draft is the worst thing this feature could do. Discarding a review takes its drafts with it, and nobody ever knew they existed. **Submitting sends one notification.** A twelve-comment review is one email, because it is one act. Drafting sends none at all. ### The three verdicts ``` POST …/changes/{change}/review/submit { "verdict": "request_changes", "body": "the retry loop is unbounded" } ``` - **`approve`** writes exactly the row `POST …/approve` writes. Reviews do not have their own idea of sufficiency: the engine, the land gate and the lander read `approvals` and are untouched by any of this. - **`comment`** is words and no verdict — the ordinary "here are my notes" pass. It leaves approvals alone in both directions. - **`request_changes`** is the one the product did not have. Before it, the only negative signal was *silence*, which is exactly what "hasn't looked yet" also looks like. It revokes your own approval on that patchset — nobody approves and blocks the same code — and it stands. Submitting takes a person, like approving and for the same reason: a review says whether code should land, and that is not a fact a machine can observe. A service token may still post checks and comments. ### What `request_changes` blocks, and what it does not **It does not vanish on the next patchset.** An approval dies when new code arrives, because the approver never saw the new code. A block must not, or the author clears every objection by force-pushing over it — which is the exact move the objection existed to stop. It ends when its author withdraws it, or when they submit a different verdict of their own. Nobody else can lift it; `…/review/withdraw` withdraws *your* block and cannot name anybody else's. **It blocks the land gate only when its author has standing on a path this patchset touches.** That is the rule OWNERS already answers for approvals, asked about a "no" instead of a "yes": your objection holds up the change when the repository would have counted your approval — because you own one of the touched paths, or, where OWNERS says `*` or governs nothing, because you have write access. Everyone else's is **recorded, rendered, and advisory**. GitHub lets any passer-by wedge a pull request; we can do better precisely because the reviewer set is computed rather than nominated. The verdict endpoint says which is which: ```json "blocks": [ {"author": "Casey", "verdict": "request_changes", "blocking": true, "body": "the retry loop is unbounded"}, {"author": "Dev", "verdict": "request_changes", "blocking": false, "body": "I would not, personally"} ] ``` and when one of them is blocking, the verdict itself says so in words: ``` blocked: casey@acme.dev asked for changes; it stands until they withdraw it ``` A `request_changes` needs words: either a cover message, or comments of its own. A block with neither tells the author no and never what would make it a yes. **There is still no reviewer nomination**, and there will not be one. Blocking is the one place a person's opinion becomes authoritative here, and the authority comes from the same OWNERS resolution as everything else — not from anybody adding a name to a list. ## How a change should flow The pieces above are one discipline, end to end: 1. **Protect trunk once.** From then on the verdict is not advice; it is the only way trunk moves. 2. **Small changes, stacked.** One commit is one reviewable idea; a stack lands together when the top lands, so nothing blocks on batch size. 3. **Read the diff where you approve it.** The change view puts the line diff, the conversation and the verdict on one screen — approval and reading are the same sitting, not two tabs. 4. **Say why on the line, and say it once.** A comment anchored to the line survives the revision that answers it, pinned to the patchset it was about. Draft the whole pass and submit it as one review: twelve remarks are one act, one verdict and one email. 5. **Approve the patchset, not the person.** A new revision starts the count over; nobody lands code their approver never saw. 6. **Let CI vote where reviewers look.** Wire your CI to post checks on the change; a failing check blocks the queue, and the change page is the one place both verdicts — human and machine — read together. 7. **Land through the queue and stop watching.** Sufficiency and checks are re-verified at claim time, the promotion is a compare-and-swap, and every outcome — landed, included, ejected — arrives in words, on the change and over webhooks. ## CI in the loop Your CI is a reviewer with a badge, not a bystander. The loop is three steps, using systems you already run: 1. **Hear about work**: subscribe a [webhook](/docs/webhooks/) — `push` fires on every branch update, `change.landed` / `change.ejected` on queue outcomes. 2. **Run whatever you run** — GitHub Actions, Buildkite, Jenkins, a shell script. Weft does not care who does the computing. 3. **Report the verdict as a check** on the change: ``` POST …/changes/{change}/checks { "name": "ci/tests", "state": "failing", "url": "https://ci.example.com/run/812" } GET …/changes/{change}/checks the latest patchset's checks ``` Checks attach to the **latest patchset**, like approvals: push a revision and CI reports again, because a green run of yesterday's code says nothing about today's. Posting again under the same name updates in place (`pending` → `passing`), and a service token is exactly the right credential — reporting a build is a machine's job, unlike approving, which stays human-only. The states mean what they say: `pending` is running, `passing` is green, `failing` blocks. A failing check refuses the land request — `blocked: check 'ci/tests' is failing` — and the queue re-checks at claim time, so a red that lands between enqueue and claim ejects with `ejected: check failing — ci/tests`. A pending or absent check does not block: which checks are *required* before landing is per-repo policy, in design; today the contract is exactly this — red stops the queue, and the change page shows every check with its state, who posted it, and a link to the run. ## Webhooks `change.landed` and `change.ejected` deliver beside `push`, HMAC-signed the same way — see [Webhooks](/docs/webhooks/): ```json { "event": "change.landed", "payload": { "change": "I8f3a2c94e1b7d605", "commit": "…", "branch": "main", "patchset": 2 } } ``` ## In design Speculative batching, target-aware parallel landing, and three-way merge for drifted changes are in design for Repos customers. Until they ship, the queue's contract is exactly what this page describes — fast-forward only, with the verdicts above. Patchset-to-patchset review shipped and is [above](#what-changed-since-i-last-looked); what remains in design there is the rebase-aware half — subtracting the trunk move from a rebased patchset's diff, and carrying comment anchors across the range. # The freshness contract The contract has one invariant: **never a silent stale miss.** Every case where the mirror could serve you something old is explicit, on the wire, in a way your runbook and your vendor-risk audit can quote. ## The cases **You fetch a commit the mirror has.** It serves immediately from object storage. Webhooks keep the mirror seconds behind your origin (p50 < 10 s delivery-to-servable); a 60-second poll backstops webhook loss. **You fetch a commit the mirror doesn't have yet.** Weft synchronously fetches from your origin *before responding*, within a bounded budget (default 8 s, configurable per deployment). If the sync lands your commit, the response is fresh — CI racing a push just works. Concurrent requests for the same repo coalesce into one origin fetch. **The commit doesn't exist upstream either.** `404`, with a body that says a sync ran and names the origin — never a hang, never a guess. **The sync exceeds the budget.** `404` explaining the freshness budget was exceeded and to retry shortly. Your job fails fast with a quotable reason instead of hanging on a slow origin. **Your origin is unreachable.** Everything already mirrored keeps serving — that's the continuity pitch — and every response carries: ``` X-Weft-Staleness: X-Weft-Origin-Error: ``` The headers clear on the first successful sync after recovery. ## Why this makes the mirror safe unconditionally The failure mode that would burn you is the invisible one: a green build against code that wasn't the code. Under this contract a response is either provably current, explicitly stale (headers), or an explicit failure naming the origin. There is no fourth state. ## Verifying it yourself Both behaviors are easy to drill: ```bash # freshness: push to origin, immediately fetch the new SHA via the mirror git fetch mirror $NEW_SHA # triggers a synchronous sync # staleness: check the headers during an origin incident curl -sI -H "Git-Protocol: version=2" \ "https://api.weft.sh/acme/widget.git/info/refs?service=git-upload-pack" \ | grep -i x-weft ``` # Audit & undo Agent platforms need two answers on demand: *"what did the agent change and when?"* and *"put it back."* Weft treats both as first-class API surface. ## The audit trail Every write — API commit, git push, repo create/delete, ref change, token mint — records who acted, a timestamp, and, for commits, **your context blob**. "Who" is the person when there is one (`user:01hx…`), whichever credential they reached for, and the token when it acts for nobody (`token:01hx…`): ```json { "context": { "agent_run": "r-42", "prompt": "p-991", "user": "u-7" } } ``` Query it per repo, per person, per action, or by time: ```bash curl -H "Authorization: Bearer $TOKEN" \ "https://api.weft.sh/v1/orgs/acme/audit?repo=session-8412&limit=100" ``` | Parameter | What it narrows to | | --- | --- | | `repo` | One repo, by name | | `user` | Everything one person did, by user id | | `principal` | One exact actor string — `user:…`, `token:…`, `system:…` | | `action` | One action, e.g. `token.mint` | | `since` / `until` | A time window, epoch milliseconds | | `order` | `asc` (default, oldest first) or `desc` (newest first) | | `after` / `before` | The pagination cursor for that order | | `limit` | Up to 1000; 100 by default | | `format=csv` | The same rows as `text/csv` | Paging follows the order you asked for. Reading forwards, the response carries `next_after`; reading backwards it carries `next_before`. Feed the cursor back on the next request until a page comes back short: ```bash # Newest first, then the page before it. curl -H "Authorization: Bearer $TOKEN" \ "https://api.weft.sh/v1/orgs/acme/audit?order=desc&limit=100" curl -H "Authorization: Bearer $TOKEN" \ "https://api.weft.sh/v1/orgs/acme/audit?order=desc&limit=100&before=" ``` `format=csv` returns the same rows for a spreadsheet or a ticket. Every field is quoted and inner quotes are doubled, so a context blob's commas stay inside their cell: ```bash curl -H "Authorization: Bearer $TOKEN" \ "https://api.weft.sh/v1/orgs/acme/audit?format=csv&since=1735689600000" \ -o activity.csv ``` **A credential scoped to one repo sees only that repo's trail** — filter or no filter, JSON or CSV. That is deliberate: a per-repo CI token satisfies org-level `org:read` so it can read its own repo's metadata, and without this it would also be reading the org's credential inventory. Asking it for a different repo answers `404`, the same way a foreign org does. The log is append-only at the application layer, and batches ship write-once to object storage — the durable record can't be rewritten, by us or by you. In the dashboard the same trail is **Settings → Activity**: newest first, the same filters, and the CSV export behind one button. ## Undo primitives **Reset** moves a branch pointer — three commits forward, one request back: ```bash POST /v1/orgs/acme/repos/session-8412/reset { "branch": "main", "to": "", "expected_head": "" } ``` `expected_head` makes it race-safe: if someone else moved the branch first, you get `409` with the current tip. After a reset, the abandoned commits remain **reachable by SHA until garbage collection** — auditors can still fetch exactly what the agent did. **Revert** appends instead of rewinding — a new commit whose tree undoes the branch head, preserving history: ```bash POST /v1/orgs/acme/repos/session-8412/revert { "branch": "main" } ``` **Branch** checkpoints cheaply before risky work: ```bash POST /v1/orgs/acme/repos/session-8412/branches { "name": "checkpoint-12", "from": "main" } ``` Tags work the same way (`POST /tags`, `DELETE /tags/{name}`). ## The wire stays strict Over git, pushes remain fast-forward-and-create-only — forge semantics for humans and tools. The REST API is the authority that may move refs backwards and delete branches. That asymmetry is deliberate: your product owns undo; git clients can't accidentally invoke it. # Search and discovery One endpoint answers both "find that repo of ours" and "what is public here", because they are the same question asked with different credentials. ```bash curl "https://api.weft.sh/v1/search/repos?q=widget" ``` ```json { "repos": [ { "id": "01HX…", "org": "acme", "name": "widget", "description": "the fast one", "public": true, "kind": "native", "created_at": 1766000000000 } ], "next": null } ``` No credential is needed. With one — a session cookie from the dashboard, or an org token — the same request also returns repositories in the namespaces you belong to. ## What matches A case-insensitive substring of the **repo name**, its **namespace**, its **description**, or any of its **topics**. Nothing else: not file paths, not file contents. An empty `q` matches everything you can see, which is what the [discovery page](/discover/) browses with. ## Narrowing to one topic `q` is fuzzy and inclusive — somebody typing `kubernetes` means "anything to do with kubernetes" and does not know or care which field carries the word, so a repository *tagged* `kubernetes` and one that merely mentions it in its description both come back. `topic` is the other half, and is exact: ```bash curl "https://api.weft.sh/v1/search/repos?topic=kubernetes" ``` Only repositories actually carrying that topic. This is what a topic pill in a repository's About panel links to, and why it is a separate parameter rather than a qualifier inside `q`: a pill that also returned every repository mentioning the word in prose would be useless for the one job a facet has. Topics are stored lowercased, so `topic=Rust` and `topic=rust` are the same request. A topic no repository carries — or a string that could not be a topic at all, like `not a topic` — comes back as an empty page rather than a `400`, because a link somebody typed by hand should come back empty rather than as an error. The two compose: `?q=operator&topic=rust` is "repositories tagged rust whose name, namespace, description or topics also mention operator". ## What topics exist ```bash curl "https://api.weft.sh/v1/search/topics?limit=24" ``` ```json { "topics": [ { "name": "rust", "repos": 12 }, { "name": "cli", "repos": 4 } ] } ``` Most-used first, then alphabetically so the order is stable between calls rather than reshuffling among equal counts. Scoped the same way everything else here is: a topic carried only by repositories you cannot see is not listed, because a list of topic names is an existence oracle for the work behind them — "we have a `project-atlas` topic" is a sentence about a private repository. This is what the [discovery page](/discover/) builds its topic chips from. They were a fixed list of common words until 2026-08-31, which matched whatever a repository happened to *say* rather than what anybody had filed under, so most of them found nothing on a real instance while the topics in genuine use appeared nowhere. Your query is text, never syntax — `%` matches a literal percent sign and `_` a literal underscore, so a search for `100%` finds the repo called `100%` rather than every repo there is. Queries longer than 128 characters are refused with a `400` rather than quietly truncated: answering a different question than the one asked is worse than saying no. ## What you are allowed to find | You are | You see | | --- | --- | | anonymous | every public repository | | signed in | public repositories, plus everything in namespaces you belong to | | an org token | public repositories, plus that org's | | a repo-scoped token | public repositories only — it was minted to reach one repo, and a search is not that repo | This is the same rule every other route enforces, and it is deliberately *not* per-repo grants: a grant without membership is not access anywhere else in the product, so it does not widen search either. **A description is as private as its repository.** Text you write about a private repo is never matched for anyone who cannot already see it. ## Paging `limit` defaults to 25 and caps at 100. When there is another page the response carries a `next` cursor: ```bash curl "https://api.weft.sh/v1/search/repos?q=&limit=50&after=acme/widget/01HX…" ``` Ordering is `(namespace, name, id)` — deterministic and stable under inserts, so paging visits each repository exactly once, and never depends on a relevance score that could change between pages. The cursor is applied *inside* the visibility filter. Editing one moves the window within what you could already see; it cannot widen it, and a cursor that parses as nothing simply starts from the beginning. ## Describing a repository Descriptions are the only free text search can find a repo by. Set one at creation, or afterwards: ```bash curl -X PATCH https://api.weft.sh/v1/orgs/acme/repos/widget \ -H "Authorization: Bearer $WEFT_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "description": "the fast one" }' ``` - Up to 512 characters, one line — no newlines, tabs or control characters, because a description is rendered inside a table cell. - `"description": null` or `""` clears it. Leaving the field out leaves it alone, so an edit to one field never silently changes the other. - `repo:write` is enough. ## Publishing a repository ```bash curl -X PATCH https://api.weft.sh/v1/orgs/acme/repos/widget \ -H "Authorization: Bearer $WEFT_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "public": true }' ``` Changing visibility needs **`org:admin`**, not `repo:write`: it is the one edit here with consequences outside the organization — it puts the code in front of anonymous search — and it is recorded in the [audit trail](/docs/audit-and-undo/) as `repo.visibility`, which is the question asked after a leak. ## Absent and private answer the same Anonymously, a repository that does not exist and one that is private both answer **401**: ```console $ curl -si https://api.weft.sh/v1/orgs/acme/repos/payments | head -1 HTTP/1.1 401 Unauthorized $ curl -si https://api.weft.sh/v1/orgs/acme/repos/no-such-repo | head -1 HTTP/1.1 401 Unauthorized ``` Two different answers would be an enumeration oracle: ask for a name, read the status code, and you have learned whether that private repository exists. The git wire has always answered `401` to both, and this is REST catching up — the two front doors now agree about the same repository. Once you *have* presented a credential the answer is **404** for both instead. Having authenticated tells you nothing about repositories you cannot reach, so a foreign token and a real absence are indistinguishable as well. Namespace names are not masked. They are globally unique and claimed first-come, so signup already answers "does `acme` exist?" to anyone who asks; pretending otherwise here would be theatre. # Webhooks ## Inbound: keeping mirrors fresh Point your origin's push webhook at Weft: ``` POST https://api.weft.sh/webhooks/github (GitHub App deliveries) POST https://api.weft.sh/webhooks/generic (anything else) ``` Deliveries must carry `X-Hub-Signature-256: sha256=` computed over the raw body with your webhook secret; unsigned or mis-signed deliveries are rejected with `401`. A verified push event fans out background syncs to every mirror of that origin, and the receipt-to-servable lag is recorded as the `freshness` metric. Generic-provider payloads identify the origin by URL: ```json { "full_name": "https://git.example.com/acme/widget.git" } ``` ## Outbound: push events from your repos Subscribe a URL to a repo: ```bash curl -X POST https://api.weft.sh/v1/orgs/acme/repos/session-8412/webhooks \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "url": "https://app.example.com/hooks/stratum" }' ``` The response includes the delivery `secret` — shown once. Every delivery is signed the same way (`X-Weft-Signature-256: sha256=`); verify before trusting. You can do the same from the repository's **Settings → Push webhooks** panel, which is also where the CI intake secret lives — the two are the two halves of one job. See [CI integration](/docs/ci-integration/) for the whole loop. ### The events | Event | Fires when | |---|---| | `push` | Anything moves a ref: a `git push` over HTTPS or SSH, or `POST …/commits` | | `change.landed` | A change lands, including one that landed by inclusion when a stack's top landed | | `change.ejected` | The lander refused a change and took it out of the queue | Every delivery is `{ event, repo_id, payload }`: ```json { "event": "push", "repo_id": "01jm…", "payload": { "via": "api", "commit": "3f2a…", "branch": "main" } } ``` **The `push` payload depends on how the push arrived, and the common case is the thin one.** A `git push` carries `{ "via": "git" }` and an SSH push carries `{ "via": "ssh" }` — no branch, no commit. Only `POST …/commits` carries `{ "via": "api", "commit": …, "branch": … }`, as above. So a receiver that reads `payload.branch` works against the API path and silently never fires for real pushes; treat a `push` as "something moved, go and look" and fetch to find out what. `change.landed` carries `{ change, commit, branch, patchset }` (or `included_in` in place of `patchset` when it landed by inclusion), and `change.ejected` carries `{ change, verdict }`. Failed deliveries retry three times with backoff; delivery outcomes are recorded and visible to support. List subscriptions with `GET …/webhooks`, remove them with `DELETE …/webhooks/{id}`. # Export & the escape hatch No lock-in is a stated product principle, so leaving has first-class API support. Two doors are always open: ## Door one: it's git Every repo — including every fleet repo your agents created — is a standard git remote: ```bash git clone https://x:$TOKEN@api.weft.sh/acme/session-8412.git ``` Whatever you clone is verified history; our CI gates every serving change on `git fsck --full --strict` of the produced clone. ## Door two: bundles For bulk moves or archives, export server-side bundles: ```bash # start the export (async) curl -X POST …/repos/session-8412/export -H "Authorization: Bearer $TOKEN" # → { "job": "01jm…", "state": "queued" } # poll, then download curl …/repos/session-8412/export/01jm… curl -OJ …/repos/session-8412/export/01jm…/download ``` The artifact is a plain `git bundle` — clone it with nothing but git, anywhere: ```bash git clone session-8412.bundle restored/ ``` Bundles are built from a freshly materialized, fsck-verified copy of the layout, so an export is also an integrity check. ## The whole org at once ```bash curl -X POST https://api.weft.sh/v1/orgs/acme/export \ -H "Authorization: Bearer $ADMIN_TOKEN" ``` starts one export job per active repo and returns all job ids. Point a script at the job list and you have a complete, standard-format copy of everything — which is exactly the position we want you negotiating renewals from. # Metrics & usage ## Per-repo serving metrics ```bash curl -H "Authorization: Bearer $TOKEN" \ "https://api.weft.sh/v1/orgs/acme/repos/widget/metrics?from=$FROM_MS&to=$TO_MS" ``` Returns, per kind: | Kind | Meaning | |------|---------| | `clone` | full clones served — count, bytes, p50/p99 latency | | `fetch` | incremental fetches — count, bytes, p50/p99 | | `push` | accepted writes and their latency | | `api` | REST and advertisement requests absorbed | | `freshness` | webhook-receipt → servable lag on mirrors | Percentiles come from log-scale latency histograms recorded per minute, so p99 is a real tail measurement, not an average in disguise. Add `&format=csv` for the spreadsheet-ready version — the artifact your renewal conversation wants. The metrics response also carries the mirror's current sync state (`last_sync_at`, `sync_error`), so one call answers "is it healthy and how fast is it". ## Org usage ```bash curl -H "Authorization: Bearer $TOKEN" https://api.weft.sh/v1/orgs/acme/usage ``` Daily rows of `active_repos` (repos that did any work), `total_repos`, `requests`, and `bytes_out`, plus your plan. Billing is computed from these same rows — **dormant repos never appear in `active_repos`, and dormant repos are free.** ## Prometheus `GET /metrics` exposes process-level counters (`stratum_requests_total`, `stratum_bytes_out_total`) for your own monitoring stack, alongside `GET /healthz` for liveness checks. # Service limits These are the v1 limits, stated plainly. Each traces to a measured or designed bound in the engine, not to a guess. | Limit | Value | Notes | |-------|-------|-------| | Push / request body | 64 MB | oversized pushes get `413`; large-object offload (LFS-class) is on the roadmap | | Concurrent pushers per repo | ~16 | measured burst ceiling; beyond it pushes are politely rejected with a retry hint, never corrupted | | Batch create/delete | 1,000 repos per call | pipeline batches for larger fleets | | Repo listing page | 1,000 | keyset pagination; fleets of millions list fine | | Commit operations per request | 10,000 | one commit = one atomic ref transaction | | Object ids | SHA-1 | layouts are format-versioned; SHA-256 repos are recognized and rejected loudly today | | Shallow clones | `--depth 1` fast path | deeper `--depth N` falls back with an explicit error rather than degrading silently | | Partial clone filters | not yet advertised | stock git therefore never sends them; full clones serve instead | | CDN offload | opt-in, current-tip only | needs `fetch.uriprotocols` on the client (git ≥ 2.34) and a pack at the current tip; otherwise the clone is served inline, correct either way | | Free plan | repo-count capped | creation past the cap returns `402`; reads keep working | ## Behavioral guarantees behind the numbers - Anything over a bound fails **loudly and specifically** — a `413`, a `ng` report naming the reason, a `404` with an explanation. Silent degradation is treated as a bug. - Just-pushed objects are readable immediately through the API and the wire; single-object point reads may take the slower write-log path until the background compactor folds them (seconds to minutes). - Reset/undo never deletes objects; garbage collection honors a grace window longer than the longest running clone. - A CDN-offloaded clone verifies what it downloads. A corrupt or missing pack fails the clone outright; it never produces a repository that looks complete and is not. # Organizations and billing The shape copies the one people already know. A **personal namespace** is free: you get one when you sign up, your **public** repositories live at `/you/repo`, and the only limit is the free-tier repository cap. It holds public repositories only. Private repositories live in an organization with a subscription, billed per person; a solo developer is a one-seat organization, and pays for one seat. An **organization** is free while everything in it is public. Members, teams, forks, review and landing cost nothing on public work. The first **private** repository starts a subscription, billed per seat, at the price the [pricing page](/pricing) quotes. Hosted CI minutes come with the plan: a fixed allowance for a free namespace, a larger one per paid seat. **On a self-hosted Weft, none of this applies.** With no payment provider configured, organizations are not something we are selling, so nothing is gated and the billing screen says there is nothing to buy. ## Creating one ```bash curl -X POST https://api.weft.sh/v1/orgs \ -b "$COOKIE_JAR" -H "Content-Type: application/json" \ -d '{ "name": "acme" }' ``` ```json { "id": "…", "name": "acme", "plan": "free", "billable_seats": 1, "detail": "Ready. Public repositories and members are free; the first private repository starts the per-seat subscription." } ``` Organizations belong to people, so this needs a signed-in session with a confirmed address — an API token has nobody to own the result. The name goes through the same rules as a personal handle: valid shape, not reserved, not already taken, case-folded. The organization is `free` the moment it exists: public repositories, any number of members, the free hosted-minute allowance. No card is asked for. Stripe is the merchant of record here — it handles sales tax, VAT and disputes — and a merchant-of-record account has no "save a card, charge nothing" page: the card is met on the subscription page, together with the price and a promotion code, the first time the organization wants something private. Nothing at the provider exists for an organization until then. ```bash curl -X POST https://api.weft.sh/v1/orgs/acme/billing \ -b "$COOKIE_JAR" -H "Content-Type: application/json" -d '{}' # → { "url": "https://billing.stripe.com/…", "kind": "portal" } ``` Once a subscription exists this route opens the provider's own portal — cards, invoices and cancellation live there, because building that here would mean handling card details. Before one does it answers `402` (`card: nothing to manage yet … subscribe first`). ## Going private Creating a private repository in a personal namespace is refused, and the refusal says where private work lives: ```json { "error": "quota: private repositories live in an organization — create one from the dashboard; a personal namespace holds public repositories" } ``` Creating a private repository on a `free` organization is refused: ```json { "error": "quota: private repositories need a paid plan — public repositories are free; subscribe from Billing to make this one private" } ``` The dashboard answers that `402` with the price for the seats in use today and a button. The button is this call, and it is a redirect to the provider's own subscription page rather than a confirmation, because a **promotion code** can only be redeemed there: the page carries the seat price for today's seat count, a box for a code, and the card fields — the merchant of record collects the card and billing address there. ```bash curl -X POST https://api.weft.sh/v1/orgs/acme/billing/subscribe \ -b "$COOKIE_JAR" # → { "url": "https://checkout.stripe.com/…", "kind": "checkout" } ``` Nothing is written by this call. The subscription the page opens arrives on the provider's `customer.subscription.created` webhook, and that is what moves the plan to `paid`; the page comes back to the organization's billing screen with `?subscribed=done` or `?subscribed=cancelled`, and the screen keeps asking for a few seconds while the webhook lands rather than telling somebody who just paid that they have not. The repository creation that was refused is offered there, by name, and one click finishes it. A card is accepted or declined on the provider's page now, never here; a cancelled or expired page leaves the organization `free` and nothing charged. A redeemed code is a discount on the subscription at the provider, and the billing view does not show it — the invoice does, in the portal. One edge to know about: a second tab that was left on the provider's page can finish a second Checkout after the first opened a subscription; the newer one is the one recorded here, and the older one is cancelled from the portal. Calling it on an organization that already pays returns its billing view and opens nothing; the provider is keyed on the organization id, so a double click cannot open two subscriptions. ## What a seat is > Every member of the organization, plus every outside collaborator who > can reach at least one **private** repository. Somebody you added to a public repository is free — that is the open-source case. Somebody who is both a member and a collaborator is one seat, not two. Team grants add nobody: team membership requires org membership, so anyone a team can reach is already counted. ```bash curl https://api.weft.sh/v1/orgs/acme/billing -b "$COOKIE_JAR" ``` ```json { "org": "acme", "plan": "paid", "billable_seats": 7, "paid_seats": 7, "status": "active", "current_period_end": 1800000000000, "may_create_public": true, "may_create_private": true, "may_add_people": true, "price_per_seat_cents": 400, "paid_minutes_per_seat": 2000, "free_minutes": 500, "ci_minutes_limit": 14000, "ci_minutes_used": 1284, "ci_minutes_remaining": 12716, "ci_suspended_reason": null, "ci_suspended_at": null, "private_repos": 3 } ``` `plan` is one of `free` (public repositories and people), `paid`, and `past_due` (see below). `billable_seats` is what we would charge for right now. `paid_seats` is what the provider has been told. They differ for a moment between a membership change and the push that follows it — if they stay different, a push did not land, and this screen is where you find out. `private_repos` counts the active private repositories here, and is only interesting on `free`: that is where a *cancelled* subscription lands as well as a new organization, so the number is how the screen tells "nothing private yet, the first one starts a subscription" apart from "three private repositories are read-only until you subscribe again". The three `may_*` flags are what a screen has to decide before it draws, already decided. `price_per_seat_cents` and `paid_minutes_per_seat` are said in numbers so nothing rendering this hard-codes a price it then disagrees with the site about; they are `null` where nothing is for sale — a personal namespace, or a deployment with no payment provider. On a personal namespace `may_create_private` is `false` wherever the deployment sells: private work lives in an organization, and a private create there is refused with ```json { "error": "quota: private repositories live in an organization — create one from the dashboard; a personal namespace holds public repositories" } ``` ## Hosted CI minutes The last five fields are [hosted workflows](/docs/workflows/), and they are here rather than on a page of their own because it is the same question as the seat count — what has this organization used, and what is it allowed — and somebody whose builds have just started saying "out of minutes" comes looking at billing first. What the plan includes is `free_minutes` per rolling month for a personal namespace or a free organization, and `paid_minutes_per_seat` times the seats billed for a paid one — the example above is seven seats at 2,000. A `past_due` organization keeps counting per seat: the seats were paid for until the invoice failed, and shrinking the allowance underneath running builds would be a second punishment for one declined card. `ci_minutes_used` is **the last thirty days, rolling** — counted per job and rounded up, with a job still running counting from the moment it started, so the number moves while builds run. There is no reset date and no first-of-the-month: the oldest minutes fall out of the window as they age, which is why the dashboard reads "used in the last 30 days" rather than naming a date to wait for. `ci_minutes_limit` and `ci_minutes_remaining` are **`null` when there is no limit at all** — not zero. "0 minutes left" and "no limit" are opposite facts, and anything rendering these has to tell them apart. `ci_minutes_limit` is never `0`: a budget of zero is read as no budget, so an unmetered organization reports `null` in both. The zero that means *metered and out* is `ci_minutes_remaining: 0`, and it is floored there rather than going negative — a job already running can take an organization past its limit, because nothing is killed for budget — so `ci_minutes_used` may exceed `ci_minutes_limit` while `ci_minutes_remaining` reads `0`. `ci_suspended_reason` is set when hosted workflows have been switched off for the organization — today, only a runner catching a job mining cryptocurrency does that. While it is set, every push's workflow runs are `blocked` with that reason. Clearing it is an operator action, not a button: see [Workflows](/docs/workflows/#what-is-refused-for-abuse). **Jobs that ran on your own machines are not counted.** These five fields are about *hosted* runners — our compute, our bill. A job with `runs-on: [self-hosted]` costs no minutes, is not stopped when the organisation's budget runs out, and is not stopped by a suspension either; only a workflow file containing at least one hosted job is. See [self-hosted runners](/docs/workflows/#self-hosted-runners) for what that does and does not carry over. Minutes are counted on a self-hosted deployment too — the one part of this page that is not about a payment provider. A fleet applied from this repository's terraform is metered from the first apply; an operator can set any budget, or turn metering off entirely. ### Seats are elastic upward and sticky downward Adding somebody to a paid organization is **never refused**: the quantity goes up and so does the bill, the moment they join, prorated. Removing somebody does **not** silently shrink what you have paid for. A seat bought for this period is one you should be able to refill without paying twice, so releasing it is a deliberate act in the billing portal — next to where you would cancel. Refilling a paid seat costs nothing more and pushes nothing. ## When a payment fails The organization goes to `past_due`. Everything in it stays exactly where it is and stays readable — clones, fetches, the API, the dashboard. What stops is *creating*: no new private repositories, no new people, and hosted workflows on **private** repositories are paused. Public repositories keep building, as they would on a free organization; jobs on self-hosted runners are not affected at all. ```json { "error": "quota: this organization's last payment failed — everything here is still readable, and private repositories resume as soon as it is settled" } ``` Settling the invoice — the "Settle the payment" button on the billing screen opens the provider's portal — restores it in full, with nothing lost. Cancelling drops the organization back to `free`, which keeps its public repositories and people and refuses new private ones. **No billing state ever deletes a repository.** Taking somebody's work away over a declined card is not a dunning strategy. The private repositories it already holds stay, readable, and become **read-only**: a push is refused with ``` quota: this repository is private and the organization's subscription has ended — everything here is still readable; subscribe from Billing, or make the repository public, to write to it again ``` That sentence is on the repository row as `write_blocked` — null whenever writes are allowed, and folded into `viewer_write`, which is false while it is set — so a page can say so before somebody clones. The forge and the dashboard both show it as a read-only banner over the repository, the repository table marks those rows *Private · read-only*, and the billing screen names the count rather than offering the first private repository to an organization already holding several. Making the repository public is the other way out and needs no subscription. ## Webhooks `POST /webhooks/stripe` verifies the timestamped HMAC in `Stripe-Signature` within a five-minute tolerance — the timestamp is inside the signed material, so a captured delivery cannot be replayed tomorrow. Configure `STRATUM_STRIPE_WEBHOOK_SECRET` with the signing secret from the provider's dashboard. Deliveries are **idempotent**: providers retry until they see a 200, and a retry that lands after the first one succeeded does nothing twice. The converse holds too: a delivery answered 5xx — the card webhook arriving while Stripe itself is down, say — is not remembered as seen, so the retry is acted on rather than waved through as a duplicate. Events we do not act on, and subscriptions we have never seen, are acknowledged and ignored — an endpoint that errors on an event type somebody enabled in a dashboard is one the provider eventually stops calling, and then subscription state silently stops tracking. ## Configuration | Variable | What it is | |---|---| | `STRATUM_STRIPE_KEY` | secret API key; with the two below absent, this deployment sells nothing | | `STRATUM_STRIPE_PRICE` | the recurring price each seat is billed at | | `STRATUM_STRIPE_WEBHOOK_SECRET` | signing secret for `POST /webhooks/stripe` | | `STRATUM_STRIPE_BASE` | API base, for pointing at a test double | | `STRATUM_PRICE_PER_SEAT_CENTS` | what the dashboard and site quote per seat per month (default `400`) — display only; the Stripe price is what is charged, and the two have to agree | | `STRATUM_FREE_CI_MINUTES` | hosted minutes per rolling month for a personal namespace or free organization (default `500`; `0` is unlimited) | | `STRATUM_PAID_CI_MINUTES_PER_SEAT` | hosted minutes per rolling month per paid seat (default `2000`; `0` is unlimited) | The first three are all-or-nothing: **setting some of them and not the rest is a boot error**, not a warning. Each half fails a different way and all fail quietly — with no price every subscribe is a 502 from the provider rejecting an empty price, with no webhook secret every delivery fails its signature check and no subscription ever lands, with no key every call is a 401 — and none of them says "misconfigured" anywhere a person would look before the first customer does. A blank value counts as unset, because a task definition cannot unset a variable, only empty it. # CDN-offloaded clones A clone normally streams every byte through a Weft node. With offload, the bulk comes straight from a CDN instead, and the node streams nothing. This uses git's own **`packfile-uri`** capability (git ≥ 2.34) — no custom client, no wrapper, no plugin. ## Turning it on It is **opt-in per client**, because git only follows an advertised pack URL when you have told it which protocols are acceptable: ```bash git -c fetch.uriprotocols=https clone https://api.weft.sh/acme/session-8412.git ``` To make it the default for yourself: ```bash git config --global fetch.uriprotocols https ``` For a CI image, set it once in the image's global git config and every job in the fleet inherits it. **Nothing changes for clients that have not opted in.** They clone exactly as before and never contact the CDN. There is no flag day and no compatibility risk to a fleet you do not control. ## What you get The pack is immutable — named by tip and content hash — so the edge caches it. A CI fleet cloning the same repo all day fetches it from the nearest edge location rather than assembling it at origin every time. Offloaded clones are metered separately as `cdn_clone`, so you can see the split on the [metrics endpoint](/docs/metrics/). ## When it engages Offload engages while the repo's CDN pack covers the **current tip**. After a push the pack lags, and until the background packer catches up (seconds) clones are served inline — correct, just not offloaded. So the pattern that benefits most is **clone-heavy and push-light**: mirrors, release repositories, anything a CI fleet pulls constantly. A repo under continuous push churn spends most of its time lagging and offloads rarely. Serving a lagging pack is deliberately *not* attempted. The remainder would have to be streamed inline, and a push's pack is **thin** — its deltas refer back to objects that are inside the CDN pack. git indexes the inline pack before it downloads the advertised URLs, so those bases would be missing and the clone would fail. That failure depends on whether your commits happen to touch similar files, which makes it exactly the kind of bug that passes a test suite and breaks in production. We do not go near it. ## Private repos Private repos offload too. git sends **no credentials at all** when it fetches an advertised pack URL, so authorization lives in the URL itself: a short-lived signed URL that the edge validates before it serves a byte. URLs expire (an hour by default), and a public repo's pack is additionally marked cacheable so it can be shared at the edge. ## Failure behaviour - **A pack that cannot be confirmed present is never advertised.** git does not fall back to the server for a URL it was given, so the server checks the object exists before offering it. - **A corrupt or truncated pack fails loudly.** git verifies what it downloads; you get a failed clone and no repository, never a silently-incomplete one. - **Operators can turn it off fleet-wide** without a redeploy (`STRATUM_CDN_ENABLED=0`); clones keep working, served inline. ## Over SSH Offload is a property of the git protocol, not of HTTP, so an SSH clone offloads exactly the same way — the negotiation rides SSH and the advertised pack URL is HTTPS. See [Git over SSH](/docs/ssh/). # Changesets across repositories A **changeset** is one review unit made of changes that live in different repositories of the same organization: the API that grows a field, the web app that reads it, the CLI that prints it. Each of those is still an ordinary [change](/docs/code-review/) in its own repository, with its own patchsets and its own approvals. The changeset is the thing that says *these belong together*, in what order they land, and — once one is a member — that none of them lands or is abandoned on its own. This page covers what is on the wire: composing a changeset, shaping its landing order, the rules a member is held to, the verdict over all of it, landing it as one unit, the [composed CI](#composed-ci) that tests every member together, and the [workspace](#the-workspace) that checks every member out at its proposed head with one `git clone`. ## What a changeset is - **Members** are open changes, at most one per repository and at most sixteen in all. Sixteen is a ceiling on coordination, not storage: a unit that touches more repositories than that is not one change. - A change is a member of **at most one open changeset**. Trying to add it to a second is refused by name until the first lands or is abandoned. - **Edges** say which member lands before which: `{ from, to }` means `from` lands first. Edges are optional, must not form a cycle, and are replaced as a whole when you set them — acyclicity is a property of the set, so the set is what you send. - The **landing order** is derived from the edges, ties broken by the order members were added. Every response carries it as `order`, so what a reviewer sees is exactly what the lander will do. - A changeset is `open`, then `landing`, `landed`, `abandoned` or `failed`. Only an open one can be shaped. ## Composing one Register a change in each repository first, as you would alone. Then: ``` POST /v1/orgs/{org}/changesets { "key": "Ic5000001", "title": "Rename the customer field", "body": "web first, then api", "members": [ { "repo": "api", "change": "Iaa000001" }, { "repo": "web", "change": "Ibb000002" } ], "edges": [ { "from": { "repo": "web", "change": "Ibb000002" }, "to": { "repo": "api", "change": "Iaa000001" } } ] } ``` The key is yours to choose — same alphabet as a Change-Id, unique within the org — so a tool that composes changesets can name them deterministically and re-post safely: the second attempt is a `409` that says the key exists, not a duplicate. `201` returns the changeset: its members with each change as the change API shows it, its edges, and `order`. Every refusal is decided before anything is written, and each names the member it is about: | Status | Why | |---|---| | `400` | bad key or title; no members, or more than sixteen; the same change listed twice; two members from one repository; an edge that names a non-member, a change before itself, or a cycle | | `404` | `no change web/Inope0001` — a member that is not a change, or one the caller may not write to | | `409` | the key exists; a member is not open; `api/Iaa000001 is already in changeset Ic5000001` | | `402` | the organization is read-only and a member is in a private repository | Finding the changes to compose is a request of its own: ``` GET /v1/orgs/{org}/changes?state=open ``` It answers every change in the organization you may read, newest first, each row naming its `repo` and the `changeset` already holding it — or `null` when the change is free. That `changeset` field is the same question the `409` above answers, so a picker can grey out exactly the changes a compose would refuse. This is what the dashboard's changeset picker reads; asking each repository's own change list in turn is a round trip per repository before anyone can see what there is to compose. Authority is per repository, not org-wide: a `repo:read` token sees its own repository's changes here, a public repository's are readable with no credential at all, and a private one's are not — the same answers `GET /v1/orgs/{org}/repos/{repo}/changes` gives one repository at a time. ## Shaping an open changeset ``` POST /v1/orgs/{org}/changesets/{key}/members { "repo": "cli", "change": "Icc000003" } DELETE /v1/orgs/{org}/changesets/{key}/members/cli/Icc000003 PUT /v1/orgs/{org}/changesets/{key}/edges { "edges": [ … ] } POST /v1/orgs/{org}/changesets/{key}/abandon ``` A new member joins at the end of the order with no edges. Removing a member removes every edge that touched it. The last member cannot be removed — a changeset with nothing in it is not a thing — abandon the changeset instead. Abandoning releases every member: each change is open again in its own repository and may land, be abandoned, or join another changeset. The record stays, closed, for the audit trail. ## What a member may no longer do alone While a change is a member of an open changeset, landing it or abandoning it through its own repository is refused with `409` and the changeset's key: ``` this change is a member of changeset Ic5000001 — it lands with the changeset; remove it from the changeset to land it alone ``` That is the whole point of the unit: nobody lands the API rename on a Friday and leaves the web app reading a field that is gone. The change itself says so before you try. `GET /v1/orgs/{org}/repos/{repo}/changes/{change}` answers `change.changeset`: the key of the changeset holding it, or `null` when it is free to land alone. It is read from the same binding the `409` above is refused on, so a client can show "lands with `Ic5000001`" up front rather than offering a Land button and discovering the truth from the refusal. ## Who can see and shape one Authority is per member, and comes from the repositories: - **Reading** a changeset needs read on **every** member. If any member is in a repository you cannot read, the changeset does not exist for you — `404`, the same answer whether or not it exists — and it is left out of the list. A changeset over public repositories reads anonymously, like the repositories do. - **Composing and shaping** need write on every member, the one being added included. So do landing, reverting and abandoning. Every changeset you read says whether you hold that — `"viewer_write": true` or `false` — so a client can withhold the controls rather than offer them and be answered with the masked `404` a stranger gets; each row of `GET /v1/orgs/{org}/changes` says the same about its own repository, for the picker. - A credential from **another organization** reads a changeset over public repositories the way anyone does — anonymously, as it reads the repositories themselves — and may shape nothing. - A [read-only organization](/docs/billing/) — no card yet, or a subscription that has ended — can still read its changesets and shape those over public repositories; anything touching a private repository answers `402` with the sentence that says what to do. Every composition, membership change, edge change and abandonment is in the org's [audit trail](/docs/audit-and-undo/) as `changeset.create`, `changeset.member.add`, `changeset.member.remove`, `changeset.edges` and `changeset.abandon`. ## Reading ``` GET /v1/orgs/{org}/changesets?state=open&limit=50 GET /v1/orgs/{org}/changesets/{key} ``` The list is newest first and shows only changesets whose every member you may read. `state` is one of `open`, `landing`, `landed`, `abandoned`, `failed`; anything else is `400`. ## How big it is ``` GET /v1/orgs/{org}/changesets/{key}/diffstat ``` The `+412 −77` a reader wants before they open anything, counted from the same tree diff `…/diff` reports path by path — per member, and in total: ```json { "changeset": "Ic5000001", "total": { "files": 9, "insertions": 412, "deletions": 77, "truncated": false }, "members": [ { "repo": "api", "change": "Iaa000001", "patchset": 2, "files": 3, "insertions": 41, "deletions": 12, "truncated": false }, { "repo": "client", "change": "Ibb000002", "patchset": 1, "files": 6, "insertions": 371, "deletions": 65, "truncated": false } ] } ``` Per member and not only in total, because "one review over several repositories" is the thing a changeset *is*: a set that is +12 in the API and +900 in the generated client is a different review from one that is +450 in each, and a single number says the same thing about both. Members are in landing order, and each member's numbers are its **latest patchset** against its parent commit — the same range the change page diffs. **`truncated` is honesty, not an error.** Some files have no line count this server is willing to claim: over 512 KiB (the same limit the diff view refuses to fetch as text, so it is a file you would never be shown a diff of anyway), binary, a submodule pointer — which is a commit oid rather than content — or a rewrite so total that matching it up would run past the request's work bound. Those files are left out of `insertions` and `deletions` and the member says so. `files` stays exact either way, because it comes from the tree walk, which never declines a path. A number invented for a file the server declined to read would be worse than an absent one: nothing about it would look wrong. On `total`, `truncated` means *some* member's is — the only reading of it that cannot overstate what was counted. Reading it needs read on **every** member, like the changeset itself; a member you cannot see makes the whole thing `404`, because the size of a review over a private repository is a fact about that repository. ## The workspace ``` GET /v1/orgs/{org}/changesets/{key}/workspace ``` A changeset is several changes in several repositories, and the question a reviewer keeps asking is *what does it all look like together*. The workspace answers it with one git repository, served for the changeset, that you can clone: ```sh git clone --recurse-submodules https://weft.example/acme/changesets/Ic5000001.git ``` That checkout has one directory per member repository, named after it, with the member's proposed head checked out inside — the `api` change's latest patchset under `api/`, the `web` change's under `web/`. The superproject is one commit on a branch called `workspace`; its tree holds one submodule per member pinned to that member's commit, and its `.gitmodules` names each member by a **relative** URL (`../../api.git`), so the members are fetched over whatever transport and credential you cloned the workspace with — HTTPS with a token, or SSH with your key, from the same `ssh_clone_url` shape the changeset reports. The response says the same thing in JSON: ```json { "key": "Ic5000001", "title": "Add the field end to end", "state": "open", "composition": "3f9c2b…", "tip": "8ad14e…", "clone_url": "https://weft.example/acme/changesets/Ic5000001.git", "ssh_clone_url": "ssh://git@weft.example/acme/changesets/Ic5000001.git", "members": [ { "repo": "api", "change": "Iaa000001", "title": "Add the field", "path": "api", "commit": "e0d37a…", "fetch_ref": "refs/patchsets/e0d37a…", "clone_url": "https://weft.example/acme/api.git", "ssh_clone_url": "ssh://git@weft.example/acme/api.git" } ], "note": null } ``` - `composition` is the hash the changeset's [composed CI](#composed-ci) runs are named by, and `tip` is the workspace commit. Both move together — a new patchset on any member, or a member added or removed, is a new composition and a new tip — and a workspace at a given `tip` is exactly the combination a given composed run tested. Each tip is a fresh root commit with no parent: the history is in the members, not in the workspace, so the same changeset clones to the same commit from any node and at any time. That also means `git pull` will not follow it — there is nothing to merge. To move a checkout to the current tip: ```sh git fetch origin && git reset --hard origin/workspace && git submodule update --init ``` - `fetch_ref` is where each member's commit lives in its own repository, `refs/patchsets/`, so it can be fetched even when the branch it was pushed from has moved on or been deleted; the workspace's submodule pins reach it the same way. - The workspace is **read-only**. A push to `clone_url` is refused on the wire with the reason; changes go to the member repositories, as patchsets of the member changes. - **Who may clone it** is who may read every member — the rule for the changeset itself. A member you cannot read makes the whole workspace `404`, and anonymous clones work only when every member repository is public. The `.gitmodules` URLs carry nothing you did not already hold. - `note` is `null` except while the changeset is `landing`, when it warns that some members may already be on their trunks while others are not, and that the workspace is the proposed state, not the trunks. That window is a few round-trips to object storage, and the view says so rather than pretending it is zero. - A member whose repository has since been deleted is not in the tree, and a changeset with no member left has `composition` and `tip` `null` and an empty `members` list; its clone URL advertises no refs. The repository name `changesets` is reserved in every organization, because `/{org}/changesets/{key}.git` is where its workspaces are served from. ## The verdict: one review at a time ``` GET /v1/orgs/{org}/changesets/{key}/verdict ``` A changeset is one review, so it has one answer to "can this land?" — and because a member is still a change in its own repository, that answer is composed from the answers each member already has. Nothing is re-decided here: every member's `verdict` is exactly what `GET …/changes/{change}/verdict` gives for it — the [OWNERS](/docs/code-review/) sufficiency at its latest patchset — and its `gate` is exactly what the change's own Land button consults, the [required checks](/docs/ci-integration/#making-a-check-required) on its target branch. ```json { "changeset": "Ic5000001", "state": "open", "landable": false, "gate": "waiting", "explanation": "api/Iaa000001: feature.txt: needs approval from oa@acme.test", "waiting_on": ["web/Ibb000002: ci/tests"], "members": [ { "repo": "api", "change": "Iaa000001", "state": "open", "patchset": 1, "commit": "…", "landable": false, "explanation": "feature.txt: needs approval from oa@acme.test", "gate": "ready", "waiting_on": [], "reason": null, "verdict": { "landable": false, "explanation": "…", "per_path": [ … ] }, "approvals": [] }, { "repo": "web", "change": "Ibb000002", "…": "…", "landable": true, "gate": "waiting", "waiting_on": ["ci/tests"] } ] } ``` Members are in **landing order**. `landable` is true when every member is: its change is open, its verdict says approved, and no required check has failed. When it is not, `explanation` is the first member's that stands in the way, with `repo/change` in front — so the reader of one review over four repositories is told which repository to go to. Within a member the order is the order you would fix things in: a change that is not open cannot be helped by approving it, and an unapproved one cannot be helped by a green build. `gate` keeps the three answers the per-change gate has. `waiting` is not a *review* refusal — a changeset whose members are all approved and waiting on CI has nothing left for a person to do — and `waiting_on` lists the checks as `repo/change: check`; but it is a reason not to land yet, and [landing](#landing-a-changeset) refuses it until they have reported. `blocked` is different in kind: something has already said no, and waiting cannot rescue it. `reason` on the member carries the check's own words. Approvals stay where they are given — on each member, by someone the repository's OWNERS names — and each member's row lists them. A viewer who may read every member may read the verdict; anyone else is told there is no such changeset. ## Landing a changeset ``` POST /v1/orgs/acme/changesets/Ic5000001/land ``` Landing is the promise the unit exists for: **every member lands, or none is left landed.** There is no transaction across repositories — each repository's refs live in its own manifest and change only by compare-and-swap — so the promise is kept by a protocol rather than by the store, and this is what it does. **Pre-flight, which writes nothing.** Every member must be open, approved per its OWNERS at its latest patchset, its required checks **passing**, and its patchset a fast-forward of its target branch. Anything short of that is a `409` naming the first member in landing order that stands in the way: ```json { "error": "web/Ibb000002: blocked: required check 'ci/tests' is failing", "gate": "blocked", "waiting_on": [] } ``` `gate` is what stands in the way — `blocked` for anything a person has to act on, including a member nobody has approved yet — and is never `ready` in a refusal. A required check that has not reported yet is a refusal here, with `"gate": "waiting"` and the checks in `waiting_on`. That is the one place a changeset is stricter than a change on its own: a single change is held in the queue and lands unattended when its check reports, but the plan a changeset lands from is made against the trunks *as they stand at pre-flight*, and "the trunk has not moved since CI ran" is one of the things being promised. Land again when the checks have reported. A member whose trunk has moved since its patchset was made is `repo/change: not fast-forward from `; push a new patchset on top of the trunk and land again. **The commit point.** When every member is green, one landing record is written with the full plan — for each member its target ref, the tip it was judged against (`old`) and the commit it lands (`new`) — and the changeset turns `landing` in the same transaction. The response is `202` with that plan: ```json { "queued": true, "job": "…", "changeset": "Ic5000001", "landing": "…", "plan": [ { "repo": "api", "change": "Iaa000001", "ref": "refs/heads/main", "old": "9f6de998…", "new": "9e54f5f2…" }, { "repo": "web", "change": "Ibb000002", "ref": "refs/heads/main", "old": "80b5f804…", "new": "0b8105e4…" } ] } ``` From this row on the landing *will* finish, on this node or any other. While it lands, the changeset cannot be reshaped, abandoned or landed again (`409 changeset is landing`), and a member cannot land or be abandoned on its own. **Apply.** The lander walks the plan in landing order, one repository at a time, moving each target ref from `old` to `new` by compare-and-swap. The window between the first and last swap is a few round-trips to object storage; a reader cloning both repositories inside it can see one landed and the other not. That is the honest part of the promise, and the changeset says `landing` while it is true. **Finish, or unwind.** When every step is done, every member change is `landed` at its commit with the verdict `landed with changeset Ic5000001`, and the changeset is `landed`. If a trunk moved between the plan and its turn — somebody pushed to `web`'s `main` in the window — that member's swap fails, the landing fails, and every member that had already landed is put back: a **revert commit** on top of the landed one, restoring the tree the trunk had before, authored `weft-lander` and saying why — ``` Revert api/Iaa000001: changeset Ic5000001 did not land Restores refs/heads/main to the tree of 9f6de9988c33 because web/Ibb000002 — refs/heads/main moved to 3c1d0a9e4b7f before web/Ibb000002 could land. Reverts commit 9e54f5f216c1… ``` — never a rewind, because the landed commit may already have been fetched by somebody. The changeset is `failed`, and each member says what happened to it: the reverted one is `open` again with `landed, then reverted in : — push a new patchset to land again` (its patchset is no longer a fast-forward of the reverted trunk, so a new one is needed); the one that could not land is `open` with `ejected: refs/heads/main moved to before web/Ibb000002 could land`; any member that never got its turn is `open` with `ejected: not attempted, `. A `failed` changeset is final, like an abandoned one; its members are released and may be composed into a new changeset. If a landed member's trunk moved *again* before it could be reverted, it is left landed rather than fought over, and its note says `not reverted: refs/heads/main moved to after it landed`. That is the one outcome where a person has to look, and it takes two writers racing the same trunk inside one landing to produce it. **Reading the progress.** `GET …/changesets/Ic5000001` carries `landing` — null until the changeset has been asked to land, then the plan with each member's progress: ```json "landing": { "id": "…", "attempt": 1, "started_at": 1788395527022, "finished_at": 1788395528901, "outcome": "failed", "members": [ { "repo": "api", "change": "Iaa000001", "ref": "refs/heads/main", "old": "9f6de998…", "new": "9e54f5f2…", "state": "reverted", "note": "b2d4e6f8…" }, { "repo": "web", "change": "Ibb000002", "ref": "refs/heads/main", "old": "80b5f804…", "new": "0b8105e4…", "state": "failed", "note": "refs/heads/main moved to 3c1d0a9e4b7f before web/Ibb000002 could land" } ] } ``` `state` is `pending`, `done`, `failed` or `reverted`; `note` says why a step failed, names the revert commit of a reverted one, or says why a landed member could not be reverted. `outcome` is null while landing, then `landed` or `failed`. **If the node dies mid-landing.** The landing record, not the process, is what promises to finish. A node that dies between two swaps leaves a job whose lease lapses (`STRATUM_LAND_LEASE_SECS`, two minutes) and is claimed again; a landing whose job has failed outright is picked up by the reaper after `STRATUM_LAND_RECHECK_SECS` (twenty seconds) under a fresh job, and `attempt` counts them. The count is bounded: at `STRATUM_JOB_MAX_ATTEMPTS` failed drivers (five) the landing is given up rather than rescued again. If the plan has not yet failed, the member the drivers died on is `failed` with `the landing was given up after attempts; the last failed with: ` and one more driver unwinds what landed, so the outcome reads like any other failure. If the drivers were dying in the *unwind* itself, the landing is closed as it stands: `failed`, with every landed member left landed and its note saying `not reverted: the landing was given up after attempts; …` — the second outcome where a person has to look, and one that takes a store refusing the same write five times running to produce. Either way the new driver decides from the **store**, not the record: a trunk already at a member's `new` is done — the swap landed and only the acknowledgement was lost — and one still at `old` is swapped now. A trunk anywhere else is read through its history, because by the time a second driver looks the world may have moved on top of the first one's work: if `new` is in the tip's history the member landed and somebody has since pushed over it, so it is `done` with the note `refs/heads/main moved to after api/Iaa000001 landed` and is never reverted over that push; if it is not, the member is `failed` with `moved to before … could land` and the landing unwinds as above. The same reading recognises a revert whose acknowledgement was lost — the commit directly on the landed one, with the landed one as its only parent and the pre-landing tree — as the revert it is, rather than as a stranger's push. The walk is bounded (4096 commits); a trunk that has taken more than that inside one landing is `failed` with a note saying the question is beyond the bound, and a person has to look. Nothing is landed twice and nothing is reverted that did not land. **A member aimed at a branch that does not exist yet.** A change may target a branch its repository does not have; the plan records `old` as `null`, the swap expects the ref to be absent, and landing creates the branch. Reverting such a member has no tree to go back to but the empty one: the revert commit restores the empty tree and its message says so (`Restores refs/heads/release to the empty tree because …`). ## Reverting a landed changeset ``` POST /v1/orgs/acme/changesets/Ic5000001/revert { "key": "Ic5000002" } ``` A changeset that landed and should not have is undone the way it was done: as one unit, across every repository, reviewed and landed through the same protocol. One call makes a **revert changeset** — `Ic5000002` here — with one member per repository the original landed in, and the response is that changeset as `GET …/changesets/Ic5000002` would return it, `open`, with `"reverts": "Ic5000001"`. The original is untouched and says `"reverted_by": ["Ic5000002"]`. Each member is an ordinary change. In its repository a commit is made on a new branch `revert/Ic5000002`, off the target branch **as it is now**, that puts back every path the landed member changed — a deleted file returns, a rewritten one is its old self again at its old mode, an added one is gone, and a directory emptied by that is gone with it — and touches nothing else, so work that has landed since on other paths is kept. The commit carries a `Change-Id` of its own and is registered exactly as a push would register it: pinned, its CI triggered, its owners notified. ``` Revert "change api" Reverts api/Iaa000001, landed by changeset Ic5000001 as 9e54f5f216c1…. Change-Id: I4b7c… ``` The members are composed with the original's edges **reversed**: what landed after its dependency is undone before it, so `web` is put back before `api` when `api` landed first. Then it is a changeset like any other. Nothing has moved yet — no trunk, and nothing about `Ic5000001` — and nothing does until `Ic5000002` is reviewed under the same OWNERS that governed the paths the first time and landed with `POST …/changesets/Ic5000002/land`. A revert changeset can itself be reverted, which is how a change is re-landed after a wrong revert. `title` and `body` are optional and default to `Revert ""` and `Reverts changeset Ic5000001.`. **What is reverted.** The members the landing record says are landed: every member of a `landed` changeset, and, of a `failed` one, the members the unwind could not put back — those `done` with a `not reverted` note. A `failed` changeset the unwind fully put back has nothing to revert and says so (`409 nothing of changeset Ic5000001 is landed: every member that landed was reverted`), and a changeset that never landed is refused outright (`409 changeset is open: nothing of it has landed`). **Everything is checked before anything is written.** A revert of three members out of four would be a half-landed changeset by another name, so any one member that cannot be reverted cleanly refuses the whole call, and no branch is made in any repository. The refusal names the first such member in landing order and lists every one of them: ```json { "error": "web/Ibb000002: refs/heads/main has changed since it landed at readme", "conflicts": [ { "repo": "web", "change": "Ibb000002", "why": "refs/heads/main has changed since it landed at readme", "changed": ["readme"] } ] } ``` A path is *changed* when the trunk no longer has, at that path, exactly what the landing left there — edited, deleted, or turned into a directory. That is somebody's later work, and a revert that quietly undid it would be a regression in the shape of a fix; put the path back by hand (or revert their change first) and call again. The other refusals are a target branch that has been deleted since the landing (`refs/heads/release no longer exists`), a `revert/Ic5000002` branch already in a repository (`revert/Ic5000002 already exists` — an earlier call under this key got that far, or somebody made one), a member whose repository has been deleted (`web/Ibb000002: the repository no longer exists`), and a `key` that is not a valid changeset key (`400`) or is already a changeset (`409 changeset Ic5000002 already exists`). Making a revert changeset needs `repo:write` on every member's repository, the same as composing one; anyone short of that is told there is no such changeset. ## Composed CI A member's own CI tests that member's repository. It cannot tell you whether the API and the web app still agree, because it never has both. A **composed run** does: one run, per member repository, with every member checked out at the head the changeset proposes for it. Declare it in the repository's [workflow file](/docs/workflows/), by adding `changeset` to `on:`: ```yaml name: contract on: [change, changeset] jobs: contract: steps: - name: Test against the sibling api run: | make test API_DIR="$WEFT_WORKSPACE/api" ``` Steps start in the job's own repository; the siblings are beside it under `$WEFT_WORKSPACE`, one directory per repository name, and `$WEFT_CHANGESET_MEMBERS` lists them as JSON with an absolute `path` each. Each sibling is fetched with a read token minted for that one repository, so a composed script cannot read organization repositories the change's author cannot. The details are in [Composed runs for a changeset](/docs/workflows/#composed-runs-for-a-changeset). Only repositories whose `.weft/` asks for `changeset` get a run; a changeset of four repositories where one declares a composed workflow has one composed run. Per-repository `on: change` runs continue exactly as before and still gate their own member. **A fork member holds the whole composition.** If any member's change comes from a fork and its workflows have not been approved yet, every member's composed run is held — `blocked`, with the reason `fork` — and not just the fork member's. A composed job runs in a maintainer's own repository, but the stranger's tree is checked out beside it under `$WEFT_WORKSPACE` and the maintainer's own script may execute it, so holding only the fork member's run would still run a stranger's code on every other member's behalf. Approving that change's workflows, with the button on the change itself, releases the whole composition at once. The per-repository `on: change` runs are gated exactly as they were. ### What the changeset reports `GET …/changesets/{key}` carries two fields for this: ```json { "composition": "3f9c2b1e…", "checks": [ { "repo": "api", "name": "contract / contract", "state": "passing", "detail_url": "https://weft.example/acme/api/checks/runs/wr_01H…", "run": "wr_01H…" }, { "repo": "web", "name": "contract / contract", "state": "running", "detail_url": "https://weft.example/acme/web/checks/runs/wr_01H…", "run": "wr_01H…" } ] } ``` `composition` identifies **this set of members at these commits**: it is a hash over each member's repository and its latest patchset commit. A new patchset on any member, or a member added or removed, is a different composition — the live composed runs of the old one are cancelled and a fresh set starts, and `checks` only ever shows the current one. A changeset with a member that has no patchset yet has no composition and no composed checks; `composition` is `null`. `state` is `queued`, `running`, `passing`, `failing`, `cancelled` or `skipped`, the same six a check on a commit has. `run` and `detail_url` lead to the run page, where the log tails live; `detail_url` is absolute, under the deployment's public URL. The run page names the changeset and the composition it was started for and links back here — a composed run is not listed on the member repository's Checks tab (see [where verdicts land](/docs/workflows/#composed-runs-for-a-changeset)), so the changeset is where its runs are found. ### What it does to the gate The composed verdict is folded into the changeset's `gate` alongside every member's own, with the same precedence: blocked beats waiting beats ready. - Any composed check `failing`, `cancelled` or `skipped` → the changeset is **blocked**, and `explanation` names it: `composed check contract / contract in api is failing`. - Otherwise any composed check `queued` or `running` → **waiting**, and the check is listed in `waiting_on` as `repo: check` — `builds: contract / contract` — naming the repository whose composed job it is, where a member's own check is listed as `repo/change: check`. - Otherwise — including a changeset with no composed checks at all — composed CI has nothing to say and the gate is whatever the members make it. `GET …/verdict` reports that fold, and [`POST …/land`](#landing-a-changeset) refuses on it the same way it refuses a member's own red check. Landing does **not** cancel a composed run that is still going; it cannot start, because a running composed check is `waiting`. ## Who gets told Composing a changeset, landing one, and a landing that fails each send one email. The recipients are the union, over every member, of that member's participants — its author, everyone who commented, everyone who approved — and the people that repository's OWNERS files *require*. The person who took the action is never mailed about their own act. Two rules are worth stating because they are the ones that would otherwise surprise you. **A path governed by `*` makes nobody required.** Whoever has write access may approve it, so no individual is on the hook, and mailing everyone with commit access about every change is how a notification becomes something people filter. "The OWNERS file names you" is a reason to interrupt somebody; "you happen to have commit access here" is not. **You are never told about a changeset you could not read.** A changeset mail names its members, so mailing somebody who cannot see one of those repositories would publish that repository's existence. Every candidate recipient is checked against every member before the mail goes out — the same answer the API reaches when it masks a changeset you may not read as a `404`. One message per event, not one per member: a changeset is one review, and four emails about it would be four reviews. ## In the dashboard Everything above has a screen. **Changesets** in the sidebar lists the organization's changesets, filterable by state, and **New changeset** composes one: name it, then pick members from every open change in the organization, grouped by repository. A change that is already in a changeset, or that would be a second member from one repository, is greyed out with the reason — the same three refusals `POST /changesets` would give, shown before you submit rather than after. So is a change in a repository you cannot push to: composing takes write access to every member, and the server would refuse it without saying why. A changeset's page is the verdict first — one word for the whole set and the member it is waiting on, by name — then the members in landing order with each one's own gate and approvals, the composed checks for the current composition, and the workspace clone URL. Approving a member happens where it is reviewed, on the change's own page — each member's key in the table is a link to it; that page says **Lands with changeset ``**, links back, and turns its own Land and Abandon off while the change is held. A member whose paths no OWNERS rule governs is not free: it needs one approval from anybody with write access, and its row says so. **Land all members** is enabled only when the gate is `ready`; the page follows the landing step by step until every member is on its trunk, or a failure has been put back. **Revert…** prefills `revert-`, makes the reverting changeset and opens it; the original links to it under *Reverted by*, and it links back under *Reverts*. **Abandon** releases the members. All of those are a writer's controls — write access to every member repository — and somebody without it is told so in their place, rather than shown buttons that answer `no changeset` when pressed. # CI integration This page is about **bringing your own CI**: whatever already builds your code, wherever it runs, signing a verdict back to us. That is one of two ways to get a check onto a commit here. The other is [Workflows](/docs/workflows/) — hosted CI, a `.weft/*.yml` file in the repository, run on our runners, with logs you can read while the job is still going and `push`/`change` triggers that start it. If you have no CI yet, start there; it is fewer moving parts than anything on this page. The two coexist, and neither replaces the other. Both write into the same `check_runs` table under the same names, so a hosted `ci / test` and a Buildkite `ci/tests` sit in one list on the Checks tab, are required by name the same way, and gate the land queue identically. A repository can run both. Note that a hosted workflow starts from a **push** — over HTTPS, over SSH, or through `POST …/commits` — so commits that arrive in a mirror by syncing from its origin do not start one; a mirrored repository's verdicts come from the paths on this page. What Weft does with either is **hold the verdict and act on it**. A check named `ci/tests` sitting at `failing` blocks the land queue, shows in review beside the human approvals, and colours the badge in your README. The rest of this page is how a verdict from *your* CI gets here. ## Which of the three paths you are on Find yourself here first. Two of the three are short, and most readers of this page need one paragraph of it. ### 1. Mirrored from GitHub — you do nothing Its Actions runs are polled in through the same App installation the commits already come through, and they appear on the repository's **Checks** tab without a secret, a snippet or a change to your workflow. Creating the mirror is what starts it: the first sync asks GitHub for the run history, and every sync after it asks again, so a run that finishes after the last look is picked up by the next one. **Check GitHub again** on the Checks tab asks right now instead of waiting for the next sync. The one requirement is a permission: the installation needs **`actions: read`**, which installations created before checks existed do not have. Until it is approved, the Checks tab says so in as many words — "we cannot read this project's checks" — rather than showing an empty list that reads as a project without CI. The button on that page sends you to GitHub to approve it, and the runs already in GitHub's history come with it. That is the whole of it. The rest of this page is for the other two. ### 2. Hosted here, CI somewhere else — four steps, and none of them is optional This is the case with the most moving parts, because **nothing on Weft starts a build of yours**. Hosted [workflows](/docs/workflows/) are the one exception and they run here rather than on your CI; for everything else, a repository created here is not connected to anything that runs your code, so the loop has to be closed at both ends: something has to tell your CI there is work, and something has to bring the verdict back. Each half is useless without the other, and each is a different page of these docs — which is why they are listed here in order rather than left to be assembled. 1. **Subscribe a webhook, so a push reaches your CI.** `POST …/webhooks`, or the repository's **Settings → Push webhooks** panel. Deliveries fire on `push`, `change.landed` and `change.ejected`, signed with a delivery secret shown once. This is the trigger; without it your CI never learns that anything happened. See [Webhooks](/docs/webhooks/), and read *What a delivery does and does not tell you* below before you write the receiver. 2. **Give the runner a credential to clone with.** A token with `repo:read` ([Authentication](/docs/authentication/)) or a deploy-style SSH key ([SSH](/docs/ssh/)). Your CI fetches from us the same way a person does. 3. **Post the verdict back.** The intake secret and the request in the rest of this page. 4. **Read it on the Checks tab**, on the change under review, and in the badge. #### What a delivery does and does not tell you Worth an afternoon to whoever writes the receiver. The envelope is `{ event, repo_id, payload }`, and the `payload` differs by how the push arrived: | The push came in by | `payload` carries | |---|---| | `git push` over HTTPS | `{ "via": "git" }` | | `git push` over SSH | `{ "via": "ssh" }` | | `POST …/commits` | `{ "via": "api", "commit": …, "branch": … }` | So an ordinary `git push` — which is how nearly every push arrives — announces **that** the repository moved and not **what** moved. There is no branch and no commit in it. A receiver cannot filter by branch at the hook, and has to fetch and compare to find out what changed. Build for that rather than discovering it: a workflow keyed on `payload.branch` will work perfectly in a test against `POST …/commits` and never fire in production. `change.landed` and `change.ejected` are the ones to hang a post-landing step off; they carry the change key, the commit and the target branch. ### 3. Neither of those Anything that can make an HTTPS request and compute an HMAC can report a check: GitLab CI, Buildkite, CircleCI, Jenkins, a nightly cron on a machine under someone's desk. If something else already triggers your builds, you need only step 3 above. It also works for a mirrored repository whose real CI is somewhere other than Actions; the paths are not exclusive. ## Where the verdicts end up Every path lands in the same three places, which is the point of having one vocabulary for them: - the repository's **Checks** tab, newest first, filterable by state; - the **change** under review, beside the human approvals, where a `failing` check blocks the land queue and a **required** one must go green before the change may land — see [Making a check required](#making-a-check-required); - the **badge** in your README. ## The shape of it 1. Mint a **per-repo intake secret**, once, and put it in your CI's secret store. You can do this from the repository's **Settings → CI checks** panel, or with the request below. 2. Your CI posts a small signed JSON body to `/v1/orgs/{org}/repos/{repo}/ci/checks` when a build finishes. 3. `GET /v1/orgs/{org}/repos/{repo}/badge.svg` renders the result. The secret can do exactly one thing: write a check on the latest patchset of a change. It cannot read your code, approve anything, land anything, or push. That is the whole reason it exists — the alternative is pasting a `repo:write` token into a third-party runner, and a token that can report a build should not also be a token that can delete the repository. ### What the intake does not do for you, said now rather than later Worth knowing before you wire anything up, because it is a plan you make once. None of this is a limit of the product as a whole — hosted [workflows](/docs/workflows/) do run code, keep logs and start themselves — it is what the *intake on this page* is and is not: - **It does not run your code.** A verdict arrives; nothing executes here. That is the point of a credential that can only write a check. - **It does not start your build.** We announce a push over a [webhook](/docs/webhooks/) and your CI acts on it. There is no nightly timer here to hang one off, for hosted workflows either. - **It does not hold your logs.** We store the `url` you send and link to it; the log lives on your CI and stays there. (A hosted workflow's log *is* held here, and streamed.) - **It has no re-run button.** Re-run it where it ran, and post the new verdict. [What this deliberately is not](#what-this-deliberately-is-not), further down, has the full table and the reasoning. ## 1. Mint the secret In the dashboard: the repository's **Settings** tab, **CI checks** panel. It shows whether a secret is configured and when it last moved, mints one when you press the button, and offers a confirmed revoke. Only somebody who may administer the repository sees it at all. Or over the wire, which is what a setup script wants: ```bash curl -sS -X POST "$WEFT_URL/v1/orgs/$ORG/repos/$REPO/ci/secret" \ -H "Authorization: Bearer $TOKEN" ``` ```json { "secret": "…", "rotated_at": 1717171717171 } ``` **Shown once.** There is no endpoint that reads it back; rotating is the only way to get a new one, and rotating immediately invalidates whatever the previous holder had. `GET` on the same path reports whether one is configured and when it last moved, never the value. `DELETE` revokes it. Store it as `WEFT_CI_SECRET` in your CI provider's secret store. ## 2. Post the verdict The request, in shell. Every provider snippet below is this, wrapped in that provider's YAML: ```bash BODY=$(printf '{"commit":"%s","name":"ci/tests","state":"%s","ref":"%s","event":"%s","actor":"%s","external_id":"%s","run_number":%s,"url":"%s","summary":"%s","sent_at":%s000}' \ "$COMMIT" "$STATE" "$REF" "$EVENT" "$ACTOR" "$RUN_ID" "$RUN_NUMBER" "$RUN_URL" "$SUMMARY" "$(date +%s)") SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$WEFT_CI_SECRET" | sed 's/^.*= //') curl -sS --fail-with-body -X POST "$WEFT_URL/v1/orgs/$ORG/repos/$REPO/ci/checks" \ -H "Content-Type: application/json" \ -H "X-Weft-Signature-256: sha256=$SIG" \ --data-binary "$BODY" ``` The signature is HMAC-SHA256 over the **raw request body** with the intake secret, hex-encoded, prefixed `sha256=`. It is the same scheme Weft's outbound webhooks use, so if you have already written a verifier for those, you have already written this. ### Two shapes, and `change` is what picks between them One route, two kinds of report, and which one you are making depends on whether the body names a `change`: - **Commit-scoped** — `change` absent. The verdict is about the commit alone. **Send this one.** It works on every event including a plain push, it fills the repository's **Checks** tab and the badge, and — because a change's checks are the union of its patchset's rows and the runs reported against that patchset's commit — it also appears on the review page and satisfies the land gate. One request covers everything. - **Change-scoped** — `change` present. The verdict attaches to that change's **latest patchset** rather than to a sha. The narrower tool, and worth it only when you want a verdict that is a statement about *the revision under review*: it cannot be inherited by a rewritten commit, and when both shapes report one name the patchset row wins. If you send both, send them as two requests. They are answered separately on purpose: a `201` naming a patchset says nothing about whether the run row was written, so a reporter that wants both can retry whichever failed. > A change key is `I` followed by hex, taken from the commit's > `Change-Id: I…` trailer — and if a commit has no such trailer, Weft > mints an oid-derived key that your CI cannot compute. It is **never** > the branch name and never the pull request title; anything else is > answered `404 no such change`. This is the main reason to prefer the > commit-scoped shape: it needs no key at all. ### The fields Shared by both shapes: | Field | Required | Bound | What it is | |---|---|---|---| | `commit` | yes | 40 hex | The commit your run actually built | | `name` | yes | 100 bytes | The check's name, e.g. `ci/tests` | | `state` | yes | — | Per shape — see below | | `sent_at` | yes | — | Your clock, unix **milliseconds** | | `url` | no | 1000 bytes | Where the run's log lives (http/https) | | `summary` | no | 2000 bytes | One sentence for a human | Change-scoped only: | Field | Required | Bound | What it is | |---|---|---|---| | `change` | — | 72 bytes | The change key, e.g. `I1a2d0001`. Its presence is what makes the report change-scoped | Commit-scoped only — all optional, and all of them are things the Checks tab shows or filters on, so a report that omits them lands as a row with blanks where a reader expects a branch and a duration: | Field | Bound | What it is | |---|---|---| | `external_id` | — | **Your** id for the run. Send it. Without one the row is keyed on `(commit, name)`, so a genuine re-run of one workflow overwrites the first instead of updating the right row | | `ref` | — | The branch or tag it ran for. Named `ref` because that is what git calls it and what your CI's environment already exports | | `run_number` | — | The counter a person recognises in your CI's own UI. Not an identity here; `external_id` is | | `event` | — | What triggered it — `push`, `pull_request`, `schedule` | | `actor` | — | Who it ran for | | `started_at`, `completed_at` | — | Unix milliseconds. Omit rather than sending `0`, which renders as January 1970 — a confident wrong answer | Every bound is a refusal, not a truncation: a summary one byte too long is answered `400`, because a summary silently cut in half is a summary that lies about what happened. `name` is the identity of the check. Posting `ci/tests` again replaces the previous `ci/tests` on that patchset — a check is current state, not a journal. Post the start of the run and then its real state, and the page follows along. ### `state`, which differs between the two shapes This is the one place the shapes genuinely disagree, and it catches people: | Shape | Accepted | |---|---| | Change-scoped | `pending`, `passing`, `failing` | | Commit-scoped | `queued`, `running`, `passing`, `failing`, `cancelled`, `skipped` | Note that **`pending` is change-scoped only** and **`queued`/`running` are commit-scoped only**. The land gate has three states because a reviewer only needs to know "not yet, yes, or no"; a repository's history of runs carries the provider's own words, including the two that mean a verdict was never reached. An unrecognised word is refused by name — never read as a default, because a silent green ships code nobody checked and a silent red blocks code that is fine. The refusal names the shape it judged you under and what the other shape would have taken, so if you sent `pending` on a push you are told where `pending` is legal rather than left to conclude we have no such state. ### Why `commit` is required A new patchset starts with no checks at all, and this is the field that keeps it that way. If your run started on patchset 3 and the author pushed patchset 4 while it was going, the verdict that arrives is about code that is no longer under review; Weft answers `409` and names both commits rather than marking the new patchset green on the strength of a build of the old one. For the same reason `sent_at` must be within five minutes of our clock, and an identical body is accepted only once. A captured request is worth nothing five minutes later. ### What comes back | Status | Meaning | |---|---| | `201` | Change-scoped only: first report under this name on this patchset | | `200` | Change-scoped: an existing check of that name was updated. Commit-scoped: every report, first or not — the write is an upsert, re-reporting a run is the ordinary case, and the route does not claim `Created` for what may be an update | | `400` | A field is malformed or over its bound; the body names it | | `404` | No such repository, no secret configured, or the signature did not match | | `409` | Stale commit, replayed body, skewed clock, or an abandoned change | | `413` | The body is over 16 KiB | The `404` is deliberately one answer for three different failures. Telling them apart would let anyone learn whether a private repository exists by reading a status code, which is the thing every other route here is careful not to do. If you are debugging a `404`, check all three. ## Provider snippets All four are the same request with that provider's variable names substituted, and all four are **commit-scoped**: no change key, works on a push and on a review alike, and it lands on the Checks tab, the badge and the review page at once. The optional fields are not decoration. `ref`, `event` and `actor` are what the Checks tab filters on, `started_at`/`completed_at` are what its duration column reads, and `external_id` is what makes a re-run *update* its row instead of overwriting a different one — without it the row is keyed on `(commit, name)`. ### GitHub Actions ```yaml - name: Report to Weft if: always() env: WEFT_URL: https://api.weft.sh ORG: acme REPO: app WEFT_CI_SECRET: ${{ secrets.WEFT_CI_SECRET }} COMMIT: ${{ github.sha }} STATE: ${{ job.status == 'success' && 'passing' || 'failing' }} REF: ${{ github.ref_name }} EVENT: ${{ github.event_name }} ACTOR: ${{ github.actor }} RUN_ID: ${{ github.run_id }} RUN_NUMBER: ${{ github.run_number }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} SUMMARY: GitHub Actions run ${{ github.run_number }} run: | BODY=$(printf '{"commit":"%s","name":"ci/tests","state":"%s","ref":"%s","event":"%s","actor":"%s","external_id":"%s","run_number":%s,"url":"%s","summary":"%s","sent_at":%s000}' \ "$COMMIT" "$STATE" "$REF" "$EVENT" "$ACTOR" "$RUN_ID" "$RUN_NUMBER" "$RUN_URL" "$SUMMARY" "$(date +%s)") SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$WEFT_CI_SECRET" | sed 's/^.*= //') curl -sS --fail-with-body -X POST "$WEFT_URL/v1/orgs/$ORG/repos/$REPO/ci/checks" \ -H "Content-Type: application/json" \ -H "X-Weft-Signature-256: sha256=$SIG" \ --data-binary "$BODY" ``` ### CircleCI ```yaml - run: name: Report to Weft when: always environment: WEFT_URL: https://api.weft.sh ORG: acme REPO: app command: | STATE=$([ "$CIRCLE_JOB_STATUS" = "success" ] && echo passing || echo failing) COMMIT="$CIRCLE_SHA1" REF="$CIRCLE_BRANCH" EVENT=push ACTOR="$CIRCLE_USERNAME" RUN_ID="$CIRCLE_WORKFLOW_JOB_ID" RUN_NUMBER="$CIRCLE_BUILD_NUM" RUN_URL="$CIRCLE_BUILD_URL" SUMMARY="CircleCI build $CIRCLE_BUILD_NUM" BODY=$(printf '{"commit":"%s","name":"ci/tests","state":"%s","ref":"%s","event":"%s","actor":"%s","external_id":"%s","run_number":%s,"url":"%s","summary":"%s","sent_at":%s000}' \ "$COMMIT" "$STATE" "$REF" "$EVENT" "$ACTOR" "$RUN_ID" "$RUN_NUMBER" "$RUN_URL" "$SUMMARY" "$(date +%s)") SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$WEFT_CI_SECRET" | sed 's/^.*= //') curl -sS --fail-with-body -X POST "$WEFT_URL/v1/orgs/$ORG/repos/$REPO/ci/checks" \ -H "Content-Type: application/json" \ -H "X-Weft-Signature-256: sha256=$SIG" \ --data-binary "$BODY" ``` ### Buildkite ```yaml steps: - label: "Report to Weft" depends_on: tests allow_dependency_failure: true env: WEFT_URL: https://api.weft.sh ORG: acme REPO: app command: | STATE=$(buildkite-agent step get outcome --step tests | grep -q passed && echo passing || echo failing) COMMIT="$BUILDKITE_COMMIT" REF="$BUILDKITE_BRANCH" EVENT="$BUILDKITE_SOURCE" ACTOR="$BUILDKITE_BUILD_CREATOR" RUN_ID="$BUILDKITE_BUILD_ID" RUN_NUMBER="$BUILDKITE_BUILD_NUMBER" RUN_URL="$BUILDKITE_BUILD_URL" SUMMARY="Buildkite build $BUILDKITE_BUILD_NUMBER" BODY=$(printf '{"commit":"%s","name":"ci/tests","state":"%s","ref":"%s","event":"%s","actor":"%s","external_id":"%s","run_number":%s,"url":"%s","summary":"%s","sent_at":%s000}' \ "$COMMIT" "$STATE" "$REF" "$EVENT" "$ACTOR" "$RUN_ID" "$RUN_NUMBER" "$RUN_URL" "$SUMMARY" "$(date +%s)") SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$WEFT_CI_SECRET" | sed 's/^.*= //') curl -sS --fail-with-body -X POST "$WEFT_URL/v1/orgs/$ORG/repos/$REPO/ci/checks" \ -H "Content-Type: application/json" \ -H "X-Weft-Signature-256: sha256=$SIG" \ --data-binary "$BODY" ``` ### GitLab CI ```yaml report-to-stratum: stage: .post when: always variables: WEFT_URL: https://api.weft.sh ORG: acme REPO: app script: - | STATE=$([ "$CI_JOB_STATUS" = "success" ] && echo passing || echo failing) COMMIT="$CI_COMMIT_SHA" REF="$CI_COMMIT_REF_NAME" EVENT="$CI_PIPELINE_SOURCE" ACTOR="$GITLAB_USER_LOGIN" RUN_ID="$CI_PIPELINE_ID" RUN_NUMBER="$CI_PIPELINE_IID" RUN_URL="$CI_PIPELINE_URL" SUMMARY="GitLab pipeline $CI_PIPELINE_IID" BODY=$(printf '{"commit":"%s","name":"ci/tests","state":"%s","ref":"%s","event":"%s","actor":"%s","external_id":"%s","run_number":%s,"url":"%s","summary":"%s","sent_at":%s000}' \ "$COMMIT" "$STATE" "$REF" "$EVENT" "$ACTOR" "$RUN_ID" "$RUN_NUMBER" "$RUN_URL" "$SUMMARY" "$(date +%s)") SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$WEFT_CI_SECRET" | sed 's/^.*= //') curl -sS --fail-with-body -X POST "$WEFT_URL/v1/orgs/$ORG/repos/$REPO/ci/checks" \ -H "Content-Type: application/json" \ -H "X-Weft-Signature-256: sha256=$SIG" \ --data-binary "$BODY" ``` ### Reporting the start of a run too Optional, and worth it on a slow suite so the review page says "running" rather than nothing. Send the same body with `"state":"running"` before the tests, and the same `external_id`, which is what makes the second report update the first rather than adding a row beside it. Note the vocabulary: commit-scoped uses `queued` and `running`, where a change-scoped report would use `pending`. Sending `pending` here is answered `400`, and the refusal says where `pending` is legal. ### Reporting against the change instead Only if you want a verdict pinned to the patchset rather than the sha. Replace the `"ref"`/`"event"`/`"actor"`/`"external_id"`/`"run_number"` fields with `"change":"$CHANGE"`, use `pending`/`passing`/`failing`, and derive the key from the commit's own trailer: ```bash CHANGE=$(git log -1 --format=%B "$COMMIT" | sed -n 's/^Change-Id: *//p' | tail -1) ``` If that comes back empty the commit has no trailer, Weft minted a key your CI cannot compute, and the commit-scoped report above is the one to send — which is why it is the default here. ## Making a check required Everything above gets a verdict onto the page. This is the step that makes the verdict *matter*, and it is a different rule from the one most readers assume. By default a change lands as soon as it is approved. A `failing` check blocks it — that much is automatic, and needs no configuration. But a check that has **not reported at all** blocks nothing, because as far as the gate is concerned there is nothing to fail. "CI must not have failed yet" and "CI must pass" are different promises, and only the second one keeps a broken build off your trunk. Naming a check as required is what turns the first into the second. It needs a **protected branch** first — on a branch anyone can push to, the land queue is not the only road in and a requirement is bypassable, so the request is refused rather than giving you a fence with no field behind it: ``` 409 branch "main" is not protected: protect it first, or a required check is one anyone can push past ``` Then: ```sh curl -sX POST "$STRATUM/v1/orgs/acme/repos/app/required-checks/main" \ -H "authorization: Bearer $TOKEN" \ -H 'content-type: application/json' \ -d '{"name": "ci/tests"}' ``` Or, without the shell: **Settings → Branch policy**, under the protected branch — which is why the list lives there and not in a picker of its own — where the field suggests the check names this repository has actually reported so a typo is harder to make. The branch rides in the path as a trailing catch-all, so a branch name with slashes in it (`release/2.0`) goes in whole and unescaped. The check name rides in the body, because it usually has a slash in it too (`ci/tests`) and cannot be a path segment. `GET` the same path to list them; `DELETE …?name=ci/tests` to stop requiring one. The name is matched against the check's `name`, which is the same namespace both writers use — the intake and the Actions poller — so `ci/tests` from your pipeline and `ci/tests` polled from GitHub are the same requirement. ### Waiting is not blocking A required check that has not reached a verdict does **not** eject the change. It holds it in the land queue: | Required check | The queue | |---|---| | `passing` | lands | | `failing`, `cancelled` | ejects, naming the check | | `skipped` | ejects — "must pass" does not admit "did not run" | | `pending`, `queued`, `running` | waits | | has never reported | waits | That last row is the ordinary case, not the exotic one: push, press Land, CI has not started yet. Ejecting there would make you press Land a second time once the build went green, which is most of what the queue exists to abolish. The change page says what it is holding for while it waits. The hold is bounded. After `STRATUM_LAND_WAIT_SECS` — **30 minutes** by default, and worth raising for a test matrix that takes two hours — the queue gives up and ejects with the reason: ``` ejected: waited 30m0s for ci/tests, which never reported ``` which is also how you find out you required `ci/tets`. A required name nothing ever reports is legal and silent: it holds every change on that branch until the budget runs out. ## 3. The badge ```markdown [![build](https://api.weft.sh/v1/orgs/acme/repos/app/badge.svg)](https://api.weft.sh/dashboard/acme/app/changes) ``` Add `?branch=release-2` for a branch other than the default. Wrap it in a link, as above. A badge nobody can click is a dead end: the reader has just been told the build is red and has nowhere to go. Both GitHub's own badge generator and shields.io hand you linked Markdown for the same reason. It is drawn to the same geometry as every other badge in your README — 20px tall, 3px corners, 11px Verdana, 5px of padding either side of each word — so `build | passing` comes out 88 pixels wide, which is exactly what shields.io serves. It will not be the odd one out in the row. The SVG is rendered by Weft. There is no shields.io in the path and no outbound request of any kind — a badge is fetched by every reader of your README, and that is not a log to hand to a third party. It reports the checks on the **most recent change that landed on the branch**, which is the last thing to reach that branch through review: | Colour | Meaning | |---|---| | green `passing` | Every check on that change passed | | red `failing` | At least one failed | | amber `pending` | Something is still running | | grey `no status` | Nothing has landed on that branch, or nothing reported | An open change never colours the branch's badge — it is not on the branch yet, and a red review making trunk look broken is the expensive direction to be wrong in. A branch with nothing landed is honestly grey, never green. **Post-landing runs work, and they are why the badge can go red at all.** Landing already refuses a change with a failing check, so a badge that only ever looked at what passed on the way in could never turn red. Point your nightly or post-landing suite at the landed change — naming its landed commit, which is the branch tip — and trunk breaking after the fact shows up where readers see it. A public repository's badge is readable by anyone, which is the point. A **private** repository's badge is masked exactly as the repository is: a stranger gets the same answer they would get for a repository that does not exist. There is deliberately no badge that says "private" — that badge would confirm the repository exists. The badge carries `Cache-Control: max-age=60`. A badge that caches for a day is a badge that lies. ## What this deliberately is not Through this intake, Weft holds a verdict and not a *run*. Your build happened somewhere we cannot see, and the fields in the request are everything we will ever know about it. If you are coming from GitHub Actions, here is what stays on your CI's side, so you can plan around it rather than discover it — with, for contrast, what a hosted [workflow](/docs/workflows/) has, since that one *does* run here: | On GitHub, a failing check offers | Through this intake | With a hosted workflow | |---|---|---| | Re-run job / re-run failed jobs | Nothing. Re-run it where it ran, and post the new verdict. | Not yet. Push again, or cancel and push again. | | Full logs, searchable, per step | Nothing. `url` points at your CI's log page. | The whole log, `text/plain` or streamed live as it is written. | | Inline annotations on the diff | Nothing. A `summary` sentence, kept in the audit trail. | Nothing. | | Build artifacts | Nothing. | Nothing. | | A job-summary panel the run writes | Nothing. | Nothing. | | The workflow graph, per-job timing | Nothing. | The run's jobs, their `needs`, and each job's start and finish, over the API. | Everything in the middle column needs us to be running your code, and through this path we are not. What this path gives you instead is the part your CI cannot do for itself: the verdict gating the land queue, sitting beside the human approvals, on a credential that cannot touch your repository. One consequence worth stating plainly: **the `url` field is the whole escape hatch.** It is the only route from a red check back to the thing that went wrong, so a delivery without one leaves a reviewer with the word "failing" and nowhere to go. Set it. (A hosted workflow's rows fill it in for themselves: they link to the run's page here, which is where its log lives.) ## Verifying deliveries the other way If you want Weft to tell *your* CI when something happens, rather than the other way round, that is [webhooks](/docs/webhooks/) — outbound push events, signed with the same `X-Weft-Signature-256` scheme. The two directions use one signing scheme on purpose. Checks feed the land queue: see [Changes, OWNERS & landing](/docs/code-review/) for how a failing check interacts with human approvals. # The contribution graph **Half of this ships today, and the halves are worth separating before you read on.** Verified email addresses — the substrate all of this rests on — are live: adding an address and confirming it from the link works now, and the endpoints in *Confirming the addresses you have committed under* below are real ones you can call. The graph itself — the walk over your history, the rendered squares, the private aggregate, the agent handling — is designed and **not built**. Nothing in the sections after that one is running yet. We publish the design because it decides things you may be acting on now, like which addresses are worth confirming. The design has one idea in it. A graph here is to be derived from git history rather than from things you did on this website, and a commit counts only when we can prove you wrote it. Everything below follows from those two sentences. ## What is designed to count, and what is not A commit is to be attributed to an account by one of exactly two proofs: | Proof | Meaning | |---|---| | `email-verified` | the commit's author address matches an address confirmed on that account | | `pushed-by` | the principal that pushed the commit is that account | Everything else is `unverified`, and unverified is to count for **nothing** — no square, no total, no streak. That is the anti-gaming rule, written as a rule rather than as a heuristic. It has to be. An author line is a string anybody can put in `git config user.email`, and a graph that counted claims would be a graph you could forge in one command by committing under a stranger's address. That much is not merely designed. The lookup that answers *whose work is a commit authored by this address* exists today, with the confirmation requirement inside it rather than in each caller, and it returns nothing for an unproved address. It ships ahead of its consumer deliberately: the rule belongs with the table it protects, not with the code that will lean on it later. The consequence people notice first is the good one: anyone may commit under any address they like without inheriting anybody's history, and you lose nothing by having committed under six addresses across four jobs. ## Confirming the addresses you have committed under **This part works today.** List what the account already owns: ```bash curl -H "Authorization: Bearer $TOKEN" \ https://api.weft.sh/v1/users/alice/emails ``` Add one, and confirm it from the link that arrives in that mailbox: ```bash curl -X POST -H "Authorization: Bearer $TOKEN" \ -H 'content-type: application/json' \ -d '{"email":"alice@oldjob.example"}' \ https://api.weft.sh/v1/users/alice/emails ``` ```json { "address": "alice@oldjob.example", "status": "check your email", "detail": "Confirm the address from the link we sent it. Until then it counts for nothing." } ``` The link is the only proof, so it goes to the mailbox and never into the response — a claim you could confirm over the API would prove nothing about the mailbox. Addresses are private by default: they are what authorship will be resolved through, and only their owner can read, add or remove them. An address already in use elsewhere is refused with a `409` that deliberately carries no name, because whose address it is, is exactly what a stranger must not be able to ask. Confirming addresses now is work banked rather than work wasted. The walk, when it exists, runs over history that is already stored, so it has nothing to wait for except itself. ## Private repositories, as designed Everything from here down is design. None of it is running. Work in a private repository is to be an **opt-in aggregate day count**, and the aggregate is to be all there is: no repository name, no commit title, no link, at any zoom level, to any viewer. Off by default. The one piece of it that exists today is the profile setting itself, which currently governs nothing because there is no graph for it to govern. The response is to carry `private_included` so that a reader can tell a quiet week from an opted-out one. A blank stretch that might mean either is a worse answer than a stated one. If a repository's visibility flips, the rows are to be rebucketed — raw authorship kept rather than only the totals, precisely so a private repository going public can render its history and a public one going private can stop rendering it. ## Agent-authored commits, as designed An agent is its own principal with its own identity, and it never borrows a human's name — that rule is in force today for service principals, and it is what the design leans on. Commits authored by an agent are to render distinctly and never to inflate a human's graph, including the graph of the person who was running it. An agent principal is to have a graph of its own, and that graph is an operator's audit trail rather than a résumé. Agent authorship is to be read from the commit's trailers, using the same trailer parser [`Change-Id`](/docs/code-review/) is read with — one grammar, one implementation, rather than a second one written for this. ## Why the design is portable Because it derives from commits rather than from platform events, a graph built this way moves in both directions. **In:** mirror a repository here and its history is already stored, so the walk has a decade of commits available to it from the day the mirror lands rather than from the day you signed up. Mirroring the git content works today; the walk over it does not exist yet. **Out:** the inputs are in your clone, and this needs nothing from us at all. You can compute the same numbers yourself right now, with no API and no account: ```bash git log --all --format='%aI %aE' \ | grep -Fi 'alice@oldjob.example' \ | cut -dT -f1 | sort | uniq -c ``` That is the point of computing it this way. A contribution graph that only one company can compute is a lock-in mechanism wearing a résumé's clothes; this one is a view over data you hold a copy of, which is the same argument the [escape hatch](/docs/export/) makes about the repositories themselves. ## Not built, and not planned as part of this: signature verification Commit signature verification — GPG or SSH — is not part of this design, and is not on this page as a coming-soon. Signatures are part of the commit object, so they travel with the history and verify with stock git on any clone we serve. What we do not do is check one for you, or render a verified badge from one. Doing that means a per-user key store and a trust model: which keys an account vouches for, what happens when one is rotated, what a signature made by a key that has since been revoked means for a commit from three years ago. That is a design job with real answers required, not a flag to turn on. `email-verified` and `pushed-by` give a graph that cannot be forged by editing a config file, so the graph is not waiting on it, and nothing here blocks adding it later. # Forks and the contribution flow A fork is how somebody with no push credential contributes at all. Here it is a **new repository in your namespace that shares the upstream's stored objects** until the two histories diverge: creating one takes milliseconds and occupies nothing until you push something upstream does not have. A contribution is then an ordinary [change](/docs/code-review/) against the upstream whose commits happen to live in your fork, and it lands through the same `OWNERS` sufficiency, required checks and serialized land queue as a colleague's. There is no second path. Two things this page describes as designed and not built — bringing a fork up to date with its upstream, and [the maintainer firewall](/docs/maintainer-firewall/) — are called out as such [at the end](#what-is-not-built). ## What a fork is `POST /v1/orgs/{org}/repos/{repo}/forks` creates one. You may fork what you may read; where it goes is up to you: ```sh curl -X POST "$STRATUM/v1/orgs/ada/repos/cantor/forks" \ -H "Authorization: Bearer $TOKEN" \ -d '{"org": "bob", "name": "cantor"}' ``` Both body fields are optional — `org` defaults to your personal namespace and `name` to the source's name — so the common case is a button with nothing to fill in, which is what the dashboard's **Fork** button is. Creating in the target namespace is gated exactly as any other repository creation is: you need `repo:write` there, a verified address, and a plan that admits what you are creating — a private source asks the target to hold a private repository, so forking one into an organization on the free plan is refused with the same `402 quota:` sentence a private create gets. The free-tier repository cap counts forks too: a `free` namespace that is at its cap is answered `402 quota:` and nothing is created. The sentence says what the reader can actually do about it, which is not the same thing in the two places a cap is met: an organization is told `the free plan is limited to N repositories — subscribe from Billing to create more`, and a personal namespace — which is always `free`, has no billing page, and cannot be subscribed — is told `a personal namespace holds up to N repositories — create an organization to hold more`. Being sent to Billing for a namespace that has none is a dead end, and it read as the product being broken rather than as a limit. Pressing Fork on something you already forked still hands that fork back at the cap, because nothing is being made. Forking is something a signed-in person does; a service token is answered `401`. The answer is **`202`, not `201`**, and the difference is honest rather than pedantic. The repository row exists the moment the call returns; the storage pointers that make it readable are written by a job a moment later. Claiming `Created` would let you clone immediately and get an empty repository with no explanation. Instead the repository view carries `fork_state`: `pending`, then `ready` — or `failed`, because a fork that says it is broken beats one that silently serves nothing. `fork_parent` is the `owner/name` it came from, and the dashboard prints it under the repository name as "Forked from …", the way every contributor expects. It is named to anyone who may read the upstream — public or not — and is `null` to a reader who may not, so a private repository is never named to somebody who could not otherwise know it exists. Forking a repository you have **already forked** into the same place answers `200` with the fork you have, not a second copy and not an error: pressing **Fork** on a project you forked last month takes you to your fork, as it does on GitHub, and the dashboard says that is what happened. The name being taken by a repository of yours that is *not* a fork of this one is a real collision, answered `409` with the repository that is in the way and the way past it — `bob/widget already exists and is not a fork of ada/widget; fork it under another name`. The fork has its own name, its own permissions, its own push path over [HTTPS and SSH](/docs/ssh/) and its own [audit trail](/docs/audit-and-undo/). Your pushes are new data in a repository you own. Nothing about it is a view onto upstream that upstream can change out from under you: the storage a fork refers to is pinned for as long as the fork refers to it. ### Visibility is not negotiable across a fork A fork **inherits its source's visibility**. A fork of a private repository is private, and it cannot be made public while its root is private — the request is refused with `403 a fork of a private repository cannot be made public`, not warned about. A fork shares the upstream's bytes without ever having copied them, so publishing it would publish the repository it came from to anonymous readers. The check is against the fork **root**, not the immediate parent, so it holds down a chain of forks. Making a fork *private* is always allowed. ### Counting forks `GET /v1/orgs/{org}/repos/{repo}/forks` lists the repositories forked directly from this one, and `count` is the number **you may see** — always the length of the list, never a stored total. A public repository's forks can be made private afterwards, and publishing the stored number would tell every visitor exactly how many private forks exist. The dashboard's fork count reads the same way. ### Deleting an upstream does not delete its forks You may delete a repository that has forks, as on GitHub; what differs is that the forks survive it. Every fork still reading the deleted repository's data is **promoted** onto storage of its own first, and only when the last reference is released does the upstream's storage become sweepable. A promoted fork's `fork_parent` is `null` — the same answer as "not a fork" and as "an upstream you may not see", deliberately, so that the field never leaks the existence of a repository you cannot read. ## The contribution flow The flow is the one every open-source contributor already knows: 1. **Fork** the repository you want to change. 2. **Push** to your fork — a clone and a push, over HTTPS or SSH, like any other repository. 3. **Open a change** against upstream, naming your fork as its `source`. Step two is an **ordinary push to a repository you own**. There is no special case in push authorization for forks, no borrowed permission on upstream and no staging area with its own rules: you have write access to your fork, which is why the push works; you do not have write access to upstream, which is why a direct push there is refused. Forking adds no case to push authorization at all: to the upstream, a forker is a reader, and what a reader meets on both transports is what the forker meets. A reader **reads** — your own token or SSH key clones and fetches the upstream, which is how you bring your fork up to date — and a reader's push is refused with the reason, over HTTPS and SSH alike: `you can read ada/cantor but not push to it; fork it and open a change from your fork, or ask an owner for write access`. It used to be refused as `repository not found`, the answer meant for a repository you cannot read, and a contributor who had cloned it a minute earlier was left checking the URL for a typo. Step three is `POST /v1/orgs/{org}/repos/{repo}/changes` with `source`: ```sh curl -X POST "$STRATUM/v1/orgs/ada/repos/cantor/changes" \ -H "Authorization: Bearer $TOKEN" \ -d '{"from": "fix-empty-config", "target": "main", "source": "bob/cantor"}' ``` With `source` set you need only **`repo:read` on the target** — which on a public repository every signed-in person and every personal token holds, whatever namespace it was minted in. The change is yours: it is recorded with you as its author, the review's association badge calls you `first-time` until something of yours has landed there and `contributor` after, and you take part in the review as yourself — comment, tick off the files you have read, revise, and withdraw it (`POST …/abandon`) if you change your mind, which otherwise takes write access. Without `source` you still need `repo:write`, because commits that are already in the target could only have got there through an authorised push; a reader who tries is answered `403` with a sentence that names forking as the way through. `source` must be `owner/name` — a bare name is ambiguous between "a repository in this org" and "a namespace" — it must be readable by you, so a private fork is masked here exactly as it is everywhere else, and it must actually be a fork of the repository being targeted. That last check is not tidiness: landing copies objects out of the source into the target, so an arbitrary repository named here would be a request to move somebody else's bytes into a project they do not own. The dashboard's Changes tab offers the same thing as **Propose a change** to anyone who can read the repository but not push to it, with a field for the fork; readers with write access see **Start a review** instead. The server says which you are (`viewer_write` on the repository view), so the form is never offered to somebody who will be refused after filling it in. ### The commits arrive when the change opens Your commits are copied into the upstream **the moment the change is registered**, onto `refs/staged/` — outside `refs/heads/`, so it is not a branch, does not appear in the branch list and no protection rule applies to it. GitHub does the same with `refs/pull/N/head`, for the same reason: the diff, `OWNERS` resolution and the land verdict then all read one repository rather than two. Deferring the copy to landing was tried first and failed exactly where you would expect — approval resolves `OWNERS` at a commit that was still in the fork. A second patchset moves only what is new; re-registering the same tip moves nothing. The staged ref stays after the change is abandoned, so the objects of a change that was approved and then ejected are not swept while you are still working on it. Everything downstream is identical to internal work: [patchsets, `OWNERS` sufficiency, required checks and the land queue](/docs/code-review/). The Land button on a change from a fork *is* the queue, which is how a protection rule and an approval requirement mean the same thing on a contribution from a stranger as on one from a colleague. A change's `source` is reported on the wire as `owner/name`, and stays `null` for a change that did not come from a fork — and for a landed change whose fork has since been deleted, because a landing that happened is still a real thing that happened. ### Workflows from a fork are held until a maintainer says so A change whose commits come from a fork does **not** run its workflows. The workflow file was written by the contributor, and running it would hand a stranger a `repo:read` token and a machine — a fork's change is somebody else's code on your hardware. Its runs are recorded as `blocked` with `blocked_reason: fork`, its check rows stay `queued` rather than failing because nothing is wrong with the commit, and a maintainer who could land the change releases them with `POST …/changes/{change}/workflows/approve`. Approval is **per tip**: a new patchset from the fork is blocked again. In a [changeset](/docs/changesets/) one unapproved fork member holds every member's composed run, because a composed job checks out all of them. The full rules, including the organisation-level block that no maintainer can approve away, are in [Workflows](/docs/workflows/#changes-pushed-from-a-fork). ## What a fork does not get to skip Sharing upstream's objects buys speed, not trust. Objects pushed to a fork go through the same quarantine, the same fetch and hash-verification of thin bases, and the same connectivity and fast-forward walk as any other push; a fork does not skip verification on the grounds that the bases "already exist". Copying a change's commits into the upstream materialises both repositories and moves the objects with `git` itself rather than by arithmetic on packs — correct and expensive over clever and conditionally wrong, when being wrong means a repository that does not `fsck`. Every clone of a fork, and of an upstream a fork has landed into, passes `git fsck --full --strict`; the serving gates check it on every change to this path. ## What is not built **Syncing a fork from its upstream.** There is no request that brings a fork up to date with the repository it came from. Today that is a `git fetch` from upstream — your own credential reads it — and a push to your fork, in your own clone, which is also where the decision a divergent history forces, merge or rebase, is yours to make. When it is built it will be fast-forward only, refusing a diverged fork in the [land queue's](/docs/code-review/) house style; a one-click button that silently picked one would sometimes rewrite your work. **Following the upstream's storage.** A fork keeps referring to the upstream's storage as it was when the fork was made, and that is deliberate: the upstream's data is pinned for the fork, and the fork never has to learn about a compaction it did not ask for. **The [maintainer firewall](/docs/maintainer-firewall/)** — staged intake in front of review — is designed and published as a design. A change from a fork reaches review the moment it is registered. # Importing from GitHub **The issue importer does not exist. This page is its design.** There is no import endpoint and no import job. Do not plan a migration date around this page; plan it around [the migration page](/migrate), which is the shorter per-artifact version of the same account and is equally careful about what is a plan. **What changed since this page was written:** the issue tracker it imports *into* now exists — file, comment, close, label, filter and sort, with filing gated on read access rather than write, because a tracker only writers can post to is closed to everybody it is for. So the missing half is the importer alone. **What does work today is the half that matters most and is pure git:** mirroring a GitHub repository's commits, branches and tags. That is [Mirror in 5 minutes](/docs/quickstart-mirror/), it is running now, and it is the step that moves your history. This page is about the things that are not in the repository — issues, their conversations, and the URLs that point at them. ## What an import would move - **Issues** — title, body, state, author, timestamps, open and closed - **Comments**, in the order they were written - **Labels**, with their colors and descriptions - **Milestones**, with due dates and state - **Reactions**, per issue and per comment - **Assignees** - **Cross-references** — a `#123` in one issue's body still pointing at the right issue here - **Old URLs** — a redirect map, so that a GitHub issue link in a mailing-list archive from 2019 resolves to its new home Conversations are to read in insertion order rather than in identifier order, because a conversation that reorders itself is not a record of anything. ## Import order, and why it would be that order The sequence is not an implementation detail, which is why it is on a page written before the code. Two of the steps are only correct in one position. 1. **Labels**, then **milestones**. Issues refer to them, so they have to exist before anything refers to them. 2. **Issues, ascending by original number.** This is the load-bearing one. Issue numbers are to be allocated from a per-repository counter, so importing in ascending order leaves that counter at the maximum imported number and the next issue filed natively is `max + 1`. Import out of order and native numbering collides with imported numbering — which would show up weeks later as two issues that both believe they are `#412`. 3. **Comments**, then **reactions**, then **assignees** — each attaching to an issue that by then exists. 4. **Cross-references last**, when every possible target has been created. A reference resolved earlier would have to guess at issues not yet imported, and a guess in a permanent record is worse than a plain `#123`. Everything arriving from the API is to be treated as untrusted input with caps on size and count, not as best-effort parsing of a friendly payload. ## Authors that cannot be mapped An author or commenter with no account here would keep a **display name** marked as coming from GitHub — `octocat (github)` — with their words attached to that name. They are never to be silently attributed to a local account, however closely a handle or an address matches. Handles are not identity across platforms; the person who holds `octocat` here need not be the person who held it there, and quietly merging the two puts words in somebody's mouth in a permanent record. If the real person later joins and confirms the address, the mapping can be made deliberately, by them. ## Pull requests would import as issues A GitHub pull request is to arrive here as an **issue**, carrying a link to the original and a redirect from its old URL: the conversation, labels, reactions and cross-references, but not the patchset. An imported PR would not be a reviewable, landable change here. This is a deliberate cut, and the reason is accuracy rather than effort. A pull request's head commits live in the contributor's fork, and for a merged or closed PR they frequently no longer exist anywhere — the fork is deleted, the branch is gone, and what remains is a diff GitHub rendered at the time. Reconstructing patchsets from that is guesswork dressed up as an import, and a review history that is 90% right is worse than one that is honestly a record. So they would arrive as what they now are: the written record of a discussion, with a working link to where it happened. Open pull requests a project still cares about would be re-opened as [changes](/docs/code-review/) here by their contributors, from the [fork](/docs/forks/) they came from. That is a real cost of a move, and [the migration page](/migrate) counts it as one. ## One door, because we have not launched An earlier version of this page described **two** doors, and the reasoning is worth keeping even though the answer changed. Reading issues from GitHub needs `issues: read` on top of the metadata and contents permissions a mirror already uses. Adding a permission to a *published* GitHub App requires **every existing installation to re-accept** it — an operational event for every customer, not a deploy. That constraint would have forced a token-paste import as the default, and a pasted token is a credential we would have to store: encrypted at rest, with a lifetime and a deletion, because any node in a fleet may claim the job and the token cannot live in the memory of the node that received it. **There are no installations in the wild yet.** So the permission goes on the App now, before launch, and the import uses the App like everything else does. That deletes the whole token subsystem — no paste, no at-rest secret, no expiry, no deletion path — because installation tokens are minted from the App private key that every node already has. The refusal that mattered stays: where an installation does **not** carry `issues: read`, the import refuses with a message naming the missing permission. It does not import an empty list and call it a success — an import that quietly produces nothing looks exactly like a project that never had issues, and that is the failure worth a specific sentence. This is a decision with a shelf life. The moment the App is published and installed by somebody outside this repository, adding a permission stops being free, and any further permission this importer turns out to need is a re-consent event. Anything it needs, it should ask for now. # The maintainer firewall **None of this is built. This page is a design, published as one.** There is no intake worker, no trust ladder and no style gate in the product today; a change reaching a repository today reaches review the moment it is registered. What *does* exist, and is unchanged by any of this, is the machinery the design sits in front of: [changes, patchsets, `OWNERS` approval sufficiency and the land queue](/docs/code-review/). We publish the design ahead of the build for the same reason this site publishes what the land queue cannot do as plainly as what it can — a maintainer deciding where to host a project is better served by a plan they can argue with than by silence. Read every "would" below as load-bearing. ## The shape: what would happen to an inbound change A change from an untrusted contributor would be admitted in stages, each either passing it along or refusing it in a sentence naming the rule it broke. It is not a score and not a filter you have to trust — the intent is four gates whose results you can read. **It applies.** The change must fast-forward the target branch. The design calls the same ancestry function the land queue already uses at claim time, at the door instead of at the end — one function, so that the firewall and the queue cannot disagree about whether something is landable. A change that could never have landed would never become a tab a maintainer has to close. **Checks pass.** Intake would read the `change_checks` your CI already posts — that reporting API exists today and is documented under [Changes, OWNERS & landing](/docs/code-review/) — and hold the change at the door until they are green. Nothing new is invented here; a red check would stop a change at intake for the same reason it already stops one at the queue. **It is not the fourth copy of the same patch.** A fingerprint over the normalized diff lines plus a hash of the sorted set of changed paths, compared against the recent open changes on the repository — bounded, not the whole history. Slop travels in herds, and six near-identical patches to one file should arrive as one thread with five siblings attached rather than as six separate demands on an evening. **It matches house style.** A declarative gate over the diff, read from a `.weft/style.toml` in the repository. The file is designed to look like this, and does nothing at all today: ```toml max_file_bytes = 262144 forbidden_paths = ["vendor/**", "*.min.js"] required_trailer = "Signed-off-by" line_endings = "lf" ``` Declared in the repository, evaluated at the door, and the refusal names the rule — so that a first-time contributor learns something rather than being ignored. Admission would be a **separate axis** from the land queue's state, deliberately. A change would have an admission decision and, independently, a place in the queue's lifecycle. The lander would re-check admission when it claims a change, in the same way it already re-checks approval sufficiency and failing checks, and refuse to land one that has not been admitted. Collapsing the two into one enum would make that re-check ambiguous, which is how a change gets landed on the strength of a decision that has since been withdrawn. ## The trust ladder Which gates a contributor would meet depends on what the repository already knows about them: | Rung | Who | What they would meet | |---|---|---| | `collaborator` | an owner, admin or member of the namespace, or anyone an `OWNERS` file already trusts with the paths they touched | nothing — straight to review | | `trusted` | landed changes behind them, no recent ejections | nothing — straight to review | | `first_timer` / `unknown` | everybody else | the full gauntlet above | | `blocked` | set by hand | refused at the door, both transports | The ladder would be derived, with a manual override in either direction. **The top rung is exactly today's behaviour, which is the point.** Inside a company every principal is already known, so the ladder collapses to its top rung and intake never fires. An existing private repository would therefore see no change at all when this lands — not a migration, not a new setting to turn off, no new step between a member and review. That is a constraint on the design rather than a happy accident: the firewall has to be the same machinery wearing open-source defaults, because a second product bolted on beside the first is how the first one rots. ## Agent policy, per repository The design has a repository declare how it wants to be contributed to by agents, with the platform enforcing it: | Policy | Intended effect | |---|---| | `welcome` | agent principals contribute like anyone else | | `labeled` | agent principals contribute, and their changes are labeled as theirs (the intended default) | | `human-only` | an agent principal is refused at both doors, with a sentence naming the policy | Enforcement, not documentation — that is the part worth designing. A policy that asks maintainers to police it themselves is a policy that costs maintainer attention, which is the resource this whole page exists to protect. The one piece already in force is that an agent never borrows a human's name to get past it: service principals have their own identity today. ## Two things this deliberately will not do These are cuts, not gaps waiting to be filled. The first is about what we will execute on behalf of a stranger; the second is about the rule that every test in this repository is hermetic. **The firewall will not run your lint command against a stranger's diff.** Executable gates on an *untrusted* patch — project-supplied commands, run on our servers, on code a person who is not a contributor just sent you — is a sandboxing product, not a forge feature, and shipping a half-built one is how a forge becomes an arbitrary-code-execution surface. This is why a change whose commits come from another repository is recorded as `blocked` and its [workflows](/docs/workflows/) do not start: they were written by the contributor, and running them would hand a stranger a repository token and a machine. There is no approval button yet. Branches in the repository itself are a different question, and their workflows do run here. **The server will never call a model.** There is to be no outbound model call anywhere in intake. An outbound call could not be tested hermetically, could not be reasoned about when the far end is slow or wrong, and would put your diffs in front of a third party — which would contradict [what we say about AI](/ai-policy). The design's replacement is an inbound seam: a triage report **posted to** the change by an agent principal holding a token, exactly like a CI check, landing as a gate row on the change. Model-assisted triage would then be an agent you run, under your policy, with your model, reporting in like any other machine — a deployment decision you make, rather than one made on your behalf inside our worker. # Running a self-hosted runner [Workflows](/docs/workflows/#self-hosted-runners) describes self-hosted runners from the workflow author's side: `runs-on: [self-hosted]`, labels, groups, and the organisation policy that admits them. This page is the other side — you have a machine, and you want Weft to be able to run jobs on it. The whole of it is one binary, `weft-runner`, which **asks for work and never waits to be asked**. It makes outbound HTTPS calls to your Weft URL and nothing else. It does not listen on a port, it does not need a public address, an inbound firewall rule, or a tunnel, and there is nothing to expose. Read [Isolating it](#isolating-it) before you put one on a machine that matters. A runner executes shell commands out of a repository, as whatever user it runs as, and every other decision on this page follows from that. ## Getting the binary There is **no download to `curl`, and no image on a public registry**. Both come from this repository, and the honest reason is that a runner is the one component you should want to have built yourself. **From source.** You need a Rust toolchain matching the one the image builds with (`rust:1.98-bookworm` today): ```bash # in a checkout of the Weft repository cargo build --release -p stratum-runner sudo install -m 0755 target/release/weft-runner /usr/local/bin/ ``` **As a container.** `Dockerfile.runner` at the repository root is the same image our own hosted fleet runs, and it is deliberately small: the binary, `git`, `curl`, `ca-certificates`, `build-essential`, `python3` and `jq` on Debian bookworm, running as an unprivileged `runner` user. No docker CLI and no docker socket — a runner that can talk to a daemon can escape its container — and no cloud CLIs. ```bash docker build -f Dockerfile.runner -t weft-runner:local . ``` If your builds need a toolchain that image does not have, that is a `FROM weft-runner:local` of your own. A self-hosted job runs its steps directly on the machine the runner is on, so whatever is on that machine's `PATH` is what the job gets — the `image:` key still means `default` and only `default`. ## Registering a machine Registration is a two-step exchange, and the two secrets are different things. An organisation admin mints a **registration token** — under **Settings → Runners → Add a runner**, or: ```bash curl -sS -X POST "$WEFT_URL/v1/orgs/$ORG/runners/registration-token" \ -b "$COOKIE_JAR" -H "Content-Type: application/json" \ -d '{ "group": "default" }' ``` ```json { "token": "weftg_…", "expires_at": 1800000000000, "group": "default", "command": "weft-runner register --url https://weft.sh --token weftg_…" } ``` That token is **single-use and lasts one hour**. It is the right to obtain a credential, not a credential — which is why it is safe enough to paste into a cloud-init script and short-lived enough that a leaked one is usually already spent. The machine exchanges it once: ```bash weft-runner register \ --url "$WEFT_URL" --token weftg_… \ --name build-01 --labels gpu,cuda-12 \ --dir /var/lib/weft-runner weft-runner run --dir /var/lib/weft-runner ``` | Flag | | |---|---| | `--url` | your Weft base URL | | `--token` | the registration token | | `--name` | defaults to the machine's hostname | | `--labels` | comma-separated, case-folded to lowercase; `self-hosted`, the OS and the architecture are added for you | | `--ephemeral` | take one job and exit — see [below](#ephemeral-runners-and-autoscaling) | | `--dir` | where `.runner` and the working directories live; defaults to `.` | `register` writes `DIR/.runner` with mode `0600`. That file holds the runner's own long-lived credential, so it is the thing to protect: back it up nowhere, and if it leaks, remove the runner from the list and register again. `run` then loops — ask for a job, run it, ask again — printing one line per job it takes and one per job it finishes. Running `register` again under the same name **replaces** that runner and kills the old credential; that is how rotation works, and there is nothing else to it. ## A systemd unit ```ini # /etc/systemd/system/weft-runner.service [Unit] Description=Weft self-hosted runner After=network-online.target Wants=network-online.target [Service] Type=simple User=weft-runner Group=weft-runner WorkingDirectory=/var/lib/weft-runner ExecStart=/usr/local/bin/weft-runner run --dir /var/lib/weft-runner Restart=always RestartSec=5 KillSignal=SIGTERM TimeoutStopSec=120 [Install] WantedBy=multi-user.target ``` ```bash sudo useradd --system --home-dir /var/lib/weft-runner --create-home weft-runner sudo -u weft-runner weft-runner register --url "$WEFT_URL" \ --token weftg_… --dir /var/lib/weft-runner sudo systemctl enable --now weft-runner ``` Two details in there are load-bearing: - **`SIGTERM` is a clean stop, and `TimeoutStopSec` has to allow for it.** On `SIGTERM` the runner kills the running job's process group, reports it `cancelled` so the check does not sit queued forever, and exits `0`. Give that longer than systemd's 90-second default if your jobs are large; a `SIGKILL` mid-job leaves a check waiting for a verdict nobody is going to send, until the server's own sweep fails it. - **`Restart=always` is right for an ordinary runner and wrong for an ephemeral one.** A removed runner exits `2` after printing `this runner has been removed; register it again`, and restarting it into that same exit is a loop that fills a journal. If you removed the machine deliberately, `systemctl disable --now` it. The unit deliberately carries no `ProtectSystem=` or `PrivateTmp=` hardening. Those are worth adding, but they are decisions about what your builds are allowed to touch, and a hardening line that silently breaks `make install` reads as Weft being broken. Add them knowing what your jobs do. ## Ephemeral runners, and autoscaling `--ephemeral` takes exactly one job and exits `0`, and the server removes the runner the moment that job reaches a terminal state. It is the only way to be sure a job cannot see what the previous job left behind, and it is what to reach for if you are scaling machines up and down. The shape that works is **a fresh instance per job, registering at boot**: something holding an `org:admin` credential mints a registration token, the instance's boot script exchanges it with `register --ephemeral`, `run` takes one job, and the instance terminates. The token being single-use and hour-long is what makes that safe to put in user-data. The shape that does not work is a systemd `Restart=always` around an ephemeral runner: its registration is gone after its one job, so the restarted `run` gets a `401` and exits `2`. Ephemeral means the *machine* is disposable, not just the process. An ephemeral runner that never comes back is removed from the list after **1 day** unseen; an ordinary one after **14 days**. ## What the runner needs from the network Outbound HTTPS to your Weft URL, and whatever your builds themselves reach. That is the list. | Direction | | |---|---| | **Outbound** | HTTPS to the Weft base URL you registered with — the claim loop, the job's log upload and its verdict — and the same host again for the `git` fetch of the repository | | **Inbound** | none. Nothing listens. The runner has no port, no health endpoint and no callback | `weft-runner run` talks to whatever URL you gave `register`, so that URL has to be reachable from the runner's own machine — a private hostname or a NAT-side address is entirely fine, and it does not have to be the server's public URL. The claim call is a **long poll**: the runner asks for a job and the server holds the request open for up to twenty seconds before answering "nothing" rather than replying instantly and being asked again. A proxy or load balancer between the runner and Weft needs an idle timeout above that or it will cut every empty poll, which looks like a runner that flaps between `online` and `offline`. If your egress goes through a proxy that terminates TLS, its CA has to be in the machine's own trust store — for the container image, that is the `extra_ca` build secret `Dockerfile.runner` already takes. ## Isolating it **A runner executes code from a repository, as the user it runs as, on the machine it runs on.** Nothing about the design changes that; the whole point of a self-hosted runner is running your build on your hardware. So the question is only ever *what would this cost me if a job were hostile*, and there are four answers worth having: 1. **A dedicated, unprivileged user with nothing of yours in its home.** Not your account, not `root`, and not a user that has an SSH key, a `~/.aws/credentials`, a kubeconfig or a signed-in package-registry token lying around. A job is a shell; everything that user can read, it can read. 2. **A machine, VM or container that only does this.** The runner's isolation between one job and the next is a fresh working directory and nothing more — a job can write outside it, leave a process running and start a daemon. A dedicated VM you can throw away, plus `--ephemeral`, is the version of this that actually holds. 3. **No ambient cloud credentials.** An instance profile, an IMDS endpoint or a mounted service-account token is reachable from any `curl` in any step. Our own hosted runners are given no task role at all, for exactly this reason. Block the metadata endpoint if the machine has one. 4. **Off the network you care about.** Give it internet and give it Weft; do not give it the route to your database, your internal registry or your admin panel. "It is behind the firewall" is what makes a self-hosted runner interesting to somebody else. And the setting that decides who gets to run code on it at all: > **Do not turn on "allow public repositories" for a group unless you > mean it.** Anybody can fork a public repository, and a fork's change > brings its own `.weft/*.yml`. The > [fork-approval gate](/docs/workflows/#changes-pushed-from-a-fork) > stands in front of it — a maintainer must approve each new tip — but > that is one human decision between a stranger and your machine, and a > group left closed is zero required decisions. The [mining watch](/docs/workflows/#what-is-refused-for-abuse) runs here too: a step caught running a miner has its process group killed and the job fails. It is protecting *you* in this direction, not our bill, which is why it does not also suspend your organisation's hosted workflows the way it does on our fleet. ## Removing one **Settings → Runners → Remove**, or: ```bash curl -sS -X DELETE "$WEFT_URL/v1/orgs/$ORG/runners/$RUNNER_ID" -b "$COOKIE_JAR" ``` The credential is dead immediately. The process finds out on its next call — there is nothing to signal, because nothing connects to it — prints `this runner has been removed; register it again` and exits `2`. A job that was running on it is **failed**, with `runner removed while the job was running`, and is not retried: removing a runner is a decision, and silently re-running the job somewhere else is not what the person who pressed the button asked for. Stop the process and delete `DIR/.runner` on the machine as well. The credential is already useless, but a file that reads like a live secret is a thing somebody will later assume is one. # Git over SSH Every repo is reachable over SSH as well as HTTPS: ```bash git clone ssh://git@ssh.weft.sh/acme/session-8412.git ``` The `ssh_clone_url` field on any repo response gives you the exact URL, or `null` if the deployment has not enabled the SSH door. SSH needs no domain and no certificate, so on a fresh deployment it is the fully-encrypted git path from the first minute — before DNS and ACM are sorted out. ## Registering a key A key never carries permissions of its own. It names something that does, and you choose which when you register it. **Your own key** — the normal case, and the one the dashboard offers under Settings → SSH keys. Sign in, paste the contents of your public key file, done: ```bash curl -X POST https://api.weft.sh/v1/orgs/acme/ssh-keys \ -b "$COOKIE_JAR" -H 'Content-Type: application/json' \ -d '{"public_key":"ssh-ed25519 AAAA… you@laptop","label":"laptop"}' ``` It signs in as *you*, so its authority is re-resolved from your role on every connection — per-repo grants included, in both directions. Change a role or a grant in the dashboard and the next `git push` from that laptop obeys it; there is no key to re-issue and nothing cached to expire. **Register it once, and it works in every namespace you belong to.** A personal key names a person, not a namespace, so one laptop key clones from your own repos and from every organization you have joined. Which one you are reaching is decided by the URL, where your membership is checked. Adding the same key a second time is refused — it is already yours — and revoking it revokes it everywhere, because there is only one of it. The key list is likewise one list. It is the same whichever organization's Settings page you open, because these are your keys and they reach everywhere you do; a list that changed per page would be a lie about what the key can do. **A deploy key** names a token instead, and inherits that token's scopes and repo binding. This is what an unattended machine wants, and creating one needs `org:admin`: ```bash curl -X POST https://api.weft.sh/v1/orgs/acme/ssh-keys \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -H 'Content-Type: application/json' \ -d '{"public_key":"ssh-ed25519 AAAA… deploy@ci", "token_id":"'"$TOKEN_ID"'", "label":"ci"}' ``` The response carries `fingerprint_sha256`, which matches what `ssh-keygen -lf key.pub` prints locally — check it if you want to be sure the server stored the key you meant. List and revoke: ```bash curl https://api.weft.sh/v1/orgs/acme/ssh-keys -H "Authorization: Bearer $TOKEN" curl -X DELETE https://api.weft.sh/v1/orgs/acme/ssh-keys/ -H "Authorization: Bearer $TOKEN" ``` An admin sees and can revoke every deploy key in the org; you see and can revoke your own personal keys. Revoking the key — or the token, the membership, or the account behind it — takes effect on the next connection. There is no cached session to outlive it. A disabled account reaches nothing: its keys stop authenticating, exactly as its tokens stop being credentials — the connection is refused before a repository is named, so a disabled key cannot even read a public one. A key that *is* live and simply has no role in a namespace reads that namespace's public repositories and is answered "not found" for everything else, the same masked answer a namespace you were never in gives, so a key cannot be used to map who still works where. ## What the SSH user name means: nothing Connect as `git@`, `root@`, or `anything@` — it makes no difference. **The key is the only credential.** The username is not a trust boundary and is never consulted, which is why the docs use `git@` purely by convention. A key that resolves to read-only authority cannot push, whatever it connects as; `git-receive-pack` is refused with an in-band error your git client prints as `remote error: weft: you can read acme/widget but not push to it; fork it and open a change from your fork, or ask an owner for write access`. The same key reads any public repository, in any namespace — a maintainer's key fetches a contributor's fork, a contributor's key fetches the upstream — and a repository it cannot read at all answers `repository not found`, whichever way it is asked. ## Host keys The server presents one fleet-stable host key. Every node presents the same one, so a client that pinned it on first connect keeps trusting the fleet across restarts, deploys, and scale events — a per-node key would look like a machine-in-the-middle attack to every user. Operators: this is `STRATUM_SSH_HOST_KEY`, and booting with an SSH bind but no host key is a deliberate startup failure rather than a generated-per-boot key. ## Protocol notes - **Protocol v2 is required.** git ≥ 2.26 sends it by default; if you have pinned `protocol.version=0` somewhere, the server tells you so in-band rather than misparsing the request. - **The same engine serves both transports.** Clones over SSH read the same immutable segments as HTTPS clones, and are `git fsck --full --strict` clean under the same test gate. - **CDN offload works over SSH too.** The advertised pack URL is HTTPS even when the negotiation rode SSH — see [CDN-offloaded clones](/docs/cdn-offload/). ## Limits - SSH serves **git only**. There is no shell, no SFTP, no port forwarding, and no command other than `git-upload-pack` / `git-receive-pack` is accepted. - Mirrors are read-only over SSH exactly as over HTTPS: a push is refused with the origin URL in the message. # Workflows Put a YAML file in `.weft/` and pushing runs it. Each job runs in a throwaway container on Weft's runners — or on a machine you registered yourself, if it asks for one — its output is a log you can read while it is still being written, and its verdict arrives on the commit's **Checks** tab as a check named after the job, where it gates landing exactly like a check posted by any other CI. This is one of two ways to get a verdict here. The other is [CI integration](/docs/ci-integration/): your own CI, wherever it runs, signing a result back. They coexist, and a change waits on both. The workflow file is deliberately a *subset* of GitHub Actions' syntax rather than a lookalike of it. Everything not implemented is **refused by name, with a line number**, and never accepted-and-ignored. A key that silently did nothing would be the worst outcome available: a job reported green for not having done what its author wrote. ## A complete example `.weft/ci.yml`: ```yaml name: ci on: [push, change] jobs: test: steps: - name: Build run: make build - name: Test run: make test ``` That is a whole workflow. On every push to any branch, and on every patchset of every change, one job called `test` runs two commands in a container holding your repository at that commit, and a check called `ci / test` appears on the commit. ## Where the files live | | | |---|---| | Directory | `.weft/`, at the repository root. Not configurable | | Extensions | `.yml` and `.yaml` | | Files read | the first **32**, in tree order | | Size | **64 KiB** per file | They are read **from the pushed commit**, not from the default branch: a workflow change is tested by the push that contains it, and a branch that has not landed yet runs its own version of the file. One file is one workflow, and one workflow is one run. `---` document separators, YAML anchors, aliases and `!` tags are all refused — anchors and aliases because they are how a small file expands into a very large one, and tags because everything in this subset is plain text and a tag that was honoured nowhere would only mislead. A duplicated key in a block is refused rather than resolved last-wins: a file with `steps:` twice has one of them doing nothing, and which one is not something anybody should have to work out. ## The keys **Top level:** `name`, `on`, `jobs`. A file with no `name:` is named after itself, so `.weft/nightly.yml` is the workflow `nightly`. **`on:`** takes `push`, `change`, `changeset`, or a list. `pull_request` is accepted as a spelling of `change`, so a pasted Actions workflow works. There is no `schedule` and no `workflow_dispatch`. `push` means a push that arrives through one of the three push doors — `git push` over HTTPS, `git push` over SSH, or `POST …/commits`. It does not mean a ref moving for some other reason: commits that reach a **mirror** by syncing from its origin start no run, and neither do tags. Only `refs/heads/*` triggers anything. `changeset` means a patchset of a [changeset](/docs/changesets/) this repository is a member of: the job runs with every member repository checked out beside this one. It is a different event from `change`, not a wider one — `on: [change, changeset]` asks for both, and a repository whose file says only `changeset` runs nothing on its own changes. See [Composed runs for a changeset](#composed-runs-for-a-changeset). **A job** takes `name`, `needs`, `image` (or `container`), `runs-on`, `env`, `strategy`, `timeout-minutes` and `steps`. **A step** takes `name`, `run` and `env`. `run` is required and is a shell command; a step with nothing to run is refused. ### `runs-on` `runs-on` picks the **pool** a job runs on, and there are two: Weft's runners, and machines you registered yourself. **Weft's runners** are `ubuntu-latest`, `ubuntu-24.04`, `ubuntu-22.04` and `linux`. All four mean the same thing — the ordinary Linux runner, which is the only hosted one there is — and the key is accepted only because those four are unambiguous. `macos-latest` and `windows-latest` are refused. Running a macOS job on Linux is not a smaller version of what was asked for; it is a different thing reported green. **Your own machines** are a `runs-on` containing `self-hosted`: ```yaml runs-on: ubuntu-latest # hosted, unchanged runs-on: self-hosted # your machines; any runner allowed to take it runs-on: [self-hosted] # the same thing runs-on: [self-hosted, linux, gpu] # …narrowed by labels ``` Every other entry in that list is a **label**, and the job goes to a runner whose own labels contain all of them. A label is letters, digits, dot, dash or underscore, at most 64 characters. Labels are **lowercased once, here**, and duplicates dropped, so `GPU` in the file matches `gpu` on the runner rather than being a job that never runs and an operator with nothing to look at; the order you wrote is kept, because that order is what the refusals below print back at you. What the labels mean is in [Self-hosted runners](#self-hosted-runners) below. The two do not mix inside one `runs-on`. Naming a hosted runner *and* `self-hosted` in the same list is refused: ``` `runs-on` names both a hosted runner and `self-hosted`; pick one ``` A list without `self-hosted` must be exactly one hosted label, and an unrecognised label is refused by name as it always was — the hint now also tells you about the `[self-hosted, …]` form, because "`gpu` is not a runner we have" is unhelpful to somebody who does have a GPU machine. Different jobs in the same file may use different pools. A file whose `build` runs here and whose `gpu-test` runs on your hardware is ordinary, and `needs` between them works exactly as it does within one pool. ### `image` `image:` and `container:` both name a container image, in either the scalar form (`container: node:18`) or the block form with an `image:` key. Both **parse**, but only `image: default` — which is what a job gets when it says nothing — runs on the hosted fleet. Anything else fails that job at dispatch with ``` image "node:18" is not available on hosted runners (only default) ``` so the file is legal and the job is not. A container block's `credentials`, `ports`, `volumes` and `options` are refused outright rather than dropped quietly. A [self-hosted](#what-a-self-hosted-job-gets) job has no container at all, so it is refused for naming an image rather than for naming the wrong one. ### What is refused, and what to write instead | You wrote | What happens | |---|---| | `uses:` on a step | Refused: "this forge does not run Actions". Run the command directly with `run:` | | `if:` on a job | Refused, "not supported yet". A job runs when everything it `needs` has passed, and that is the only condition there is | | `outputs:`, `defaults:` on a job | Refused, not supported yet | | `strategy.fail-fast`, `strategy.max-parallel` | Refused. Both are scheduler behaviour that does not exist here, and accepting them would be a lie | | `env:` at the top level | Refused. Job `env:` and step `env:` are the whole of it | | `shell:` on a step | Refused — see [the runner environment](#the-runner-environment) for what a step actually runs under | `uses:` is the one most people meet first, and it has no equivalent. A workflow here is shell commands; if an action did something you need, do that thing directly. ## `needs`, and what a failure does `needs` names other jobs in the same file. A job whose `needs` names a job that does not exist is refused with the list of jobs that do, and so is a cycle — a typo that silently ungated a job, or a run that hung forever, are both worse than a red push. A job starts when **every** job it needs has passed. When a job fails, is cancelled, or times out, everything downstream of it is marked `skipped`, transitively, with the reason *a job it needs did not pass* — except a dependent that was already running, which is doing real work and reports its own verdict. Readiness is computed, not stored, so a job becomes runnable the instant its last dependency passes. ## Matrices ```yaml name: ci on: push jobs: test: strategy: matrix: os: [linux] toolchain: ["1.83", "1.84"] exclude: - os: linux toolchain: "1.83" include: - os: linux toolchain: "1.85" flags: "-D warnings" steps: - run: ./test.sh $WEFT_MATRIX_TOOLCHAIN ``` Each cell is a separate job with its own container, its own log and its own check. A cell's name — in the run, in the log and in the check — is `id (v1, v2)`, values in the order the axes were declared: `test (linux, 1.84)`. - **`exclude` is applied first and `include` after**, following GitHub's documented order, so an `include` entry can put back a combination an `exclude` removed. An `include` merges into cells it does not contradict and is appended as a new cell where it cannot merge; it may add keys but never overwrites an axis value. - **A workflow expands to at most 256 jobs in total**, not per job. The cap is checked during expansion, so a matrix that would be enormous is refused cheaply rather than built and then rejected. - **A cell that `needs` a matrix job waits for all of that job's cells.** Pairing cells up by matching values would be a different feature. Every axis value is also in the environment as `WEFT_MATRIX_`, upper-cased with anything outside `[A-Z0-9_]` replaced by `_` — so `node-version` arrives as `WEFT_MATRIX_NODE_VERSION`. ## The runner environment This section describes a job on **Weft's** runners. Each job is one **isolated task, discarded when the job ends**. It is not a machine shared with your other jobs, and there is nothing left over from the previous one. On a self-hosted runner the machine is yours and that guarantee is yours to provide — see [what a self-hosted job gets](#what-a-self-hosted-job-gets). What the job can reach: - The repository, checked out at the pushed commit, in the working directory every step starts in. The fetch is the **one ref** the job is about, with `--no-tags`. - A **repository-read token minted for that one job**, held in the runner's own environment and never passed to a step. It expires with the job and is **revoked the moment the job reports its verdict**. - The public internet, and nothing of Weft's beyond the runner API it reports to. No database, no object store, no cloud credentials, no Docker socket. The task holds no IAM role at all. Every step runs as **`bash -e -c ''`** in the checkout, with a scrubbed environment — a step does **not** inherit whatever the container happened to hold. So each step is one bash script: `-e` means a multi-line `run:` block stops at its first failing command, and `set +e` turns that off if you want it to. There is **no `pipefail`** unless you set it yourself, so `a | b` reports `b`'s status and a failure in `a` passes silently — put `set -o pipefail` at the top of the block if that matters. Steps run in order and stop at the first failure. The log lists the ones that never ran, so a reader scrolling to the bottom of a failed job can see what did not get a chance. ### The environment a step sees | Variable | Value | |---|---| | `CI` | `true` | | `WEFT_CI` | `true` | | `WEFT_JOB` | the cell's name — `test`, or `test (linux, 1.84)` | | `WEFT_SHA` | the commit being built | | `WEFT_REF` | the branch: the pushed branch, or the change's target | | `WEFT_EVENT` | `push`, `change` or `changeset` | | `WEFT_CHANGE` | the change key — **only** on a change- or changeset-triggered run | | `WEFT_WORKSPACE` | absolute path of the directory the member repositories are checked out under — **only** on a changeset run | | `WEFT_CHANGESET` | the changeset key — **only** on a changeset run | | `WEFT_CHANGESET_MEMBERS` | JSON, described below — **only** on a changeset run | | `WEFT_MATRIX_` | one per matrix axis | | `PATH`, `HOME`, `LANG` | inherited from the runner | Those three inherited names are the entire allowlist. Then, in order, lowest first: the job's `env:`, then the step's `env:` — so a step's `env` beats the job's — and the matrix variables last under their own prefix where nothing can shadow them. ### What is in the image `git`, `curl`, `ca-certificates`, `build-essential`, `python3` and `jq`, on Debian bookworm. That is the list, and a job that needs anything else installs it in a step. There is no Docker CLI and no Docker socket: a runner that can talk to a daemon can escape its container. **A job may have 4096 processes at once.** The runner sets that ceiling on the step's own process before it execs, so it counts only that job's processes. It is deliberately generous — a parallel build legitimately runs hundreds of compilers, and a bound that failed an honest `make -j$(nproc)` would be a worse bug than the fork bomb it prevents — and it exists because a job that spawns until something breaks otherwise takes its whole host down. Past the ceiling, `fork` fails the way it does on any busy machine, and the step sees that error. ### Timeouts and concurrency `timeout-minutes` defaults to **360** (six hours) and must be a whole number of at least 1 — a limit no run can meet is refused in the file. The runner enforces it; a separate sweep fails a job five minutes past its timeout if the runner has stopped reporting at all, so a task killed underneath you still gets a verdict rather than sitting `running` forever. There is also a **fleet ceiling**, which an operator sets and which is six hours on our deployment. A job asking for more than that does not run: the whole file is a **failed run**, with the reason ``` timeout-minutes: 720 exceeds this fleet's limit of 360 ``` on a check named after the file. It is refused rather than quietly clamped down to the ceiling, because a build told it may run for twelve hours and stopped at six fails in a way its author cannot explain from anything they wrote. Like the parse refusals, it is reported whatever the event that found it — a file that only asks for `change` still gets told about it on a push. An organisation runs **4 jobs at once** by default. The limit is enforced inside the statement that hands out work, so it holds across the whole fleet, and it can be raised for an organisation by name. ## What happens on a push 1. You push a branch. Weft reads `.weft/` **at the commit you pushed**. 2. If this was a push to a branch other than the default branch, the in-flight run for that branch is **cancelled**, recorded as `cancelled` with the reason `superseded by `. Its jobs' checks follow. A push to the **default branch never supersedes** anything: "was `main` green at 14:02" has to stay answerable. Deleting a branch cancels its runs too, with the reason `branch deleted`. 3. Each file that parses and asks for this event gets one run. **A second push of the same commit does not start a second run** — the existing one is found and left alone. 4. Every job in the run is written as a `queued` check on the commit, named ` / `: `ci / test`, `ci / test (linux, 1.84)`. 5. The dispatcher claims jobs whose dependencies have passed, mints each one a token, and starts a task. The check goes to `running`, then to `passing` or `failing` when the job reports. 6. The run is over when nothing is left queued or running: `passed` if everything passed, `failed` if anything failed or was cancelled. A **change** is the same, keyed on the patchset rather than the branch: a new patchset cancels the previous patchset's runs, the run carries the change key, and `WEFT_EVENT` is `change`. ### When the file is wrong A refused file is a **failed run with a failing check named after the file** — `.weft/ci.yml` — carrying the refusal, its line and its hint. It is not silence. A workflow that quietly does not run looks exactly like one that has not started yet, and somebody waits for it. The same is true when a deployment has no runner configured: the run fails at trigger time with a message naming what the operator has to set. And when Weft itself could not read `.weft/` at the commit — the store refused, say — the run fails with a check named for the **directory**, `.weft`, carrying the store's answer and saying that nothing ran: which files were there is exactly what could not be learned, and a push whose CI failed to start must not look like a push whose CI has not started yet. ### Changes pushed from a fork A change whose commits come from a fork is recorded as **`blocked`**, not run, and its check sits at `queued` rather than red — nothing is wrong with the change, it is waiting on a person. Its workflow file was written by the contributor, and running it would hand a stranger a repository token and a machine. The run's reason says so: ``` this change comes from a fork; a maintainer has to approve its workflows before they run ``` A maintainer starts it with **Approve and run workflows**, on the change's page beside its checks. The button appears only for people who could land the change — the route is ``` POST /v1/orgs/{org}/repos/{repo}/changes/{change}/workflows/approve ``` and it takes the same `repo:write` as landing does. Deliberately not the review-approval door beside it: `POST …/approve` is a review opinion, which somebody with read access may hold, and holding an opinion must not also start compute on our fleet. Two words, two authorizations. **Approval is per tip, not per change.** It starts the workflows for the change's *current* patchset, and a new patchset from the fork is blocked again — because the file the maintainer read is not the file the next push contains. This is what GitHub's "Approve and run" does, for the same reason. The response is `202` with the runs that now exist at that tip, read back from the database rather than reported optimistically: the trigger may legitimately have settled a run instead of starting one — an organisation out of minutes, a file over the timeout cap — and a caller told "running" about a run that is `blocked` would wait for a build that is not coming. `409` if the change is not open, or if nothing is blocked at its current tip; `404` if there is no such change, or it has no patchsets. **Branch on `blocked_reason`, never on the words.** Every run carries it: `fork`, `budget` or `suspended`, and `null` unless the run is `blocked`. The sentence in `error` is written for a person and will be rewritten; this will not, and it is the only one of the two a client should read. The distinction matters most here. An organisation's own refusals are decided **before** the fork gate, so a fork change under an organisation that is out of minutes or suspended is coded `budget` or `suspended`, not `fork` — it is blocked for a reason no maintainer can approve away. This route does not refuse such a change: it accepts the approval, re-triggers, and honestly answers `202` with a run that is *still* blocked, for the reason that was actually stopping it. That is the right answer to give and a poor thing to have asked for, which is why the dashboard offers the button only when `blocked_reason` is `fork`, and why anything else building this UI should do the same. Behind the button, the blocked run and its mirrored check row are deleted before the real runs are created, so one run per workflow file per commit still holds and no permanently-queued check is left holding the land gate. ## Composed runs for a changeset A [changeset](/docs/changesets/) is one review over changes in several repositories, and a test that only ever sees one of them cannot say whether the unit works. A file with `changeset` in its `on:` gets a run where **every member repository is checked out**, each at the head that changeset proposes for it. ```yaml name: ci on: [change, changeset] jobs: test: steps: - name: Test run: make test ``` Each member is materialised under `$WEFT_WORKSPACE`, in a directory named after its repository: ``` $WEFT_WORKSPACE/api # the api member, at its latest patchset $WEFT_WORKSPACE/web # the web member, at its latest patchset ``` **Steps run in the job's own repository** — `$WEFT_WORKSPACE/` is the working directory every step starts in, so a file written for `on: change` keeps working under `on: changeset` and the siblings are simply there beside it. `WEFT_SHA`, `WEFT_REF` and `WEFT_CHANGE` are this repository's member, as on a change run. `WEFT_CHANGESET_MEMBERS` is the whole list, in the changeset's member order, as JSON: ```json [ {"repo": "api", "change": "Iaa000001", "commit": "9e54f5f2…", "path": "/work/workspace/api"}, {"repo": "web", "change": "Ibb000002", "commit": "3c1d90ab…", "path": "/work/workspace/web"} ] ``` `path` is absolute, so a script can `cd` to a sibling without knowing how the workspace is laid out. **Every other member is read with a token scoped to that member alone.** The job's own repository is checked out with the job token as it always was; each sibling is fetched with a fresh repository-read token minted for *that one repository*, and all of them are revoked when the job reports. There is deliberately no organisation-wide read token here: a member's CI script is code its author wrote, and one token that could read the whole organisation would let that script read repositories the author cannot see. Like the job token, none of them is ever put in an environment a step can read, or printed in a log. **One run per member repository, per composition.** The composition is the set of members and the commit each is at; every member repository whose `.weft/` asks for `changeset` gets one run, so a changeset of three repositories where two declare a composed workflow has two composed runs. A new patchset on *any* member, or a member being added or removed, is a new composition: the live composed runs of the old one are cancelled with the reason ``` superseded by a new composition of changeset Ic5000001 ``` and a fresh set is started. A member whose repository has been deleted drops out of the changeset — its members list, its landing order and its composition all leave it out — so the next patchset on a surviving member is built as the combination the changeset now shows. A changeset with no member left, or one with a member that has no patchset yet, cannot be composed at all: nothing starts, and whatever is already running is left alone. **Verdicts land on the changeset, not on the commit.** A composed job's check appears on the changeset and gates [landing it](/docs/changesets/#composed-ci); it is not written to the member commit's Checks tab. The per-change `on: change` runs are untouched and still gate their own member — a file with `on: [change, changeset]` produces the same check name from both events on the same commit, and the per-change gate must not read the composed answer as the member's own. The consequence is worth saying out loud, because it is not what a person expects: composing three repositories and then opening one of them shows its push and change runs and nothing about the composition, which reads as the composed build having never happened. So a member repository's **Checks** tab carries a *Changeset builds* panel under its check rows — the composed runs of that repository, each linking to its run page and to the changeset that owns the verdict. It is filled from `GET …/workflow-runs?event=changeset`, filtered server-side because the composed runs of a busy repository would otherwise be paged out by its pushes. **One unapproved fork member holds the whole composition.** If any member's change comes from a fork and no maintainer has approved that tip yet, **every** member's composed run is `blocked` with `blocked_reason` `fork` — not only the fork member's own. A composed job is the one place where that has to be true: the job runs in a maintainer's own repository, but it materialises the stranger's tree beside it under `$WEFT_WORKSPACE`, and the maintainer's own script is free to build it, test it, or execute it. Blocking only the fork member's run would leave a stranger's code being run by three repositories that never asked. Approving the fork change's workflows — the same button and the same [route](#changes-pushed-from-a-fork) on that change — releases the whole composition, and the held runs start. A tip already approved stays approved: recomposing does not put it back behind the button. ## Hosted-runner minutes A hosted fleet is somebody's compute bill, so an organisation has a budget of minutes and it can run out. How many is the operator's choice — the reference deployment gives an organisation **2000** minutes per rolling thirty days, and a self-hosted Weft can set any number or turn metering off entirely, in which case everything below is inert there. **How the number is arrived at.** Usage is counted **per job, rounded up**: a job that ran for eleven seconds costs a minute, because a minute is the smallest thing the fleet bills. Summing raw milliseconds and rounding once at the end would let a thousand ten-second jobs cost almost nothing, which is exactly the shape of a workload you would want to notice. The window is a **rolling thirty days**, not a calendar month — a calendar month hands every tenant the same reset instant, which is both a stampede on the first and an obvious way to abuse the budget: burn the allowance, wait for midnight, burn it again. **A job still running counts from the moment it started.** The number moves while builds run, which is what somebody watching the page expects to see, and it means an organisation cannot hide its usage by keeping everything in flight. **Where to read it.** Settings → Billing, in the organisation's dashboard, and on `GET /v1/orgs/{org}/billing`, which carries `ci_minutes_limit`, `ci_minutes_used` and `ci_minutes_remaining` (plus `ci_suspended_reason` and `ci_suspended_at`, below). **`null` is not zero.** A `null` limit means *unlimited* — no override on the organisation and no deployment default, which is the right default for a deployment paying its own compute bill. "0 minutes left" and "no limit at all" are opposite facts and a panel that rendered them the same way would be worse than showing nothing. `ci_minutes_limit` is therefore **never `0`**: a budget of zero is read as "no budget configured", so an unmetered organisation reports `null` for both `ci_minutes_limit` and `ci_minutes_remaining`. The zero that means *metered and out* is `ci_minutes_remaining: 0`, which is a real state and the one worth rendering loudly. It is floored there rather than going negative — a running job may take an organisation past its limit, since nothing is killed for budget — so `used` can legitimately exceed `limit` while `remaining` reads `0`. **When the budget is gone,** a push does not fail and it does not run: each workflow file gets a `blocked` run carrying ``` this organisation has used its 2000 hosted-runner minutes for the month ``` with the number that was actually configured. "For the month" there is the rolling thirty days above — there is no reset date to wait for, and the oldest minutes fall out of the window as they age. It is refused at trigger time, where there is still somebody to tell — a job that quietly never got claimed leaves a build that looks like it has not started yet. The dispatcher asks again before it claims a job, because an organisation can cross its budget between queueing and claiming; a job that crossed it while waiting is cancelled with the same reason and no task is launched. **A running job is never killed for budget.** It is bounded by its timeout, and the minutes it spends are counted. The budget decides what starts, not what stops. An organisation's allowance is set by an operator, not from the dashboard: the deployment has a default for every organisation and an operator can override it for one by name. If you run Weft yourself, the statement to run is in `docs/deployment-aws.md` in the repository. ## What is refused for abuse A hosted runner executes a `run:` line somebody wrote, on a machine we pay for. The one thing that is worth real money to steal here is CPU, and the thing people do with stolen CPU is mine cryptocurrency. Four separate things stand in the way of that, deliberately, because every one of them can be walked around on its own. **1 — nothing can reach a mining pool.** All egress from the runner VPC goes through a firewall with a domain **allowlist** and a default drop. There is no pool to dial and no open proxy to dial it through. This holds against a miner nobody has heard of and against one that arrives inside a dependency, which is why it is first. It is also why a build that needs an unlisted domain sees a dropped connection: the allowlist is the product, not a bug in it. **2 — the file is refused when you push it.** A workflow that names known mining software as a command, or that carries a mining pool URL, does not schedule anything at all. Both `run:` lines and `env:` values are read — `run: ./m $POOL` says nothing on its own, so a check that read only `run:` would be walked around by the first person who tried. The refusal is the ordinary kind, with the file, the line and a hint: ``` mining software is not permitted on hosted runners `xmrig` is mining software; hosted runners are for building and testing your code ``` The pool schemes are `stratum+tcp://`, `stratum+ssl://`, `stratum2+tcp://` and `stratum+tls://`, matched anywhere on a `run:` line or in any `env:` value — no build has a use for one, so the scheme alone is enough. A miner name has to be **invoked as a command** for the run: check to fire: `grep -rn xmrig .`, a step that writes `xmrig.log`, and a README quoting this paragraph are all somebody working, and a refusal that cannot tell those apart is one people learn to route around rather than read. The line is not fully parsed as shell — leading `VAR=1` assignments, and wrappers like `sudo`, `env`, `nice` and `timeout`, are skipped to find the program, and that is as far as it goes on purpose. In an `env:` **value** only the URL half applies: a value containing the word `xmrig` is somebody naming a file. This layer is a refusal, not a detector. `curl -o m https://…/x && ./m` walks straight past it, which is exactly why there are four layers. **3 — a running step is killed.** While a step runs, the processes in its group are sampled every two seconds and read from `/proc`. A process is a miner if its **program** is one — its `comm`, or the basename of `argv[0]`, never an argument — or if a pool URL appears anywhere in its `argv`, since a renamed binary still has to be told where to send its shares. When one is found the whole process group is killed and the job ends `failed` with ``` ✗ Build stopped: mining software detected: xmrig (3s) ``` in the log and `mining software detected: xmrig` as the job's error. Nothing here is measured: there is no CPU heuristic on purpose, because a release build with `-j8` looks exactly like a miner to one, and a compile flagged as abuse is a person locked out of their own forge. **4 — the organisation is suspended.** A verdict that reports abuse switches hosted workflows off for the whole organisation, not just that repository: everything it has running, in every repository it owns, is cancelled, and every later trigger is `blocked` with ``` hosted workflows are suspended for this organisation: mining software detected: xmrig ``` — the runner's own sentence, because it names what was found. It is recorded in the audit log as `workflow.suspended`. The **first** reason stands: a second offence does not overwrite the explanation somebody is in the middle of acting on with an identical-looking one bearing a later timestamp. The reason and the time also appear on the billing view, as `ci_suspended_reason` and `ci_suspended_at`, so the page a member goes to when their builds stop says why. **Clearing a suspension is an operator action, by SQL.** There is no route and no button, because there is no operator role on this server to hang one off and inventing one here would be a security surface built in passing. If you run Weft yourself, the statement is in `docs/deployment-aws.md`; on a hosted deployment, ask whoever operates it. ### The audit trail for both of these Two events, and between them they are the whole record of a run that was held and what happened next: | Action | Recorded when | Details | |---|---|---| | `workflow.suspended` | a verdict reports abuse | `abuse`, `reason`, `job`, `run` | | `workflow.approved` | somebody approves a fork's workflows | `change_key`, `commit` (the tip approved), `files` (the workflow files that were being held) | `workflow.approved` carries the approving principal, like every audit row, and it matters more than it looks: approving **deletes** the blocked placeholder runs and their check rows, so nothing in `workflow_runs` afterwards remembers that these files were ever held. This row is the only surviving record that they were, and who let them go. It names the files for the same reason — "approved the workflows" without saying which is not a trail anybody can audit. ## Self-hosted runners A job that says `runs-on: [self-hosted]` runs on a machine **you** registered — your laptop, a box under a desk, an autoscaling group, a GPU host that could never be a line item on our fleet. Weft keeps everything else: the file, the checks, the log, the run page, the land gate. What changes is whose CPU it is, and therefore who is responsible for what the job can reach. A runner only ever makes **outbound** calls. It asks for work, and it is handed a job or told there is nothing. Nothing of ours connects to it, nothing has to be port-forwarded, and it does not need a public address. [Running a runner](/docs/self-hosted-runners/) is the operator's side of this page: the binary, a systemd unit, and how to isolate it. ### The organisation's policy Under **Settings → Runners**, which needs `org:admin` like Billing: | | | |---|---| | **Weft-hosted runners** | `allowed` (default) or `disabled` | | **Self-hosted runners** | `all` (default), `selected` — naming the repositories that may use them — or `disabled` | An organisation that only trusts its own machines sets hosted to `disabled`, and a `runs-on: ubuntu-latest` file is then refused at trigger time with ``` hosted runners are disabled for this organisation; use runs-on: [self-hosted, …] ``` and a self-hosted job in an organisation that has not enabled them, or in a repository that is not one of the `selected` ones, is refused with ``` self-hosted runners are not allowed for this repository (organisation policy) ``` Both are **failed runs**, not blocked ones: nothing lifts by itself, and somebody has to edit either the file or the settings. That is the whole point of refusing at trigger time. A job queued against a pool it can never reach sits there looking like a build that has not started yet, which is the single most common thing people ask about somebody else's self-hosted setup. Reading and writing the policy over the API: ``` GET /v1/orgs/{org}/runner-policy PATCH /v1/orgs/{org}/runner-policy ``` ```json { "hosted": "allowed", "self_hosted": "selected", "self_hosted_repos": ["builds"] } ``` `PATCH` takes any subset of those three keys, needs `org:admin`, answers `422` on a value outside the sets above, and lands in the audit log as `runner_policy.updated`. ### Runner groups A runner belongs to exactly one **group**, and a group decides which repositories may send it work. Every organisation has a `default` group, created the first time one is needed; you can make others. | | | |---|---| | **Repository access** | `all` repositories in the organisation, or `selected` ones by name | | **Allow public repositories** | off by default | **Public repositories are excluded until you say otherwise, and that default is the important one.** Anybody may fork a public repository and open a change; the change carries its own `.weft/*.yml`; and a workflow file is a shell script. A group that admits public repositories is a group whose machines will, sooner or later, be asked to run a stranger's code as the runner's own user, on your network. The [fork-approval gate](#changes-pushed-from-a-fork) still stands in front of that — a maintainer has to press **Approve and run workflows** for each new tip — but a gate a tired person clicks through is one layer, not two, so the group starts closed. ``` GET /v1/orgs/{org}/runner-groups POST /v1/orgs/{org}/runner-groups {"name", "repo_access"?, "allow_public"?, "repos"?} PATCH /v1/orgs/{org}/runner-groups/{id} any subset of the same keys DELETE /v1/orgs/{org}/runner-groups/{id} ``` Reads need organisation membership, writes need `org:admin`. A duplicate name is `409`. Deleting a group is `204` and **moves its runners to the default group** rather than orphaning them; the default group itself cannot be deleted and cannot be renamed (`422`). The three writes are audited as `runner_group.created`, `.updated` and `.deleted`. ### Registering a machine An organisation admin mints a **registration token**: ``` POST /v1/orgs/{org}/runners/registration-token {"group": "default"} ``` ```json { "token": "weftg_…", "expires_at": 1800000000000, "group": "default", "command": "weft-runner register --url https://weft.sh --token weftg_…" } ``` It is **single-use and expires in one hour**. It is not the runner's credential: it is the right to obtain one, once. The machine exchanges it, and from then on holds a long-lived credential of its own: ```bash weft-runner register --url https://weft.sh --token weftg_… --labels gpu,cuda-12 weft-runner run ``` `register` writes `.runner` in its working directory (mode `0600`), holding the URL, the runner's id and its credential, and prints one line: ``` registered build-01 as rnr_… in group default with labels [self-hosted, linux, x64, gpu, cuda-12] ``` `run` then loops: ask for a job, run it, ask again. `--name` defaults to the machine's hostname and `--dir` to the current directory. **Rotating a credential is re-registering.** Running `register` again with the same name replaces that runner: the old credential stops working immediately, the runner keeps its identity in the list, and there is no separate rotation dance to remember. Registration is audited as `runner.registered`, the token mint as `runner.registration_token.created`. ### Labels A runner's labels are what it offered plus what the server always adds: | Added always | `self-hosted`, the OS (`linux`, `macos`, `windows`), the architecture (`x64`, `arm64`) | |---|---| | Added by you | anything from `--labels`, lowercase | A job may run on a runner when **every label in its `runs-on` is one of the runner's**, the runner's group admits the repository, the organisation's policy admits self-hosted for that repository, and the runner has not been removed. `runs-on: [self-hosted]` on its own therefore means *any* machine the first three rules allow, and `[self-hosted, linux, gpu]` narrows it. Labels are matched, never invented. If no runner could ever satisfy the list, the run is refused at trigger time rather than queued: ``` no runner with labels [self-hosted, gpu] is registered for this repository ``` and if the repository is allowed self-hosted runners but no group will serve it — most often a public repository and no group with **allow public repositories** turned on: ``` no runner group admits this repository; add it to a group under Settings → Runners ``` ### What a self-hosted job gets Everything a hosted job gets from Weft, and nothing a hosted job gets from the fleet: - The same **repository-read token minted for that one job**, expiring with the job and revoked the moment it reports. The runner holds it; steps never see it. - The same environment — `CI`, `WEFT_JOB`, `WEFT_SHA`, `WEFT_REF`, `WEFT_EVENT`, `WEFT_CHANGE`, the matrix variables — under the same rules, and the same `bash -e -c` per step. - The same log, streamed live to the same run page, and the same check on the commit under the same name. Nothing downstream can tell the two apart, which is the point. The job runs in a fresh working directory under the runner's own directory, which is removed when the job ends. It runs **as the user the runner runs as**, directly on the machine — so the process ceiling and the egress allowlist described above are properties of *our* fleet and not of yours, and the isolation between one job and the next is whatever you built. [Running a runner](/docs/self-hosted-runners/) is about exactly that. Because there is no container, there is nothing for `image:` to name. A self-hosted job asking for one is refused at trigger time: ``` image "rust:1.83" is not available on self-hosted runners; steps run directly on the machine ``` Whatever is on that machine's `PATH` is what the job gets, which is the trade: you choose the toolchain by building the machine, not by naming a tag in the file. **Ephemeral runners** (`--ephemeral`) take one job and exit, and the server removes them the moment that job reaches a terminal state. It is the honest way to get a clean machine per job, and it is what to reach for if you are autoscaling. ### The runner list, and disappearing machines `GET /v1/orgs/{org}/runners` — and the same table under Settings → Runners — shows every runner with its labels, its group, whether it is ephemeral, when it was last seen, and the job it is running: | State | Means | |---|---| | `busy` | a running job is assigned to it | | `online` | it called in within the last 60 seconds | | `offline` | it did not | State is **derived on read, never stored**, so a machine that loses power is `offline` a minute later without anything having to notice. A runner that stays away is eventually removed for you: **14 days** unseen for an ordinary runner, **1 day** for an ephemeral one. That is housekeeping, not a policy — a laptop that was registered for an afternoon should not be in the list forever. Removing one yourself is `DELETE /v1/orgs/{org}/runners/{id}`, or **Remove** in the table. Its credential is dead from that moment; the process finds out on its next call, prints `this runner has been removed; register it again` and exits. A job that was running on it is **failed**, with ``` runner removed while the job was running ``` and it is *not* retried. The dispatcher retries a job whose runner was lost, because losing a runner is an accident; removing one is a decision, and quietly re-running the job on another of your machines is not what the person who pressed the button asked for. The removal is audited as `runner.removed`. ### What is the same, and what is not **Minutes are not metered.** [Hosted-runner minutes](#hosted-runner-minutes) count hosted jobs and only hosted jobs — it is your hardware and your electricity bill. So a file whose jobs are all self-hosted runs when the organisation is out of minutes, and runs when the organisation's hosted workflows are suspended: those two refusals apply only to files that contain a hosted job. A mixed file is refused as a whole, because it contains one. **The fork gate applies to both pools**, and matters more here. A change from a fork is `blocked` until a maintainer approves it, per tip, exactly as [described above](#changes-pushed-from-a-fork). On our fleet that protects our bill; on yours it protects your machine. **A miner is still killed, and your organisation is not suspended.** The [mining watch](#what-is-refused-for-abuse) runs wherever the runner runs: the file is refused when you push it, and a step caught running a miner has its whole process group killed and the job failed with the same sentence, ``` ✗ Build stopped: mining software detected: xmrig (3s) ``` because a stranger's change mining on *your* hardware is the thing the watch exists to stop. What does **not** happen is the fourth layer: hosted workflows are not switched off for the organisation, because there is no compute bill of ours being stolen. The event is still recorded as `workflow.abuse`, carrying `pool: self_hosted`, so it is in the audit log for you to act on. The egress allowlist is not there either. Ours is a firewall in front of our VPC; your runner's network is yours. ## Checking a file before you push `GET /v1/orgs/{org}/repos/{repo}/workflows?at={rev}` reads `.weft/` at any rev and tells you what would run — the expanded jobs, in start order, with their `needs` — or what is wrong with the file, with a line number and a hint. `at` defaults to `HEAD`. ```bash curl -sS "$WEFT_URL/v1/orgs/$ORG/repos/$REPO/workflows?at=my-branch" \ -H "Authorization: Bearer $TOKEN" ``` ```json { "workflows": [ { "file": ".weft/ci.yml", "ok": true, "name": "ci", "on": ["push", "change"], "jobs": [ { "key": "test", "job": "test", "matrix": {}, "needs": [] } ] } ] } ``` An `ok: false` entry carries a `problems` array instead, each with `line`, `key`, `message`, `hint` and a pre-rendered `text`. It costs nothing and it is the difference between learning about `uses:` now and learning about it from a red push. ## The run page Every check a hosted job writes carries a **Details** link to that run's page in the dashboard, at `///checks/runs/`. It shows the run's state, the commit and branch it is about, a link to the change if it came from one, the run's own error verbatim when there is one — the refused line of YAML, the cycle in `needs:` — and one panel per job. Selecting a job shows its log; while the job is live the page reads the same SSE stream described below, so the output arrives as it is written rather than on a refresh. A viewer with `repo:write` gets a **Cancel run** control there, which is the `cancel` route below. A job that ran on one of your own machines is labelled with the runner that took it. There is no page that lists a repository's runs. The Checks tab lists the *checks*, hosted ones beside everybody else's, and a row is how you reach its run. ## Runs, jobs and logs over the API Everything below needs `repo:read`, except cancel, which needs `repo:write`. | Route | | |---|---| | `GET /v1/orgs/{org}/repos/{repo}/workflow-runs` | The repository's runs, newest first, each with its jobs. `?limit=` defaults to 20 and is **clamped** to 1–100 rather than refused. `?commit_sha=`, `?change_key=` and `?event=` (`push`, `change`, `changeset`) narrow it, in the query rather than after the limit | | `GET …/workflow-runs/{id}` | One run and its jobs | | `POST …/workflow-runs/{id}/cancel` | Stop a run. `409` if it is not running any more. Needs `repo:write` — a job's own token is `repo:read` and cannot cancel anything | | `GET …/workflow-jobs/{id}/log` | The log as `text/plain`: complete if the job is over, so far if it is not | | `GET …/workflow-jobs/{id}/log/stream` | The same log as server-sent events, tailing until the job ends | A run carries `id`, `file`, `name`, `commit_sha`, `ref_name`, `event`, `change_key`, `state`, `error`, `blocked_reason`, timestamps, and `jobs`. Run `state` is one of `running`, `passed`, `failed`, `cancelled`, `blocked`; `blocked_reason` is `fork`, `budget` or `suspended`, and `null` for every state but `blocked`. A job carries `id`, `job_id`, `key`, `matrix` (an object), `state`, `attempts`, `error`, `detail_url`, `log_chunks` and timestamps; job `state` is one of `queued`, `running`, `passed`, `failed`, `skipped`, `cancelled`. A job also carries `pool` (`hosted` or `self_hosted`), the `labels` its `runs-on` asked for, and `runner` — `{"id", "name"}` for a job that ran on one of your machines, `null` otherwise. Branch on `pool`, not on whether `runner` happens to be set: a self-hosted job that has not been claimed yet has no runner either. **`commit_sha` and `change_key` narrow the query, not the page.** The filtering happens in the database, inside `limit`, which is the whole point of having them: `?commit_sha=&limit=1` gives you that commit's run, where fetching the newest 20 and filtering in the client gives you nothing at all on a repository busy enough to have pushed 20 times since. A panel that filters a window is a panel that loses its own controls exactly when the repository is busiest. A run or job belonging to another repository answers `404`, not `403`. An id that resolves differently for a stranger is an existence oracle. ### The live log `…/log/stream` is an `EventSource` feed with three event types: | Event | Data | |---|---| | `queued` | `{}`, once, if the job has not started yet | | `chunk` | `{"text": "…"}` — the next slice of output | | `done` | `{"state": "passed"}` — the feed then closes | The text arrives as JSON rather than raw, because an SSE `data:` field cannot carry a trailing newline and a log whose lines quietly ran together would disagree with the plain `…/log` route beside it. The feed polls the same stored chunks that route reads, so the two can never disagree, and it is held open for at most six hours. Logs live in object storage under a lifecycle rule and age out — **90 days** on the reference AWS deployment (`ci_log_retention_days`). They are not permanent records. ## How verdicts reach the rest of Weft Each job mirrors itself into one check row on its commit, named ` / ` and linking back to the run it came from at `///checks/runs/`, and that is the whole integration. A workflow file we refused gets a row of its own, named for the file, and a `.weft/` we could not read gets one named for the directory; each links to the run that carries the reason — the row is the only thing on the commit page that says why nothing ran. The Checks tab, the change under review, the land queue and the README badge were all built for verdicts other people's build systems reached, and they need to know nothing about this one. A hosted row carries `provider: "weft"`, which is the server saying *we wrote this*: the intake stamps `provider: "intake"` as a constant, so nothing posted from outside can claim it. That is what lets the **Details** link navigate inside the dashboard instead of opening a new tab the way a link to somebody else's build system does. So everything on [CI integration](/docs/ci-integration/) applies unchanged: a `failing` check blocks the land queue, a check you have [made required](/docs/ci-integration/#making-a-check-required) must go green before a change may land, and `ci / test` from a hosted workflow and `ci/tests` from Buildkite sit in the same list under the same rules. Required-check names are matched against the check's name, so the name to require is the mirrored one — `ci / test`, including the spaces. Note that a **matrix cell's name contains its values**, so requiring `ci / test (linux, 1.84)` requires that cell and adding an axis value does not silently add a requirement. A **composed run goes somewhere else entirely**. Its jobs mirror into the changeset's own list of checks, under the same ` / ` name and linking to the same run page, and never onto the commit. Nothing on the commit page changes when a composed job reports, and the changeset's [land gate](/docs/changesets/#composed-ci) is the only thing that reads it. ## What is not here yet Said plainly, with no dates attached: - **No re-run button, and no re-run route.** Push again, or cancel and push again. (The dispatcher retries a job whose *runner* was lost; that is a different thing, and it is not something you can ask for.) - **No artifacts and no caches.** Nothing is kept from a job but its log and its verdict. A job that needs a dependency downloads it. - **No annotations.** A verdict and a log, not marks on the diff. - **No way to clear a suspension from the product.** Suspending an organisation for abuse is automatic; switching it back on is an operator running SQL, because there is no operator role here to give a route to. - **No images but `default`.** The parser takes `image:` so that a workflow written today reads correctly later; the fleet runs one image today. - **No list of runs in the dashboard.** A run has a page and its log tails live there, but the way to it is a check row's **Details** link or, for a composed run, the Changeset builds panel on the Checks tab; there is no screen that pages through a repository's push runs. `GET …/workflow-runs` is that list, and it is the API only. - **No scheduled or manual triggers.** `push`, `change` and `changeset` are the three doors, and all three are something that happened to the repository. - **No composed clone URL, and no workspace page.** A composed run materialises the members on the runner; there is no way yet to check the same set out on your own machine, and no screen that lists a changeset's members at their proposed heads. - **No conditions, no job outputs, no service containers.**