7.6 KiB
Teletype Games
Monorepo for the Teletype Games portal: a Vue 3 frontend, a Rails 8 API host app, and the reusable WarpEngine software-catalog engine.
Project structure
apps/
frontend/ # Vue 3 + Vite + TypeScript + Tailwind SPA
api/ # Rails 8 host app: TTG-specific API + ActiveAdmin shell
libs/
ruby/warp_engine/ # WarpEngine: mountable Rails engine (catalog, updater, admin resources)
The API is split in two layers:
- WarpEngine (
libs/ruby/warp_engine) owns the software catalog: models (softwares, releases, release assets, software-image links, platform links, download stats), the CI-callable/build/*publishing endpoints, the public read-only JSON API (/api/software*,/api/builds*,/api/download,/file/*) and the catalog ActiveAdmin resources. See its README. - The host app (
apps/api) owns everything TTG-specific: members, events, the image library (Image,/api/image/:id, the admin page — the engine only links to it through an adapter), the store registry, wiki proxy, RSS feeds, Devise/ActiveAdmin authentication, theming and assets. It consumes WarpEngine as a path gem and mounts it at/.
The store registry
GET /api/stores lists the stores a client can install from. The desktop
graphical client reads it on first run, which is why it is public: a client has
nobody to log in as.
[
{ "name": "Teletype Games", "catalogUrl": "https://teletypegames.org", "storeRepositoryUrl": null }
]
A Store row has two required fields — name and catalog_url — plus an
optional store_repository_url, maintained from ActiveAdmin ▸ 🛒 Stores;
db/seeds.rb creates our own.
A store does not need a repository. The store engine's own defaults already
cover the host-to-asset mapping, the install modes, the platforms and the
behaviour; what they cannot know is identity — a slug, a name and a catalog — and
that is what this row carries. With no repository the client derives the slug from
the catalog host, writes a small config and installs. Given one, it reads that
repository's config.json and points it at catalog_url, and that file remains the
authority on how the store behaves; a repository without a config file is treated
as no repository at all.
Every WarpEngine API response also carries a WarpEngine-Version header — the registry
above is the host's own endpoint and does not, because it is not part of the engine.
This lives in the host app on purpose, not in WarpEngine. The engine serves one catalog and has no business knowing which stores exist for it; who ships a store for a catalog is a property of the site.
| Model | apps/api/app/models/store.rb |
| Endpoint | apps/api/app/controllers/api/stores_controller.rb |
| Admin | apps/api/app/admin/stores.rb |
| Client | warp-engine-client |
Development environment
docker compose up -d
| Service | URL |
|---|---|
| Frontend | http://${WEBAPP_DOMAIN} |
| API | http://${WEBAPP_DOMAIN}/api |
| Admin | http://${WEBAPP_DOMAIN}/admin |
| API docs | http://${WEBAPP_DOMAIN}/api/swagger |
The api image is built from the repo root (so the libs/ path gems are visible
during bundle install) and mounts ./libs at runtime. After changing the
compose file or the Gemfile, rebuild with docker compose up -d --build api.
Testing
make api-test
Runs the host suite (apps/api, softwares_test DB), the WarpEngine suite
(dummy app, warp_engine_test DB) and a production-mode zeitwerk:check.
Individually:
docker exec -e RAILS_ENV=test api bundle exec rspec # host
docker exec -w /libs/ruby/warp_engine -e RAILS_ENV=test api bundle exec rspec # engine
JSON contract baselines for /api/software and /api/builds live in
apps/api/spec/snapshots/ — diff against them after refactors.
Linting
Backend (RuboCop)
docker compose run --rm --no-deps api bundle exec rubocop # host app
docker compose run --rm --no-deps api bundle exec rubocop -A # host app, autofix
docker exec -w /libs/ruby/warp_engine api \
env BUNDLE_GEMFILE=/app/Gemfile bundle exec rubocop # engine
Config: apps/api/.rubocop.yml and libs/ruby/warp_engine/.rubocop.yml
(both rubocop-rails-omakase). Both are green, and CI keeps them that way.
Frontend (ESLint)
docker compose run --rm --no-deps frontend npm run lint # check
docker compose run --rm --no-deps frontend npm run lint:fix # autofix
Config: apps/frontend/eslint.config.js (ESLint 9 flat config, Vue + TypeScript)
Pipelines
Woodpecker reads every file in .woodpecker/ as its own workflow, each with its
own trigger.
.woodpecker/warp_engine.yaml — the gem
Runs when a push to master touches libs/ruby/warp_engine/**, on a manual run,
and on a warp_engine-v* tag:
| Step | What it guards |
|---|---|
version-bumped |
the engine changed but WarpEngine::VERSION did not — fails first, before anything else runs |
tag-matches-version |
tag events only: warp_engine-v0.9.1 must find VERSION = "0.9.1" |
rubocop |
libs/ruby/warp_engine against its own .rubocop.yml |
test-engine |
the engine suite (dummy app, warp_engine_test DB) |
split-mirror |
pushes the subtree split to engines/warp_engine (master pushes only) |
publish-gem |
gem push to the Forgejo registry (tags only) |
Nothing is mirrored or published until the version check, RuboCop and the suite have all passed. The mirror repository deliberately has no CI of its own: the subtree split overwrites it on every run.
.woodpecker/deploy.yaml — the deploy
Runs on every push to master (and manually): rubocop → test-host (host
suite + a production-mode zeitwerk:check) → pull → restart.
The pull and restart steps reach the server through the host Docker socket
rather than SSH — the woodpecker-agent runs in the same stack as api and
frontend. Two things this depends on:
- The stack directory is bind-mounted at the same path it has on the host
(
/srv/stacks/teletype-games), so the compose file's relative bind mounts (./data,./apps) still resolve where they did. - Woodpecker only allows step volumes on a trusted repository: enable Trusted → Volumes in the repo settings (admin only), or the deploy steps are rejected.
pull fails if the server checkout is not on master (a detached HEAD would
make the pull look successful while the running code never moves). restart
installs dependencies, runs db:migrate and only then restarts — and it
touches only api and frontend: restarting woodpecker-server would cut off
the very pipeline doing the deploy.
db:migrate in the host app covers the engine too — WarpEngine appends its own
db/migrate to the host's migration paths. A failing migration stops the
deploy before the restart, so the old code keeps running.
Secret used by both workflows: forge_token — a Forgejo token with
repository read/write and package:write.
Version bump hook
The engine's version rule is enforced twice, by the same script:
git config core.hooksPath .githooks # once per clone
.githooks/pre-commit runs script/warp_engine_version_check.rb --staged: a
commit that touches libs/ruby/warp_engine/** must also raise
WarpEngine::VERSION above the one in HEAD. The pipeline runs the same script
in --range mode over the pushed commits, so nothing slips through a
--no-verify.
Escape hatch when a bump genuinely does not belong:
SKIP_WARP_ENGINE_VERSION_CHECK=1 git commit ...