EN /HU | Login

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.

What you get

  • Catalog domainSoftware, Release, ReleaseAsset, ExternalLink, PlatformLink, SoftwareImage, Download. All soft-deleted, with download tracking.
  • CI pipeline server — a Woodpecker 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 endpointsPOST /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.

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.

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.

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:

TXT
$ 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.

Endpoints

Public API

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.

SH
$ 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.

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.
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 POSTs /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.
  5. The pipeline runs: version → build → upload → publish.

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"
}
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:

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

Marker options

Key Required Description
platform yes godot, tic80, bevy, love, ebitengine, phaser, c64
name no Software name; defaults to the repo name

Preview a pipeline

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

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.

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.

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 syncAdmin → 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:

RUBY
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

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.

Behaviour notes

  • Every model is soft-deleted (default_scope { where(deleted_at: nil) }).
  • 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