---
title: WarpEngine
url: 'https://wiki.teletypegames.org/engines/warp-engine'
markdown: 'https://wiki.teletypegames.org/engines/warp-engine.md'
lang: en
date: '2026-08-06'
description: 'WarpEngine is a mountable Rails engine that turns any Rails app into a retro software catalog. A game repo carries a one-line .woodpecker.yaml marker, the engine serves the whole CI pipeline, builds are uploaded over HTTP, and a publish call upserts the catalog. A public read-only JSON API and opt…'
taxonomy:
  tag:
    - engine
    - highlighted
---

# WarpEngine

> WarpEngine is a mountable Rails engine that turns any Rails app into a retro software catalog. A game repo carries a one-line `.woodpecker.yaml` marker, the engine serves the whole CI pipeline, builds are uploaded over HTTP, and a publish call upserts the catalog. A public read-only JSON API and optional ActiveAdmin resources are included. Every class, service and controller is documented in the source: the **[API reference (RDoc)](https://wiki.teletypegames.org/rdoc/warp_engine/latest/)** is rebuilt from it on every `warp_engine-v*` tag.

# WarpEngine related pages

| Page | What it is | Side |
|---|---|---|
| [WECP — WarpEngine Catalog Protocol](https://wiki.teletypegames.org/engines/warp-engine/wecp) | The read-side HTTP/JSON contract, endpoint by endpoint: what every catalog reader talks to. | protocol |
| [WarpEngine Admin API](https://wiki.teletypegames.org/engines/warp-engine/admin-api) | Since 1.0: what the admin does, as a JSON API with an OpenAPI 3.1 document and an MCP server. Off unless the host turns it on. | protocol |
| [WarpEngine Client](https://wiki.teletypegames.org/stores/warp-engine-client) | The desktop app: the catalog as a grid of cards, install, play, remove. Linux, macOS, Windows. | client |
| [warpstore](https://wiki.teletypegames.org/engines/warpstore) | The shared core of the shell-script store engines: one file that knows the WarpEngine catalog. | client |
| [WarpEngine Batocera Store](https://wiki.teletypegames.org/stores/warp-engine-batocera-store) | Any WarpEngine catalog on a Batocera box, under its own EmulationStation menu. | client |
| [WarpEngine RetroArch Store](https://wiki.teletypegames.org/stores/warp-engine-retroarch-store) | Any WarpEngine catalog as RetroArch playlists with box art. | client |
| [TTG Batocera Store](https://wiki.teletypegames.org/stores/ttg-batocera-store) · [TTG RetroArch Store](https://wiki.teletypegames.org/stores/ttg-retroarch-store) | Our own two stores: a config on top of each engine above. | store |
| [Public site](https://wiki.teletypegames.org/services/public-site) | teletypegames.org, the first host: the monorepo the engine is developed in, and the catalog most readers point at. | host |
| [Teletype Orbit](https://wiki.teletypegames.org/services/teletype-orbit) | The shop platform on WarpEngine: purchases, entitlements, gated downloads, device sign-in. | host |

# What you get

- **Catalog domain** — `Software`, `Release`, `ReleaseAsset`, `ExternalLink`, `PlatformLink`, `SoftwareImage`, `Download`. All soft-deleted, with download tracking.
- **CI pipeline server** — a Woodpecker [configuration extension](https://woodpecker-ci.org/docs/usage/extensions/configuration-extension). Game repos hold only a platform marker. Builder images are server-side, so bumping an image reaches every repo at once.
- **Pluggable CI** — from 0.7 every CI feature goes through one adapter. Woodpecker ships with the engine as `WarpEngine::CI::Woodpecker` and is the default; `:none` turns CI off, and another server is a host-supplied object.
- **Build endpoints** — `POST /build/upload` and `POST /build/publish`, authenticated by a shared secret or by database application tokens.
- **Public JSON API** — catalog, highlighted title, per-platform build matrix, download tracking, static file server for web builds.
- **Host-owned image library** — from 0.6 the `Image` model, its files, its `/api/image/:id` endpoint and its admin page live in the host; the engine links a software to an image and publishes the URL through an adapter. The same table can then serve avatars or anything else the site has.
- **Pluggable access** — the host supplies a policy and the catalog gains prices, entitlements and gated downloads, and says so in the API, so a client can show a paid title as paid instead of failing at download time. Default: the open catalog.
- **Client sign-in** — RFC 8628 device authorization grant over the host's user model, for clients with no browser. Off unless configured.
- **Optional admin** — ActiveAdmin resources (catalog editor, file manager, download stats, app tokens). Without ActiveAdmin the engine runs headless.
- **CI management** — with an API token the admin drives the CI server: repo sync, pipeline history with manual triggers, application tokens provisioned as CI secrets.
- **Asset checklist** — from 0.8, `rake warp_engine:builds:check` prints per software and release which assets exist and which the platform's pipeline could still deliver. See *Which assets are missing*.
- **Shelves** — from 1.2, a platform's latest builds as a flat folder under fixed, versionless names with an `INDEX.TXT`, for clients that cannot read the JSON API (the [WARP64U](https://wiki.teletypegames.org/stores/warp64u) C64 store reads the C64 shelf over plain HTTP). From 1.7 a shelf can also be one folder per title, the layout [DOSINST](https://wiki.teletypegames.org/dos/dosinst) reads, so the DOS store is built from the catalog too. See *Shelves*.
- **Platforms of your own** — from 1.5, a host registers a platform with its service, kinds and pipeline template; the built-in ones are no longer the only ones. See *Platforms of your own*.
- **Shared version and release steps** — from 1.7 the engine adds the `version` step to every generated pipeline and, where the host asks for it, a forge `release` step at the end, on any platform. See *Steps every pipeline shares*.
- **Configuration DSL** — from 1.8 the initializer is a block of named settings (`shelf`, `admin_api`, `additional_resource`, `ci … do platform … end`) instead of hashes; the old `|c|` form still works. See *Installing into a host*.
- **Releases from tags the pipeline makes** — from 1.9 a push builds, and a new version in the source gets its `v<version>` tag from the pipeline itself; the tag's pipeline publishes it. A dev build moves its branch's `dev-<branch>` tag, so the branch's newest build is always the published one. See *Versions and releases*.

## Supported platforms

Two lists per platform, and the difference matters. **Assets accepted** is what the updater ingests when a release is published (`WarpEngine::Platform#expected_kinds`). **The pipeline builds** is what the Woodpecker templates actually produce. Where they differ, the rest is a manual upload.

Since 1.5 each accepted kind is also **required**, **expected** or **optional**. A publish without a required kind answers `422` with the missing kinds and records nothing. An expected kind may be missing from a release, and the checklist below reports it. An optional kind may be absent for good and is not counted as missing. `GET /api/builds` lists all three per platform.

| Platform | Label | Assets accepted | Required | The pipeline builds |
|---|---|---|---|---|
| `godot` | Godot | html, win\_x86, win\_x64, linux\_x64, mac\_universal | html | all of them |
| `tic80` | TIC-80 | cartridge, source, html, docs, win\_x64, linux\_x64, mac\_x64 | cartridge | all of them |
| `bevy` | Bevy | html, win\_x64, linux\_x64, linux\_arm64 | html | all of them |
| `love` | LÖVE | html, win\_x64, linux\_x64, mac\_universal | html | all of them |
| `ebitengine` | Ebitengine | html, win\_x86, win\_x64, linux\_x64, linux\_arm64, mac\_x64, mac\_arm64 | html | html, win\_x86, win\_x64, linux\_x64, linux\_arm64 |
| `phaser` | Phaser | html | html | all of them |
| `c64` | C64 | cartridge, html, win\_x64, linux\_x64, mac\_arm64 | cartridge | all of them |
| `plus4` | Plus/4 | cartridge, html, win\_x64, linux\_x64, mac\_arm64 | cartridge | all of them |
| `dos` | DOS | cartridge (the installable folder as a zip), win\_x86 (optional: the Win32 build some programs have) | cartridge | all of them |

Ebitengine is the one gap: a macOS binary needs osxcross, which the linux builder image does not carry, so `mac_x64` and `mac_arm64` only ever arrive by hand.

## Which assets are missing

A release is complete when it carries every asset kind its platform's pipeline can produce — and nothing enforces that. A build step fails, a platform gains a target, a title gets published from a laptop, and the catalog serves a release with three of its seven assets. The engine ships a checklist for it:

```
$ bin/rails warp_engine:builds:check

WarpEngine release asset coverage — latest release per software
expected: the kinds a platform's updater registers · [ ] the CI builds it · [-] the CI does not, upload it by hand

rabbitroller  ebitengine  v1.1.1  3/7
  [x] html
  [x] win_x64
  [x] linux_x64
  [ ] win_x86
  [ ] linux_arm64
  [-] mac_x64
  [-] mac_arm64

The CI can build these and the release does not have them:
  rabbitroller v1.1.1 (ebitengine): win_x86, linux_arm64

12 softwares, 12 releases, 61/78 assets present, 11 missing from CI-built kinds
4 softwares are missing an asset the CI builds
2 assets are expected but this CI does not build them
```

| Marker | Meaning |
|---|---|
| `[x]` | the release has this asset |
| `[ ]` | the platform expects it **and** the pipeline builds it — a real gap, and the line worth acting on |
| `[-]` | expected, but this CI cannot build it (see the table above) — a manual upload, or nothing |
| `[~]` | optional on this platform (DOS's `win_x86`): absent, and not counted as missing |
| `[+]` | the release carries an asset the platform does not expect |

Options are environment variables, so it composes in a shell:

| Variable | Effect |
|---|---|
| `NAME=rabbitroller` | one software |
| `PLATFORM=ebitengine` | one platform |
| `RELEASES=all` | every release, not just the newest per software |
| `ONLY=missing` | only the releases short of a CI-built asset |
| `STRICT=1` | exit non-zero when anything is missing, for a scheduled check |

"Newest" is the newest non-`dev-` version — the same rule the catalog API uses when it names a title's latest release. The numbers are objects too: `WarpEngine::AssetCoverage` answers `#rows`, `#missing_rows` and `#totals`, and `WarpEngine::AssetCoverageChecklist` renders them.

Available in both hosts: the [public site](https://wiki.teletypegames.org/services/public-site) and [Teletype Orbit](https://wiki.teletypegames.org/services/teletype-orbit).

# Endpoints

## Public API

This is the read side of the API — the **WECP, WarpEngine Catalog Protocol**. Every endpoint's request and response shape is on the [WECP page](https://wiki.teletypegames.org/engines/warp-engine/wecp); this table is the map.

| Endpoint | Purpose |
|---|---|
| `GET /api/service` | What this deployment is: version, whether the catalog gates, how to sign in, and (since 1.7) its shelves with their addresses. |
| `GET /api/software` | The catalog with releases, assets, links, download counts, an optional `category` and an `access` block — whole, or one page at a time (`?page=`, `?per_page=`), filtered by `status`, `platform`, `category`, `q`, `owner_id`. |
| `GET /api/software/facets` | How many softwares each status, platform and category would show under the other filters. |
| `GET /api/software/vocabulary` | The statuses and categories this catalog uses, with labels and display order. |
| `GET /api/software/highlighted` | The highlighted title. |
| `GET /api/builds` | Expected asset kinds per platform. |
| `GET /api/softwares/:name/builds` | Actual vs. missing build assets per release. |
| `GET /api/image/:id` | Images. **The host's endpoint** since 0.6 — the engine publishes the address, the host serves it. |
| `GET /api/download?path=` | Serves an artifact and logs a download. |
| `GET /file/*path` | Static build output (web-playable games, docs). |
| `POST /api/auth/device` | Starts a device sign-in, returns the code pair. 404 when no client identity is configured. |
| `POST /api/auth/device/token` | Polls a device sign-in for its token. |
| `DELETE /api/auth/token` | Revokes the bearer token on the request — sign out. |

Every read endpoint accepts an optional `Authorization: Bearer …`; none requires one. The token only changes what the access policy is asked. An anonymous caller is a supported caller.

Every response carries a `WarpEngine-Version` header, set before the action runs — so error responses have it too, and a client can branch on the engine version without an extra request.

```sh
$ curl -sI https://teletypegames.org/api/software | grep -i warpengine
WarpEngine-Version: 0.13.1
```

The host app's own endpoints (the store registry, for example) do not carry it — they are not part of the engine.

## Build / CI

| Endpoint | Purpose |
|---|---|
| `POST /build/upload` | One artifact (multipart `file`, optional `sha256`). Needs `X-Update-Secret`. |
| `POST /build/publish` | Register uploaded artifacts as a release. Needs `X-Update-Secret`. Since 1.9 a version older than the newest published answers `409`. |
| `POST /build/config` | Woodpecker configuration extension; serves the pipeline YAML. Signature-verified (RFC 9421, ed25519). |
| `GET /build/config?platform=` | Public preview of the generated YAML. |

# Access and identity

Two seams added in **0.5.0**, both shaped like the storage adapter: a documented contract, a default identical to the old behaviour, and one config key to replace it. Set neither and nothing changes.

## Who may see and download what

```ruby
c.access_policy = MyStore::AccessPolicy.new   # :open (default) = the catalog as it was
```

The policy answers three questions:

| Method | Question |
|---|---|
| `visible_software_scope` | which titles `GET /api/software` lists |
| `access_for` | what a client is told about each title |
| `authorize_download` | may this artifact be handed over |

`authorize_download` is asked by `/api/download` **and** `/file/*` before anything is served, so a host no longer has to shadow those routes.

Every catalog entry carries an `access` block, **also under the open policy** — a client never has to guess whether the catalog is silent or the title is simply not gated:

```json
"access": { "gated": true, "entitled": false,
            "price": { "amountCents": 1490, "currency": "EUR" },
            "purchaseUrl": "https://shop.example/games/slug",
            "webUrl": "https://shop.example/play/slug" }
```

The field names are deliberately generic, because one client reads several catalogs.

**A policy that raises counts as a refusal**: empty catalog, denied download, logged. An artifact must never be served because the gatekeeper crashed.

## Signing a client in

A desktop client has no cookie jar and no browser session, so the engine uses the device authorization grant (RFC 8628) instead of a login form.

```ruby
c.access_token_owner_class  = "User"      # nil (default): no sign-in anywhere
c.identity_verification_url = "/devices"  # your page, where a person types the code
c.subject_resolver = ->(request) { request.env["warden"]&.user }   # recognise a browser
```

1. the client `POST`s `/api/auth/device` and shows the `userCode`;
2. the person opens the verification page and types that code;
3. **the host's page** calls `WarpEngine::DeviceGrantService#approve(user_code:, subject:)` — approving needs a session and HTML, which are not the engine's business;
4. the client's next poll takes the token. It is handed over exactly once.

The token is a `WarpEngine::ApplicationToken` with the `catalog` scope, so publishing and client credentials stay separable in one table. `subject_resolver` covers the other case: a browser has a session, not a bearer token, and without it a gated store would refuse its own signed-in visitors.

With `access_token_owner_class` unset, `/api/auth/*` answers 404 and `/api/service` reports `auth: null`, so the client offers no sign-in.

# The image library

An image is not a catalog concept — the same picture library serves covers, screenshots and avatars — so from **0.6.0** the engine owns only the link (`SoftwareImage`: ordered, one default), while the model, the files, the endpoint and the admin page belong to the host. `rails g warp_engine:install`writes a working set (`app/models/image.rb`, `app/controllers/api/images_controller.rb`, a `create_images` migration), and the engine reaches it through an adapter:

```ruby
c.image_class_name = "Image"          # default
c.image_adapter    = MyLibrary.new    # or replace the adapter entirely
```

| Method | Used for |
|---|---|
| `model_name` / `model` | the class `SoftwareImage#image` belongs to |
| `url_for(image_id)` | `imageUrl` and `images[].url` in the catalog JSON, and the admin previews |
| `select_options` | the image picker on the software form |
| `build_from_upload(upload)` | "upload a new image" on that form |
| `label_for(record)`, `available?(record)` | the admin's label and "is the file there" check |

The default adapter publishes `/api/image/<id>` — the address clients have always used — which is why the generated route serves exactly that. **The `images` table did not move**: the engine simply stopped creating it, and `software_images.image_id` still points at the same rows.

# Using Woodpecker with WarpEngine

A game repo has no pipeline of its own. On every pipeline start Woodpecker POSTs the one-line marker to `/build/config` and gets the full YAML back.

1. Woodpecker is configured with WarpEngine as a configuration extension.
2. The repo's `.woodpecker.yaml` holds only `platform:` (and `name:` when the software name differs from the repo name).
3. A push sends the marker to `POST /build/config`.
4. The CI adapter renders `lib/warp_engine/ci/woodpecker/platforms/<platform>/pipeline.yaml.erb` with the configured builder image — the YAML dialect belongs to the provider, so it lives with the adapter. From 1.1 the helper steps (version, lint, minify, docs, upload, publish) run in the platform's `tools` image when it has one, and install nothing; without it they run in `alpine` and `apk add` (or `luarocks install`) what they need on every run.
5. The pipeline runs. Since 1.9, after a push: version → build → tag; on the tag that step made: version → build → upload → publish (→ release, where the host turns it on). See *Versions and releases*.

A repo opts out by committing a real pipeline YAML: a non-marker config gets `204 No Content` and runs unchanged.

## Setting up a game repo

```yaml
# .woodpecker.yaml
platform: godot
name: mygame     # optional, defaults to the repo name
```

```json
// metadata.json — same for every platform except TIC-80
{
  "name": "mygame",
  "version": "1.0.0",
  "title": "My Godot Game",
  "author": "You",
  "desc": "A cool game",
  "license": "MIT",
  "category": "puzzle"
}
```

`category` is optional free text a client may group titles by — *puzzle*, *action*, *demo*. Since 0.12 it lands on the `Software` record and in `GET /api/software`; the WarpEngine Client draws a category axis in its filter as soon as a catalog publishes one. `site` and `repo` are optional too and become external links.

| Platform | Source expectations | What the pipeline builds |
|---|---|---|
| `godot` | export presets: Web, Windows x86, Windows x64, Linux x64, Mac universal | `godot --headless --export-release` per preset |
| `bevy` | standard Cargo project; `assets/` is packaged next to native binaries | WASM, linux-x64, win-x64 (mingw) |
| `love` | standard LÖVE project | love.js web, Windows exe, Linux AppImage, universal macOS `.app` |
| `ebitengine` | standard Go project | WASM, win x86/x64, linux x64, mac x64/arm64 |
| `phaser` | JS files in `src/` | syntax check, Phaser runtime download, web zip |
| `c64` | `main.asm`, or a Makefile with `build: make` | ACME assembly into a `.prg` cartridge (or `make <name>.prg`, cc65 is in the builder image), then `commodore-pack` in the `exporter` image: an EmulatorJS web build with the `vice_x64sc` core and yapeSDL bundles for linux-x64, win-x64 and mac-arm64 |
| `plus4` | the same as `c64` | the same as `c64` with the `vice_xplus4` core and yapeSDL in Plus/4 mode |
| `dos` | `SOURCE/build.sh`, its tests, and the `dos` block of `metadata.json` (see [DOS](https://wiki.teletypegames.org/platforms/dos)) | the commands of the `dos-builder` image ([dos-tools](https://wiki.teletypegames.org/platforms/dos)): the build and the tests on every event; on a tag the installable folder zip (and the Win32 one) and the forge release; the FTP store is the DOS shelf, built from the catalog |
| `tic80` | `<name>.inc` listing fragments from `inc/` | concat, luacheck, minify, LDoc, HTML + cartridge + native binaries |

TIC-80 needs no `metadata.json` — the metadata is the Lua header:

```lua
-- title: My TIC Game
-- name: mygame
-- author: You
-- desc: A TIC-80 game
-- category: puzzle
-- version: 1.0.0
function TIC() end
```

## Marker options

| Key | Required | Description |
|---|---|---|
| `platform` | yes | `godot`, `tic80`, `bevy`, `love`, `ebitengine`, `phaser`, `c64`, `plus4`, `dos`, or a platform the host registered |
| `name` | no | Software name; defaults to the repo name |
| `release` | no | `tag` (default since 1.9): the build runs on every event, a push ends with the `tag` step, and the release steps (package, upload, publish, release) run on tags only. `push`: the way before 1.9, every push publishes and nothing is tagged. A platform can require tags (`release_rule "tag"` in its service), and then the marker cannot choose `push`: DOS is such a platform |
| `build` | no | `make` on `c64` and `plus4`: `make <name>.prg` instead of ACME on `main.asm`, for a program in C (WARP64U is built this way) |

Any other key is an option a platform's template may read: letters, digits and `_ . -` only, the rest is dropped. The repo's other `.woodpecker/*.yaml` files are passed back unchanged, so a repo can keep a workflow of its own (its tests, a deploy) next to the generated one.

## Preview a pipeline

```sh
curl "https://your-host/build/config?platform=godot"
```

## Paging and vocabulary (0.13)

`GET /api/software` answers in an envelope — `api` (the JSON shape's own version, 2), `page`, `perPage`, `total`, `totalPages`, `nextPage`, and `softwares`. **Without `page` the whole catalog comes back** in that envelope, which is what every reader before API version 2 finds under `softwares`; with `page` it is one page of `per_page` entries (`catalog_page_size` when unsaid, at most `catalog_max_page_size`). Filters narrow the catalog before it is paged; the access policy decides visibility before either.

Statuses and categories are a **vocabulary in the database** — `WarpEngine::SoftwareStatus` and `WarpEngine::SoftwareCategory`, editable in the admin — not a list in the code. A software's `status` must be a known key: the migration seeds the four, and since 0.13.1 the model puts them back into an empty table the first time anything asks — a database built with `db:schema:load` rather than the migration, which is every test database, has no rows otherwise. A category a publish brings that nobody has named yet is added to the vocabulary with its key as its label and `needs_review` set. `GET /api/software/vocabulary` publishes both, and the site's catalog page and the WarpEngine Client take their labels from it.

## Secrets and versions

Every generated pipeline expects one Woodpecker secret, `application_token`, sent as the `X-Update-Secret` header to `/build/upload` and `/build/publish`.

The `tag` step (1.9) writes tags with the `gitea_token` secret, a Forgejo token with repository write access (on GitLab a `TAG_TOKEN` CI/CD variable, an access token with the `api` scope). On teletypegames.org it is a global Woodpecker secret, so a new repo needs nothing for it.

Versioning is the same everywhere, because since 1.7 the engine adds the `version` step itself (see below).

## Steps every pipeline shares

Since 1.7 no template carries these steps; the engine adds them to every pipeline it renders:

- **`version`**, first. The version comes from `metadata.json` (or the `-- version:` line of the Lua header on TIC-80). On a tag a version tag (`v1.2.3` or `1.2.3`) has to match it, and is the version when the source names none; a tag that does not match fails the pipeline. Off the repo's default branch it becomes `dev-<version>-<branch>`, e.g. `dev-1.0.0-my-feature`, and so on a `dev-<branch>` tag; with no version at all the build is `dev-<commit>`, which is never the latest. Since 1.9 the default branch is the repo's own (`CI_REPO_DEFAULT_BRANCH`), so a repo whose default is `develop` releases from `develop`; before, it was `main` or `master`.
- **`tag`** (1.9), after a push build, unless the marker says `release: push`: tags a new version, or moves the branch's dev tag. See *Versions and releases*.
- **`release`**, last, on a version tag (a `dev-` tag makes none), when the host sets `forge_release: true` in the platform's entry (any platform can have it): the Gitea/Forgejo release of the tag (a GitLab release on GitLab), named after the title and the tag, with `RELEASE_NOTES.md` as its text, made with the `gitea_token` secret. It attaches the files the build listed in `.release-assets`, or else the files the upload step uploaded. On teletypegames.org only `dos` has it.

Since 1.9.1 a generated Woodpecker pipeline starts on every event but `release` (a workflow `when`, left out where a template has its own): moving a dev tag makes Forgejo send a release event besides the tag one, and that built the same commit a second time.

Every template names its steps the same way: the one that sends the files to `/build/upload` is `upload` (c64, plus4, ebitengine and dos called it `artifact` before 1.7), the one that calls `/build/publish` is `publish`. On `c64` and `plus4` only the players that were built are uploaded; LÖVE no longer uploads a `.love.zip`.

## Versions and releases

Since 1.9 nobody tags by hand and nobody publishes by hand: the version in the source decides everything.

1. **The version lives in the source** — `version` in `metadata.json`, the `-- version:` line on TIC-80. Raising it is the release.
2. **A push to the default branch with a new version** builds, and the `tag` step tags the pushed commit `v<version>` (through the forge API). That tag starts a pipeline of its own, which uploads and publishes. A forge release is made too where the platform has `forge_release`.
3. **A push with a version that is tagged already** only builds. Committing without raising the version publishes nothing.
4. **A tag made by hand** works as before: it has to match the version in the source, or the `version` step fails the pipeline, and the `tag` step leaves an existing tag alone.
5. **Versions only grow.** `/build/publish` answers `409` to a version older than the newest one published (by `Gem::Version`): it would become the latest release and bring back its old metadata. Publishing the same version again, a newer one, or any `dev-` version goes through.
6. **Dev builds always follow the branch.** Off the default branch the version is `dev-<version>-<branch>`; with no version in the source, `dev-<commit>`. For such a build the `tag` step **moves** the branch's `dev-<branch>` tag onto the pushed commit (creating it the first time), and the tag's pipeline publishes the dev build, overwriting the previous one of the same name. A dev version is never a title's latest release, and makes no forge release.
7. **Opting out:** `release: push` in the marker publishes on every push, with no tags, as before 1.9. DOS cannot opt out.

The earlier versions of every engine-built teletypegames.org repo were tagged in one go on 2026-10-27 (`script/backfill_version_tags.rb` in the monorepo: each version's tag on the first default-branch commit that carried it), so the tags list a repo's whole history.

## Values a template takes from the host

Since 1.5 the templates hold no host's addresses. A platform's entry in the CI adapter's `platforms:` carries what differs from host to host:

- `web_shell:` — the page a bevy, ebitengine or phaser browser build is wrapped in. With a URL the pipeline downloads it; without one it takes the repo's own `web/index.html`.
- `bundle_id_prefix:` — the LÖVE macOS app's bundle id is `<prefix>.<name>` (default `org.warpengine`).
- `exporter:` — on `c64` and `plus4`, the image with `commodore-pack <c64|plus4> <name> <version>`, which writes the browser player and the three native players next to the PRG.

Our hosts set the Teletype Games values, so their pipelines render as before.

## Platforms of your own

A host adds a platform in its initializer — a service class, the kinds its CI builds, and a pipeline template per CI adapter:

```ruby
c.register_platform "amiga",
                    service:     "AmigaPlatform",
                    built_kinds: %w[cartridge],
                    templates:   { woodpecker: Rails.root.join("ci/amiga.woodpecker.yaml.erb") }
```

The service includes `WarpEngine::Platforms::Updatable` and the build concerns of its kinds, and names itself (`platform "amiga"`, `label "Amiga"`, `required_kinds "cartridge"`). From then on it is a platform like the others: a software may be on it, a publish runs its service, the admin lists it and the config extension renders its template. Everything that used to read the fixed list reads `WarpEngine::Platform.names` now.

## Shelves

A shelf (since 1.2) is a folder the engine keeps in step with the catalog: for one platform, the latest release of every public title under a fixed, versionless name, plus an `INDEX.TXT` (`file|version|size|crc32|title|author|description`, ASCII, at most 255 bytes a line). It is for clients that cannot read the JSON API over HTTPS. `c.shelves` names the platform, kind, path, extension and name length; it needs the `:local` storage adapter. The software's `shelf_name` (from `metadata.json`, the admin or the Admin API) names the file. A shelf is rebuilt on every publish and every change to its titles, the index replaced last; `rake warp_engine:shelves:check` and `warp_engine:shelves:sync` check and rebuild it by hand.

Since 1.7 a shelf can have `layout: "folders"`: one folder per title, the layout [DOSINST](https://wiki.teletypegames.org/dos/dosinst) reads. The engine unpacks the release zip's top folder, leaves out the `settings` files of the metadata's `dos` block (so an update keeps an installed program's configuration), and writes two files into each folder: `INFO.INI` (`[program]` with `name`, `description`, `version` and `platform` `DOS` or `WIN32`, CRLF) and `FILES.LST` (`crc32 size path`, CRLF). `kind` may be a list: the DOS shelf holds `cartridge` and `win_x86`, so DOSINST, for example, has a `DOSINST` and a `DOSINSTW` folder. Only folders the shelf's `INDEX.TXT` listed are ever removed, so a folder put there by hand (a test program, say) stays.

A shelf also has `urls`, where the host serves it, and `GET /api/service` lists the shelves under `shelves` (platform, kinds, layout, index, urls). Serving stays the host's job: the engine writes the folder and runs no FTP or HTTP server of its own.

teletypegames.org keeps three shelves, served by the [FTP server](https://wiki.teletypegames.org/infrastructure/ftp-server) over FTP and plain HTTP:

| Shelf | Folder | Layout | Served at |
|---|---|---|---|
| C64 | `shelves/C64` | flat | `ftp://ftp.teletypegames.org/C64`, `http://archive.teletypegames.org/C64` |
| Plus/4 | `shelves/PLUS4` | flat | `ftp://ftp.teletypegames.org/PLUS4`, `http://archive.teletypegames.org/PLUS4` |
| DOS | `shelves/APPS` | folders | `ftp://ftp.teletypegames.org/APPS`, `http://archive.teletypegames.org/APPS` |

# CI management (Woodpecker API)

Active only where the adapter has a `url` and an `api_token` (`WarpEngine.ci.configured?`). Until then the admin pages hide themselves.

- **Repo sync** — *Admin → Pipelines → Sync from Woodpecker* (the button carries the adapter's name). Mirrors the repo list into `Pipeline` records, matching each repo to a `Software` by name and inheriting its platform. Repos gone from Woodpecker are deactivated. Links stay editable by hand.
- **Pipelines** — each entry lists its recent runs (status, branch, message, time) with a manual **Trigger**. The newest run refreshes the cached status on the index. A software's admin page links to its pipelines.
- **Secret provisioning** — creating a token provisions the `application_token` secret to the owner's repos (unrestricted tokens: every active repo); deleting removes it; **Rotate** creates a replacement, provisions it everywhere and revokes the old one in one step.

The PAT must belong to a Woodpecker **instance admin** — listing repos is an admin-only endpoint, anything else returns `403 User not authorized`. Add the user to `WOODPECKER_ADMIN`, then **log out and back in**: the admin flag is written at login, a server restart is not enough.

# Build authentication

`X-Update-Secret` carries one of two credential types, chosen by `application_token_source`. The modes are exclusive.

| Mode | Credential |
|---|---|
| `:env` (default) | the shared secret in `update_secret`; unset means every request is rejected |
| `:database` | `WarpEngine::ApplicationToken` records: an owner (e.g. `AdminUser`), scopes (`update` to publish, `upload` to upload), optional expiry. Created in the admin; the plain token is shown once, only its SHA256 digest is stored. `last_used_at` tracks use. |

> **Cutover warning:** switching to `:database` invalidates the shared secret immediately. Create the tokens first.

# Installing into a host

```ruby
# Gemfile — from git:
gem "warp_engine", git: "https://git.teletypegames.org/engines/warp_engine.git"

# ...or from the Forgejo rubygems registry (tagged releases):
source "https://git.teletypegames.org/api/packages/engines/rubygems" do
  gem "warp_engine"
end
```

```sh
rails g warp_engine:install   # initializer, image library, migrations
rails db:migrate
```

```ruby
# config/routes.rb
namespace :api do
  get "image/:id", to: "images#show"   # the image library is yours (see below)
end

# keep the mount last so the host's own routes win
mount WarpEngine::Engine => "/"
```

The generator writes `config/initializers/warp_engine.rb`. Since 1.8 it is a block without a parameter, one line per setting:

```ruby
Rails.application.config.to_prepare do
  WarpEngine.configure do
    file_container_path ENV.fetch("FILE_CONTAINER_PATH", "/softwares")

    # The image library is the host's; "Image" is the adapter's default.
    # image_class "Media::Picture"
    # image_adapter MyImageLibrary.new

    update_secret ENV["UPDATE_SECRET"]

    # application_tokens from: :database, owner: "AdminUser"

    # One adapter carries the whole CI side: server, recipes, signing key.
    ci :woodpecker do
      url            ENV["WOODPECKER_URL"]
      api_token      ENV["WOODPECKER_API_TOKEN"]
      repo_owner     ENV["WOODPECKER_REPO_OWNER"]
      public_key_url "https://ci.example.org/api/signature/public-key"
      update_server  "https://catalog.example.org"
      tools          "registry.example/ci-tools:1.0"   # every platform's, unless it names its own (1.1)

      platform :godot,      builder: "registry.example/godot-builder:4.6"
      platform :tic80,      builder: "registry.example/tic80-builder:1.0"
      platform :bevy,       builder: "registry.example/bevy-builder:1.0"
      platform :love,       builder: "registry.example/love-builder:1.0"
      platform :ebitengine, builder: "registry.example/ebitengine-builder:1.0"
      platform :phaser,     builder: "registry.example/phaser-builder:1.0"
      platform :c64,        builder: "registry.example/c64-builder:1.0", exporter: "registry.example/commodore-player:1.0"
      platform :plus4,      builder: "registry.example/plus4-builder:1.0", exporter: "registry.example/commodore-player:1.0"
    end

    # max_upload_size 500 * 1024 * 1024
    # enforce_software_ownership true
  end
end
```

The block also takes `shelf …`, `admin_api do … end` and `additional_resource … do … end` (permitted attributes, the admin form, and an `api_schema` built from `string`/`integer`/… lines in camelCase). An unknown setting or key stops the boot. The settings carry the names of the table below, except `image_class` (`image_class_name`), `application_tokens from:, owner:` (the two `application_token_*` keys) and `ci` (`ci_adapter`). The `WarpEngine.configure do |c| c.site_url = … end` form of the versions before 1.8 still works; both write the same configuration.

| Key | Default | Description |
|---|---|---|
| `file_container_path` | `ENV["FILE_CONTAINER_PATH"]` or `/softwares` | Where build artifacts are stored |
| `update_secret` | `ENV["UPDATE_SECRET"]` | Shared secret for `/build/*` |
| `application_token_source` | `:env` | `:env` or `:database`, exclusive |
| `application_token_owner_class` | `nil` | Owner model for `:database` mode |
| `max_upload_size` | 500MB | Cap for `/build/upload` |
| `enforce_software_ownership` | `false` | DB token owner isolation |
| `ci_adapter` | `:woodpecker` | Which CI server builds the games; `:none` turns CI off, or a host object |
| `image_class_name` | `"Image"` | The host model behind the image library |
| `image_adapter` | `nil` | Replaces the default host-model adapter |
| `access_policy` | `:open` | Access policy object |
| `access_token_owner_class` | `nil` | User model for device sign-in; nil = no sign-in |
| `admin_api.*` | `enabled = false` | The [Admin API](https://wiki.teletypegames.org/engines/warp-engine/admin-api) and its MCP server (1.0) |

A platform spec takes three images. `builder` runs the build steps. `exporter` is a second image a template may run a step in — the c64 and plus4 pipelines package the `.prg` into the browser player and the native players there. `tools` (1.1, optional) runs the helper steps: version, lint, minify, docs, upload, publish. Without it they run in `alpine` and install git, jq, curl, zip and lua5.4 with `apk add`, plus luacheck and ldoc with `luarocks install` for tic80, on every run; with it they install nothing, so the image must already carry those, with Lua 5.4 as `lua`. In the DSL, `tools` in the `ci` block is every platform's default. The upload and publish steps get the `application_token` secret, so reference the tools image by digest. teletypegames.org gives every platform its `build/ci-tools` image this way.

# Admin integration

The host owns the ActiveAdmin instance (auth, theme, routes); WarpEngine appends its resources to `ActiveAdmin.application.load_paths`. A new host needs two things:

- the file-picker JS for release-asset path inputs (iframe → `/admin/files?picker=1&field=<dom_id>`) in its `active_admin.js`;
- with apipie docs, `"#{WarpEngine::Engine.root}/app/controllers/**/*.rb"` in `api_controllers_matcher`.

The same resources are a JSON API too since 1.0 — see [WarpEngine Admin API](https://wiki.teletypegames.org/engines/warp-engine/admin-api). The admin pages and the API call the same services (`ApplicationTokenService`, `DownloadStatsService`, `FileManagerService`), so the two cannot drift apart.

# Behaviour notes

- Every model is soft-deleted: a `deleted_at` column and the `kept` / `discarded` scopes. Since 0.10 there is no `default_scope`, so a query does not filter by itself — a host asks for `WarpEngine::Software.kept`, and one upgrading from 0.9 or earlier adds `.kept` where it relied on the default scope.
- The JSON shape is stable and bug-compatible with the former Go backend: Go zero-time timestamps, camelCase keys, legacy flat path fields.
- Model extension points: `ActiveSupport.on_load(:warp_engine_<model>)`.

# Testing

```sh
bundle install
bundle exec rake app:db:prepare RAILS_ENV=test
bundle exec rspec
```

'; var prevLevel = 2; for (var i = 0; i < headings.length; i++) { var h = headings\[i\]; var text = h.textContent.trim(); var slug = text.toLowerCase() .replace(/\[áàâä\]/g,'a').replace(/\[éèêë\]/g,'e').replace(/\[íìîï\]/g,'i') .replace(/\[óòôöő\]/g,'o').replace(/\[úùûüű\]/g,'u') .replace(/\[^a-z0-9\]+/g, '-').replace(/^-|-$/g, ''); if (!slug) slug = 'heading-' + i; if (used\[slug\]) { slug += '-' + i; } used\[slug\] = true; h.id = slug; var level = parseInt(h.tagName.charAt(1)); var cls = level > 2 ? ' class="toc-child"' : ''; html += '- [' + text + '](#'%20+%20slug%20+%20')
'; } html += '
'; toc.innerHTML = html; // Scroll-spy: highlight current heading var links = toc.querySelectorAll('a'); var ids = \[\]; for (var j = 0; j < links.length; j++) ids.push(links\[j\].getAttribute('href').slice(1)); function updateActive() { var current = ''; for (var k = 0; k < ids.length; k++) { var el = document.getElementById(ids\[k\]); if (el && el.getBoundingClientRect().top <= 80) current = ids\[k\]; } for (var m = 0; m < links.length; m++) { links\[m\].parentNode.classList.toggle('active', links\[m\].getAttribute('href') === '#' + current); } } window.addEventListener('scroll', updateActive, { passive: true }); updateActive(); })();

---

## Navigation

- Parent: [Engines](https://wiki.teletypegames.org/engines.md)
- Previous: [RuBBS](https://wiki.teletypegames.org/engines/rubbs.md)
- Next: [warpstore](https://wiki.teletypegames.org/engines/warpstore.md)
- Children:
  - [WarpEngine Admin API](https://wiki.teletypegames.org/engines/warp-engine/admin-api.md)
  - [WECP](https://wiki.teletypegames.org/engines/warp-engine/wecp.md)
