Files
mr.zero 9ffbc7a2ca
ci/woodpecker/push/deploy Pipeline was successful
wiki multilang support
2026-08-28 20:52:49 +02:00

10 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 /.

Languages

The site is bilingual (English and Hungarian) and so is the wiki behind it. Locales (lib/locales.rb) is the one list of supported codes; anything else falls back to en.

The wiki serves English unprefixed and Hungarian under /hu, so Wiki::Pages builds the language into the request path rather than into a query parameter:

GET /api/wiki/pages?tag=howto&lang=hu   ->   https://wiki.teletypegames.org/hu/custom/pages.json?tag=howto
GET /api/wiki/pages?tag=howto           ->   https://wiki.teletypegames.org/custom/pages.json?tag=howto

Each page in the response carries a locale saying which language its body is actually written in. That differs from the requested lang for the wiki pages that exist only in Hungarian: they are served as-is rather than 404ing, and a client can label them.

The RSS feeds take the same lang parameter (/api/rss/blog?lang=hu), which sets the channel language and picks the wiki language and the feed's own strings from config/locales/.

The frontend passes the interface language on every wiki call and rebuilds its outbound wiki links with wikiUrl(), so switching EN/HU re-reads the catalog and points the links at the matching wiki pages.

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): rubocoptest-host (host suite + a production-mode zeitwerk:check) → pullrestart.

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.

Gem cache

Both workflows install gems into a host directory that survives runs:

Workflow Host path BUNDLE_PATH
warp_engine.yaml /srv/ci-cache/bundle/warp_engine-ruby3.2 /cache/bundle
deploy.yaml /srv/ci-cache/bundle/api-ruby3.3 /cache/bundle

Cold install is ~35 s, warm ~6 s (113 MB of gems). The directory is keyed by Ruby version because native extensions (mysql2) are built against one ABI; the flock around bundle install keeps two concurrent pipelines from writing the same directory at once. Docker creates the directories on first run — to drop the cache, delete them.

This needs the same Trusted → Volumes flag the deploy does.

Why a MySQL service and not a stub

Two thirds of the engine suite (22 of 36 spec files, all 11 request specs) create rows and assert on what comes back, and both schema.rb files are MySQL-shaped (charset: utf8mb4, collation: utf8mb4_0900_ai_ci, unsigned keys). A null adapter answers every query with nothing, and SQLite would test a database we do not run. The service container costs a start, not a download — the agent's Docker daemon already has the image.

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