mr.zeroandClaude Opus 5 6024ef71c3 Every CI action is a CRUD action, and the URLs do not move
`CiController` had three actions named after verbs — `pipelines`, `status`,
`trigger` — and built its JSON by hand in a private `pipeline_json`, in a
codebase where everything else is serialized by Blueprinter. Triggering a
build is the creation of a run, so it is a resource of its own:

    GET  /api/ci/pipelines             -> Api::Ci::PipelinesController#index
    GET  /api/ci/pipelines/:id/status  -> Api::Ci::PipelinesController#show
    POST /api/ci/pipelines/:id/trigger -> Api::Ci::PipelineRunsController#create

The addresses are untouched on purpose: they are what the CI and the scripts
already call, and renaming them would be a breaking change for a version that
does not need one. What changed is the shape behind them — three standard
actions across two controllers, `require_ci!` and the pipeline scope in a
shared base, and a `PipelineSerializer` that carries the fields the endpoint
already answered with, `repo_id` and `woodpecker_repo_id` included (the
duplicate stays: a client may read either, and ci_controller_spec asserts they
agree).

`/api/software/highlighted` answers with `#show` now rather than `#index`: it
returns one title, and the action name should say so. Same address, same body.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 09:01:34 +02:00
2026-08-06 13:58:58 +02:00

WarpEngine

A mountable Rails engine that turns any Rails application into a retro software catalog: catalog models, a CI-pipeline-callable release updater, a public read-only JSON API, and optional ActiveAdmin resources that plug into your app's existing admin.

Repository: https://git.teletypegames.org/engines/warp_engine

Features

  • Catalog domain: Software, Release, ReleaseAsset, ExternalLink, PlatformLink, SoftwareImage, Download models with soft-delete semantics and download statistics.
  • Platform registry: WarpEngine::Platform is the one place a platform is named. Platform.names lists them, Platform.find!("godot") answers with a value object that knows its label, its expected_kinds and its updater service. Reference it instead of writing a platform list of your own — PlatformLink::SUPPORTED_PLATFORMS is kept as an alias of Platform::NAMES.
  • CI-callable updater: your build pipeline uploads artifacts over HTTP and calls one endpoint — WarpEngine extracts archives, parses metadata and upserts the catalog records. Supported platforms out of the box: TIC-80, Ebitengine, LÖVE, C64, Godot, Bevy, Phaser. Authenticated by a shared secret or by per-owner database tokens with expiry and scopes (ApplicationToken, managed in the admin).
  • Pluggable access: a host supplies a policy and the catalog gains prices, entitlements and gated downloads — and says so in its API, so clients can show a paid title as paid instead of failing at the download. Default :open is the catalog as it always was.
  • Client sign-in: an RFC 8628 device authorization grant for clients with no browser of their own, over the host's own user model. Off unless configured.
  • Host-owned image library: the catalog links a software to its images and publishes their URLs, but the image model, its files, its endpoint and its admin page belong to the host — the same model can serve avatars or anything else the site has (see Images).
  • Pluggable storage: artifacts are served through a storage adapter (:local by default); a host can serve them from an object store without patching the engine.
  • Publish events: every published release emits ActiveSupport::Notifications (warp_engine.publish), so hosts can react to new builds without model callbacks.
  • Public JSON API: catalog listing, highlighted title, per-platform build matrix, download tracking, and a static file server for web-playable builds.
  • Admin (optional): if the host runs ActiveAdmin, WarpEngine contributes ready-made resources — a catalog editor with nested release/asset forms, a file manager with a picker mode, and download statistics. Without ActiveAdmin the engine runs headless (API + updater only).
  • Pluggable CI: pipeline configs, repo sync, build triggers and secret provisioning all go through one adapter. Woodpecker ships with the engine (WarpEngine::CI::Woodpecker) and is the default; :none turns CI off, and another server is a host-supplied object.

Requirements

  • Rails >= 8.0
  • A relational database (developed and tested against MySQL 8)
  • Optional: ActiveAdmin + Devise in the host app for the admin UI

Example stack (docker compose)

examples/compose boots everything the engine's workflow assumes, end to end: the catalog app itself and — behind a compose profile — a Gitea forge with Woodpecker CI, so you can watch a pipeline publish a release into the catalog.

Service Role Where
app Minimal Rails host with the engine mounted as a path gem (headless: API + updater) http://localhost:8080
mysql Catalog database internal
gitea Git forge (profile ci) http://gitea:3000
woodpecker + agent CI wired to gitea (profile ci) http://woodpecker:8000

Quickstart — catalog only

cd examples/compose
cp .env.example .env   # defaults work for a throwaway local demo
docker compose up --build

The first boot takes a few minutes: the app container bundles, runs rails g warp_engine:install and rails db:prepare, then serves on http://localhost:8080:

  • http://localhost:8080/api/software — the (empty) catalog
  • http://localhost:8080/api/builds — the platform build matrix
  • http://localhost:8080/api/docs — apipie API docs

Publish a release by hand

The updater contract is nothing but a handful of HTTP calls, so you can play the role of the CI pipeline yourself:

# 1. Fake a build: metadata, a web build and a windows artifact, named by
#    convention (the love platform requires the .html.zip web build)
cat > demo-0.1.0.metadata.json <<'JSON'
{ "name": "demo", "title": "Demo Game", "author": "You", "desc": "Hello", "license": "MIT" }
JSON
echo '<h1>demo</h1>' > index.html && zip demo-0.1.0.html.zip index.html
echo hello > game.bin && zip demo-0.1.0-win-x64.zip game.bin

# 2. Upload them (one request per file)
for f in demo-0.1.0.*; do
  curl -fs -H "X-Update-Secret: example-update-secret" \
    -F "file=@$f" "http://localhost:8080/build/upload?name=demo&version=0.1.0"
done

# 3. Publish the release
curl -X POST -H "X-Update-Secret: example-update-secret" \
  "http://localhost:8080/build/publish?platform=love&name=demo&version=0.1.0"

GET /api/software now lists Demo Game with html and win_x64 assets, http://localhost:8080/file/demo-0.1.0/index.html serves the extracted web build, and GET /api/download?path=demo-0.1.0-win-x64.zip serves the artifact while logging a download record.

Full loop — forge + CI (profile ci)

gitea and woodpecker address each other by service name, so let your browser resolve those names too:

echo "127.0.0.1 gitea woodpecker" | sudo tee -a /etc/hosts
  1. docker compose --profile ci up -d gitea, open http://gitea:3000, finish the install wizard (SQLite is fine) and create your admin user.
  2. In gitea: Settings → Applications → Manage OAuth2 Applications, create an app with redirect URI http://woodpecker:8000/authorize; copy the client id/secret into WOODPECKER_GITEA_CLIENT / WOODPECKER_GITEA_SECRET in .env.
  3. docker compose --profile ci up -d — then log in at http://woodpecker:8000 (OAuth via gitea) and enable your repository.

A pipeline publishes a release exactly like the by-hand steps above — build, upload, publish:

# .woodpecker.yaml in a game repo hosted on the example gitea
steps:
  publish:
    image: alpine
    environment:
      UPDATE_SECRET:
        from_secret: update_secret
    commands:
      - apk add --no-cache curl zip
      - # ... build your game, produce mygame-1.0.0.metadata.json + artifacts ...
      - for f in mygame-1.0.0.*; do curl -fs -H "X-Update-Secret: $UPDATE_SECRET" -F "file=@$f" "http://app:3000/build/upload?name=mygame&version=1.0.0"; done
      - curl -fs -X POST -H "X-Update-Secret: $UPDATE_SECRET" "http://app:3000/build/publish?platform=love&name=mygame&version=1.0.0"

(The agent attaches pipeline containers to the stack network, so app resolves. For real projects, the per-platform tools/*-tools repos ship ready-made Makefile + pipeline templates implementing this contract.)

Tear the stack down with docker compose --profile ci down -v.

Installation

From the git repository:

# Gemfile
gem "warp_engine", git: "https://git.teletypegames.org/engines/warp_engine.git"

Or from the Forgejo rubygems registry, which is where tagged releases land — a version rather than whatever a branch happens to hold:

source "https://git.teletypegames.org/api/packages/engines/rubygems" do
  gem "warp_engine", "~> 0.5"
end

The registry is publicly readable, so no credential is needed to install from it. Use a block-scoped source rather than a second global one: with two global sources Bundler cannot say which gem came from where.

Then:

rails g warp_engine:install   # initializer, image library (model + endpoint), migrations
rails db:migrate
# config/routes.rb
namespace :api do
  # The image library is yours; the engine only publishes these URLs.
  get "image/:id", to: "images#show"
end

# keep the mount the last entry so your own routes win
mount WarpEngine::Engine => "/"

Configuration

# config/initializers/warp_engine.rb
Rails.application.config.to_prepare do
  WarpEngine.configure do |c|
    # Where CI drops build artifacts
    c.file_container_path = ENV.fetch("FILE_CONTAINER_PATH", "/softwares")

    # Where the site this catalog belongs to lives. The admin links a software
    # to its public page from here; left nil, the link is left out.
    c.site_url = ENV["SITE_URL"]

    # Your image model (see "Images" below). "Image" is the default.
    # c.image_class_name = "Media::Picture"
    # c.image_adapter    = MyImageLibrary.new

    # Shared secret for the /build/* endpoints.
    # nil => the endpoints reject every request.
    c.update_secret = ENV["UPDATE_SECRET"]

    # Authentication source for /build/* — an exclusive choice:
    #   :env      — the shared secret above is accepted (default)
    #   :database — only WarpEngine::ApplicationToken records with the
    #               "update" scope are accepted; the shared secret stops
    #               working the moment you switch.
    # :database mode also requires the owner class every token belongs to:
    # c.application_token_source = :database
    # c.application_token_owner_class = "AdminUser"

    # Size cap for /build/upload and the admin file manager, in bytes (default 500MB).
    # c.max_upload_size = 500 * 1024 * 1024

    # Owner isolation: a database token may only upload/publish softwares
    # owned by its own owner (unrestricted tokens are exempt). Enable only
    # after backfilling owners — ownerless softwares are claimable by anyone.
    # c.enforce_software_ownership = true

    # Which CI server builds the games (see "CI" below). :woodpecker (default)
    # reads WOODPECKER_URL / WOODPECKER_API_TOKEN from the environment; naming
    # the adapter is better, because then the settings are here in the open.
    # c.ci_adapter = WarpEngine::CI::Woodpecker::Adapter.new(
    #   url: ENV["WOODPECKER_URL"], api_token: ENV["WOODPECKER_API_TOKEN"],
    #   platforms: { "godot" => { builder: "registry.example/godot-builder:4.6" } }
    # )
    # c.ci_adapter = :none   # this host builds nothing

    # Who may see a title and who may download it (see "Access" below).
    # :open (default) lists everything and serves everything.
    # c.access_policy = MyStore::AccessPolicy.new

    # Client sign-in (see "Client sign-in" below). nil (default) means there is
    # none: /api/auth/* answers 404 and GET /api/service reports auth: null.
    # c.access_token_owner_class  = "User"
    # c.identity_verification_url = "/devices"
    #
    # How to recognise a caller with a session instead of a bearer token.
    # c.subject_resolver = ->(request) { request.env["warden"]&.user }
  end
end

The updater contract

Publishing a release from CI is two steps:

  1. Upload build artifacts into file_container_path, named by convention: <name>-<version>.metadata.json, <name>-<version>.html.zip, <name>-<version>-win-x64.zip, <name>-<version>.tic, ... (each platform declares which asset kinds it expects — see GET /api/builds). Push them over HTTP — one request per file, upload scope, optional sha256 integrity check:

    curl -H "X-Update-Secret: $UPDATE_SECRET" \
      -F "file=@mygame-1.2.0.html.zip" \
      "https://your-host/build/upload?name=mygame&version=1.2.0"
    
  2. Publish the release:

    curl -X POST -H "X-Update-Secret: $UPDATE_SECRET" \
      "https://your-host/build/publish?platform=tic80&name=mygame&version=1.2.0"
    

WarpEngine extracts the archives, parses the metadata (JSON, or the Lua comment header for TIC-80), and upserts the Software, ExternalLink, Release and ReleaseAsset records in a single transaction. Previously deleted records are resurrected on re-ingest.

Updater authentication

The X-Update-Secret header carries one of two credentials, selected by application_token_source — the modes are exclusive, the endpoint never accepts both:

  • :env (default): the single shared secret from update_secret.
  • :database: WarpEngine::ApplicationToken records. Each token belongs to an owner (the class named by application_token_owner_class, e.g. AdminUser), carries a free-form scope list — publishing requires the "update" scope, /build/upload the "upload" scope — and an optional expiry. Tokens are created in the admin (App Tokens): the plain token is generated server-side and shown exactly once after creation; only its SHA256 digest is stored. Deleting a token in the admin revokes it (soft delete), and last_used_at records when each token last authenticated successfully.

When switching to :database, create the tokens and move your pipelines to them first — the flip invalidates the shared secret immediately.

CI

Every CI feature — serving pipeline configs, syncing repositories, triggering builds, provisioning secrets — goes through one adapter. Woodpecker ships with the engine as WarpEngine::CI::Woodpecker, and it is the default:

# nothing set: Woodpecker, configured from ENV, exactly as before
c.ci_adapter = :woodpecker

# named explicitly (recommended — the host says what its CI is)
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" } }
)

# this host builds nothing: /api/ci/* answers 503, /build/config refuses,
# and the admin pipeline actions hide themselves
c.ci_adapter = :none

The engine's own code never names a provider: WarpEngine.ci is the adapter, WarpEngine::CI::Repo and WarpEngine::CI::Run are what it hands back, and WarpEngine::CI::ConnectionError / ApiError / NotConfigured are what it raises. A host with a different CI server implements this contract instead:

Method Purpose
name provider label, shown in the admin
configured? false keeps every CI feature inactive
platforms which platforms /build/config can serve
update_server server URL written into the generated pipeline; nil = the request's base_url
repos, repo(id) CI::Repo values for the repo sync
activate_repo(id), deactivate_repo(id) enable/disable a repository
runs(repo_id, page:), run(repo_id, number) CI::Run values (number may be "latest")
trigger(repo_id, branch:) start a build, returns a CI::Run
secret_names(repo_id), secret_set(repo_id, name:, value:), secret_delete(repo_id, name) token provisioning
verify_config_request(request) is this really the CI server calling
config_marker(params) { platform:, name: } from the request, or nil for "not ours"
pipeline_config(platform:, name:, update_server:) the pipeline definition, or nil for an unknown platform
config_response(platform:, config:) the body the CI server expects

Pipeline records keep the provider-neutral remote_repo_id reader over the historical woodpecker_repo_id column, so the table did not have to move.

Pipeline configs (the Woodpecker adapter)

WarpEngine can act as a Woodpecker configuration extension: instead of a copy-pasted .woodpecker.yaml in every game repo, the repo holds a one-line marker and the engine serves the full per-platform pipeline (version → build → upload → publish, calling /build/upload + /build/publish with the application_token Woodpecker secret):

# .woodpecker.yaml in a game repo
platform: godot
  • POST /build/config — the extension endpoint Woodpecker calls on every pipeline start (httpsig/ed25519-signed request, verified against the adapter's public_key / public_key_url). Non-marker configs get a 204 so the repo's own YAML keeps running — opt-in migration, and putting a full pipeline back into the repo is the opt-out.
  • GET /build/config?platform=godot — renders the same pipeline as a preview.

The adapter's platforms maps platform names to builder images ({ "godot" => { builder: "..." }, "tic80" => { builder: ..., exporter: ... } }); an empty map (default) disables the feature. Set the Woodpecker side with WOODPECKER_CONFIG_EXTENSION_ENDPOINT=https://your-host/build/config (or per-repo in Settings → Extensions). Templates live with the adapter, in lib/warp_engine/ci/woodpecker/platforms/<platform>/pipeline.yaml.erb — the YAML dialect belongs to the provider, so a second adapter brings its own.

CI management (the Woodpecker adapter)

Beyond serving pipeline configs, the adapter drives the Woodpecker REST API itself. Give it a url and an api_token — with either missing, configured? is false, every management feature stays inactive and the admin pages hide themselves.

What it unlocks (all surfaced in the admin):

  • Repo sync (Pipelines → Sync from …): mirrors the provider's repo list into Pipeline records, auto-matching each repo to a catalog Software by name; repos that disappear from Woodpecker are deactivated. Platform and software links are editable by hand afterwards.
  • Pipeline history: each entry on the Pipelines page lists its recent runs with a manual Trigger action; the newest run refreshes the cached last-pipeline status shown on the Pipelines index. The software's admin page links to its pipelines from the Quick Links sidebar.
  • Secret provisioning: database application tokens are pushed to the repos as the application_token CI secret — creating a token provisions it to its owner's repos (unrestricted tokens to all active repos), deleting a token removes the secret, and Rotate creates a replacement token, provisions it everywhere and revokes the old one in a single step.

The API token must belong to a Woodpecker instance admin — listing the server's repos is an admin-only endpoint (anything less yields 403 User not authorized). Add the user to WOODPECKER_ADMIN on the Woodpecker server, then log out and back in: the admin flag is written to the user record at login, a server restart alone is not enough.

Storage

Build artifacts are served through a storage adapter. The default is the local filesystem under file_container_path — byte for byte the behaviour the engine always had:

c.storage_adapter = :local   # default

A host that keeps its artifacts elsewhere (an object store behind a CDN, for example) can plug in its own object instead of patching the engine. The contract is three methods:

class MyObjectStore
  def file?(relative_path)      = ...   # true/false
  def directory?(relative_path) = ...   # true/false

  # Return a WarpEngine::Storage::Location:
  #   Location.file(absolute_path)  — the engine will send_file it
  #   Location.redirect(url)        — the engine will redirect (signed URL)
  def locate(relative_path, filename: nil, expires_in: nil) = ...
end

c.storage_adapter = MyObjectStore.new

GET /api/download and GET /file/* both go through the adapter, so a signing adapter turns them into redirects without any further change. WarpEngine::DownloadService#create still returns an absolute path (and nil when there is none), so existing callers keep working; #locate is the new entry point that can also hand back a redirect.

Serving only. Ingestion — POST /build/upload, archive extraction and the admin file manager — still writes to the local disk. A remote adapter needs its own upload path today.

Images

An image is not a catalog concept: the same picture library serves covers, screenshots, avatars and whatever else a site has. So the engine owns only the link — SoftwareImage, ordered, with one default — while the Image model, the uploaded files and the admin page live in the host. rails g warp_engine:install writes a working one (app/models/image.rb, app/controllers/api/images_controller.rb, a create_images migration); the engine talks to it through an adapter:

c.image_class_name = "Image"   # default

The default adapter expects that model to answer original_filename, content_type, file_path and file_upload= (the generated one does), and publishes every image as /api/image/<id> — the address clients have always used, which is why the generated route serves exactly that. A host that keeps its pictures somewhere else replaces the adapter instead:

class MyImageLibrary
  def model_name                = "Media::Picture"
  def model                     = Media::Picture
  def url_for(image_id)         = "https://cdn.example.com/#{image_id}.webp"
  def build_from_upload(upload) = Media::Picture.new(file_upload: upload)
  def label_for(record)         = record.title
  def available?(record)        = record.stored?
  def select_options            = Media::Picture.order(:title).pluck(:title, :id)
end

c.image_adapter = MyImageLibrary.new

url_for is what lands in the catalog JSON (imageUrl, images[].url) and in the admin previews; select_options fills the image picker on the software form; build_from_upload is what "upload a new image" on that form calls.

The images table stays where it is. Moving the model out of the engine did not touch it: the engine no longer creates it (the install generator does, as host code), and software_images.image_id still points at the same rows.

Access

Who may see a title, and who may download it. The default answers "everyone" to both — every software listed, every artifact served, no prices — which is the catalog the engine always had:

c.access_policy = :open   # default

A host that sells supplies a policy instead. The contract is three methods:

class MyStore::AccessPolicy
  # Which titles GET /api/software lists at all.
  def visible_software_scope(subject: nil) = ...   # an ActiveRecord scope

  # What a client is told about one title.
  def access_for(software:, subject: nil)
    WarpEngine::Access.new(
      gated: true, entitled: false,           # needs an entitlement; this caller has none
      price_cents: 1490, currency: "EUR",
      purchase_url: "https://shop.example/games/slug",
      web_url: "https://shop.example/play/slug"   # nil keeps the engine's own /file/ path
    )
  end

  # nil refuses the download; a Grant allows it.
  def authorize_download(asset:, subject:, request:) = WarpEngine::Access::Grant.new
end

c.access_policy = MyStore::AccessPolicy.new

subject is whoever the request authenticated as, or nil for an anonymous caller — deliberately untyped, because the engine has no user model and whose object this is belongs to the host.

Every catalog entry carries an access block, including under the open policy, so a client never has to tell "this catalog says nothing" from "this title is not gated":

"access": { "gated": false, "entitled": true, "price": null,
            "purchaseUrl": null, "webUrl": null }

The vocabulary is generic on purpose. A client reads more than one store, and a word from any one host's domain would make it specific to that host.

A policy that raises is treated as a refusal: an empty catalog and a denied download, logged. An artifact served because the gatekeeper crashed is the one failure mode this engine must not have.

Client sign-in

A desktop client has no cookie jar and no browser session, so it cannot host a login form without asking somebody to type a password into a window that is not a browser. The engine implements the device authorization grant (RFC 8628) instead — but only where a host has said whose tokens these are:

c.access_token_owner_class  = "User"      # nil (default): no sign-in at all
c.identity_verification_url = "/devices"  # your page where a person types the code
c.device_code_ttl           = 600
c.device_code_interval      = 5

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

The flow:

  1. the client POSTs /api/auth/device and shows the userCode it gets back;
  2. the person opens verificationUrl in a browser and types that code;
  3. your page calls WarpEngine::DeviceGrantService#approve(user_code:, subject:) with the signed-in user — approving needs a session and HTML, neither of which is the engine's business;
  4. the client's next POST /api/auth/device/token carries the token away. It is handed over exactly once and never stored in the clear afterwards.

Recognising a browser

A bearer token is what a client carries; a browser carries a session, and the engine has no idea what a session is. A host that wants its signed-in visitors recognised on these endpoints too — so that clicking a download link on the site works the same way the client's download does — says how:

c.subject_resolver = ->(request) { request.env["warden"]&.user }

Without one, a request with no bearer token is anonymous, which is what the read-only API always did. A resolver that raises is logged and treated as anonymous rather than taking the request down with it.

The token is a WarpEngine::ApplicationToken with the catalog scope, sent as Authorization: Bearer …. DELETE /api/auth/token revokes it (signing out), and the admin lists both kinds of token and the sign-ins behind them.

Publish events

Publishing a release emits an ActiveSupport::Notifications event, so a host can react to a new build without hanging a callback on the models:

ActiveSupport::Notifications.subscribe("warp_engine.publish") do |*, payload|
  payload[:software]  # WarpEngine::Software
  payload[:release]   # WarpEngine::Release
  payload[:platform]  # "godot"
  payload[:name]      # "mygame"
  payload[:version]   # "1.2.0"
end

Hosts that must support older engine versions can feature-detect with WarpEngine.respond_to?(:instruments_publish?) && WarpEngine.instruments_publish?.

Downloads emit one too — warp_engine.download, with path, asset, release, software, subject and the Download record — so a host can keep its own account of who fetched what without reaching into DownloadService.

Public API

Endpoint Purpose
GET /api/service What this deployment is: version, whether the catalog gates, and how to sign in (or that you cannot)
GET /api/software Full catalog with releases, assets, links, download counts and an access block; ?owner_id= filters to one publisher
GET /api/software/highlighted The currently highlighted title
GET /api/builds Expected asset kinds per platform (build matrix)
GET /api/softwares/:name/builds Actual vs. missing build assets per release
GET /api/image/:id Serves images — the host's endpoint, see Images
GET /api/download?path= Serves an artifact and logs a download record
GET /file/*path Serves static build output (web-playable games, docs)
POST /api/auth/device Starts a device sign-in; returns the code pair (404 without a client identity)
POST /api/auth/device/token Polls a device sign-in for its token
DELETE /api/auth/token Revokes the bearer token on the request (signing out)

Every read endpoint accepts an optional Authorization: Bearer …; none requires one. What it changes is what the access policy is asked about — an anonymous caller is a normal, supported caller.

WarpEngine-Version

Every response above carries the engine's version in a WarpEngine-Version header, so a client can branch on the engine's age without a round trip to ask:

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

Set before the action runs rather than after, which means an error response carries it too — a client needs the version most when something came back wrong. The name is WarpEngine::VERSION_HEADER, so nothing spells it out twice.

Admin integration

The host owns the single ActiveAdmin instance — authentication (Devise), theme, assets and the /admin routes. WarpEngine only appends its resource files to ActiveAdmin.application.load_paths. Two things to copy into a new host:

  • the small file-picker JS for release-asset path inputs (an iframe pointing at /admin/files?picker=1&field=<dom_id>) in your active_admin.js;
  • if you generate apipie docs, add "#{WarpEngine::Engine.root}/app/controllers/**/*.rb" to your api_controllers_matcher.

Behavioral notes

  • Every model is soft-deleted (default_scope { where(deleted_at: nil) }).
  • The JSON shape is stable and intentionally bug-compatible with the project's former Go backend (Go zero-time timestamps, camelCase keys, legacy flat path fields).
  • Model extension points: ActiveSupport.on_load(:warp_engine_<model>) hooks.
  • A software has one pipeline, and the newest assignment wins. Software#pipeline is a has_one, so two pipelines pointing at the same software is not an error the database catches — it is a link that silently does nothing, with the software still showing whichever row came first. Assigning a software that another pipeline holds therefore moves it: the previous holder is left without one, the admin says which one it took it from, and Pipeline#software_taken_from carries that list for anything else that cares. Deliberately a callback rather than a unique index: rows here are soft-deleted, and a unique index counts deleted rows, so a pipeline removed last year would block its software from ever being linked again.

Tests

The engine ships an RSpec suite running against a bundled dummy app:

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

Development

This repository is a read-only split mirror — development happens in the tools/teletypegames monorepo under libs/ruby/warp_engine, and CI republishes the mirror on every change. Please do not open pull requests against the mirror.

S
Description
No description provided
Readme
478 KiB
Languages
Ruby 81.5%
HTML 18.5%