WarpEngine is a mountable Rails engine that turns any Rails app into a retro software catalog. A game repo carries a one-line
.woodpecker.yamlmarker, 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.
Software, Release, ReleaseAsset, ExternalLink, PlatformLink, SoftwareImage, Download. All soft-deleted, with download tracking.WarpEngine::CI::Woodpecker and is the default; :none turns CI off, and another server is a host-supplied object.POST /build/upload and POST /build/publish, authenticated by a shared secret or by database application tokens.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.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.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.
| Platform | Label | Assets accepted | The pipeline builds |
|---|---|---|---|
godot |
Godot | html, win_x86, win_x64, linux_x64, mac_universal | all of them |
tic80 |
TIC-80 | cartridge, source, html, docs, win_x64, linux_x64, mac_x64 | all of them |
bevy |
Bevy | html, win_x64, linux_x64, linux_arm64 | all of them |
love |
LÖVE | html, win_x64, linux_x64, mac_universal | all of them |
ebitengine |
Ebitengine | html, win_x86, win_x64, linux_x64, linux_arm64, mac_x64, mac_arm64 | html, win_x86, win_x64, linux_x64, linux_arm64 |
phaser |
Phaser | html | all of them |
c64 |
C64 | 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.
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 |
[+] |
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 and Teletype Orbit.
| Endpoint | Purpose |
|---|---|
GET /api/service |
What this deployment is: version, whether the catalog gates, how to sign in. |
GET /api/software |
Full catalog with releases, assets, links, download counts and an access block. |
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.
$ curl -sI https://teletypegames.org/api/software | grep -i warpengine
WarpEngine-Version: 0.5.0
The host app's own endpoints (the store registry, for example) do not carry it — they are not part of the engine.
| 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. |
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. |
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.
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:
"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.
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.
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
POSTs /api/auth/device and shows the userCode;WarpEngine::DeviceGrantService#approve(user_code:, subject:) — approving needs a session and HTML, which are not the engine's business;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.
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:
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.
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.
.woodpecker.yaml holds only platform: (and name: when the software name differs from the repo name).POST /build/config.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.A repo opts out by committing a real pipeline YAML: a non-marker config gets 204 No Content and runs unchanged.
# .woodpecker.yaml
platform: godot
name: mygame # optional, defaults to the repo name
// 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"
}
| 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 |
ACME assembly into a .prg cartridge |
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:
-- title: My TIC Game
-- name: mygame
-- author: You
-- desc: A TIC-80 game
-- version: 1.0.0
function TIC() end
| Key | Required | Description |
|---|---|---|
platform |
yes | godot, tic80, bevy, love, ebitengine, phaser, c64 |
name |
no | Software name; defaults to the repo name |
curl "https://your-host/build/config?platform=godot"
Every generated pipeline expects one Woodpecker secret, application_token, sent as the X-Update-Secret header to /build/upload and /build/publish.
Versioning is the same everywhere: on main/master the version comes from metadata.json (or -- version: for TIC-80); on other branches it is prefixed, e.g. dev-1.0.0-my-feature.
Active only where the adapter has a url and an api_token (WarpEngine.ci.configured?). Until then the admin pages hide themselves.
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.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.
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
:databaseinvalidates the shared secret immediately. Create the tokens first.
# 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
rails g warp_engine:install # initializer, image library, migrations
rails db:migrate
# 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:
Rails.application.config.to_prepare do
WarpEngine.configure do |c|
c.file_container_path = ENV.fetch("FILE_CONTAINER_PATH", "/softwares")
# The image library is the host's; "Image" is the adapter's default.
# c.image_class_name = "Media::Picture"
# c.image_adapter = MyImageLibrary.new
c.update_secret = ENV["UPDATE_SECRET"]
# c.application_token_source = :database
# c.application_token_owner_class = "AdminUser"
# One adapter carries the whole CI side: server, recipes, signing key.
c.ci_adapter = WarpEngine::CI::Woodpecker::Adapter.new(
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",
platforms: {
"godot" => { builder: "registry.example/godot-builder:4.6" },
"tic80" => { builder: "registry.example/tic80-builder:1.0",
exporter: "registry.example/tic80pro:1.0" },
"bevy" => { builder: "registry.example/bevy-builder:1.0" },
"love" => { builder: "registry.example/love-builder:1.0" },
"ebitengine" => { builder: "registry.example/ebitengine-builder:1.0" },
"phaser" => { builder: "registry.example/phaser-builder:1.0" },
"c64" => { builder: "registry.example/c64-builder:1.0" }
}
)
# c.max_upload_size = 500 * 1024 * 1024
# c.enforce_software_ownership = true
end
end
| 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 |
The host owns the ActiveAdmin instance (auth, theme, routes); WarpEngine appends its resources to ActiveAdmin.application.load_paths. A new host needs two things:
/admin/files?picker=1&field=<dom_id>) in its active_admin.js;"#{WarpEngine::Engine.root}/app/controllers/**/*.rb" in api_controllers_matcher.default_scope { where(deleted_at: nil) }).ActiveSupport.on_load(:warp_engine_<model>).bundle install
bundle exec rake app:db:prepare RAILS_ENV=test
bundle exec rspec