Author SHA1 Message Date
mr.zeroandClaude Opus 5 71a5c15b4c Serve the builder images from the build org
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
The org reorganization moved the toolchain repos to build/ but left
their container images in internal/: a package namespace does not
travel with the repo and gets no redirect, which is the only reason
the internal org was still alive.

All eight images now live under build/ — the six unchanged ones copied
layer-for-layer, the Ebitengine and Bevy ones rebuilt for the ARM
target. The internal org can be emptied.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 20:57:37 +02:00
mr.zeroandClaude Opus 5 e8c3e2f792 Add linux_arm64 builds for Ebitengine and Bevy
Batocera and the ES-family distributions run on ARM as much as on
x86_64 — Raspberry Pi, Odroid, the retro handhelds — and a linux_x64
binary installs there but will not start. There was no Linux ARM asset
kind at all: KINDS had linux_x86 and linux_x64 and mac_arm64, but
nothing for 64-bit ARM Linux.

Registering the kind is deliberately separate from producing it: a
platform service only includes BuildLinuxArm64 once its pipeline builds
the artifact, otherwise /api/builds would report it missing for every
release. Hence Ebitengine and Bevy only. Godot needs a Linux arm64
export preset in each game repo first; LÖVE fuses an upstream AppImage
that ships x86_64 only; TIC-80's export command has no ARM target.

Ebitengine needs cgo on Linux, so binary_build gained a cross-compiler
argument — and unsets CC for native targets, otherwise a build after
the ARM one silently picks up the cross gcc.

Verified by cross-compiling both demos in the rebuilt images: each
produced a genuine AArch64 ELF (e_machine 183), not a silent fallback.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 20:57:23 +02:00
mr.zero 87e23545d1 phaser pipeline fix
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-16 16:45:16 +02:00
mr.zeroandClaude Opus 5 2b62070557 Give gem push a named credentials key
ci/woodpecker/tag/woodpecker Pipeline was successful
The push prompted for credentials and then died with "404 page not
found". Both are the same fault: RubyGems normalizes the keys in
~/.gem/credentials — dots become __ and a trailing slash is appended —
so a key written as the host URL can never match the --host value.
Finding no key, gem push falls back to signing in against the RubyGems
sign_in endpoint, which Gitea does not implement; that is the 404.

A named key is stored verbatim and is matched by --key, so the lookup
succeeds. Verified against Gem::ConfigFile with both formats.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 11:11:17 +02:00
mr.zeroandClaude Opus 5 d11610b6c7 Point the release pipeline at the engines org
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline failed
The publishing pipeline was already complete — subtree split to the
mirror, gem build and push on a warp_engine-v* tag — but it had never
been triggered, and after the org reorganization three of its targets
were stale: the mirror push URL and the rubygems registry namespace
(twice).

The gemspec's allowed_push_host has to match the --host that `gem push`
receives, so it now carries the full registry URL rather than the bare
forge host, plus source and documentation links.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 10:39:40 +02:00
mr.zeroandClaude Opus 5 9cbb909f08 Point the batocera page at ttg-batocera-store
The client was split into a reusable engine and a store definition, so
the page is now about our store: new repo and wiki links, the CLI path
the installer actually writes, and a closing section pointing at
warp-engine-batocera-store for anyone who wants a store of their own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 21:33:27 +02:00
mr.zero 2d750a4ce7 remove matrix link from batocera page 2026-08-11 00:01:14 +02:00
mr.zero 0a7e2d14e2 batocera store 2026-08-10 23:58:27 +02:00
mr.zero 763a092640 warp_engine: load ActiveJob itself so production eager load works
ci/woodpecker/push/woodpecker Pipeline was successful
The engine ships an ActiveJob based job (PipelineSyncJob), but a host's
application.rb does not necessarily require active_job/railtie - apps/api does
not. With eager loading off (development, test) nothing noticed; in production
WarpEngine::ApplicationJob blew up with "uninitialized constant
WarpEngine::ActiveJob", which is exactly what `rails zeitwerk:check` in
RAILS_ENV=production reported. An engine that ships jobs has to pull in the
framework it needs, so lib/warp_engine.rb requires the railtie.

Pre-existing on 0.1.0 as well; found while verifying that the 0.2.0 changes do
not break the portal. All three api-test steps are green now.

apps/api Gemfile.lock follows the 0.1.0 -> 0.2.0 path gem bump.
2026-08-10 11:30:06 +02:00
mr.zero ec43d2ae46 warp_engine 0.2.0: pluggable storage adapter and publish notifications
ci/woodpecker/push/woodpecker Pipeline was successful
Two seams the hosts needed, both backward compatible.

Storage: artifacts are served through WarpEngine::Storage.adapter instead of
raw filesystem calls. The default :local adapter keeps the previous behaviour
byte for byte, including the path traversal guard. A host can now set
config.storage_adapter to any object answering file?/directory?/locate and
serve builds from an object store - FileService and /api/download both honour
a Location.redirect, so a signing adapter turns them into redirects.
DownloadService#create still returns an absolute path (nil when missing) for
existing callers; #locate is the new entry point that can also return a
redirect. Ingestion (upload, extraction, file manager) stays local for now.

Publish: PublishService emits ActiveSupport::Notifications
("warp_engine.publish") with platform/name/version/software/release, so hosts
can react to a new build without hanging callbacks on the models.
WarpEngine.instruments_publish? lets a host feature-detect and keep its
fallback for older engine versions.
2026-08-10 11:16:17 +02:00
mr.zero afd1fe50c4 package upgrades 2026-08-09 17:57:08 +02:00
mr.zero 254d339656 warp_engine: CiRepository -> Pipeline rename everywhere, ci_ service prefixes dropped, unknown platform allowed
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-06 19:46:06 +02:00
mr.zero 9b89550766 warp_engine admin: CI Dashboard removed, Pipelines page with details sidebar, badge and Created fixes
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-06 19:32:50 +02:00
mr.zero 712fdbc97b fixes
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-06 19:23:04 +02:00
mr.zero 23bbccd2b1 engines digest: require What You Get as first heading 2026-08-06 19:03:16 +02:00
mr.zero d82bb6f468 EnginesIndexPage digests 2026-08-06 18:19:40 +02:00
mr.zero a0ced02345 readme: woodpecker ci management
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-06 16:31:28 +02:00
mr.zero 546201c886 WarpEngine dropdown emoji
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-06 16:23:52 +02:00
mr.zero c853cfadbc WarpEngine dropdown 2026-08-06 16:22:17 +02:00
Zsolt Tasnadi b1139a43bc wp admin to compose 2026-08-06 16:05:07 +02:00
mr.zero bae6fc06a6 pipeline fix 2026-08-06 16:04:50 +02:00
mr.zero 731b267aa1 wp config fix 2 2026-08-06 15:05:55 +02:00
mr.zero 508e869080 wp config fix 2026-08-06 14:08:18 +02:00
mr.zero 435d22b71d wp config 2026-08-06 14:04:42 +02:00
Zsolt Tasnadi f499b7f2af schema update 2026-08-06 14:00:13 +02:00
mr.zero 267a13b600 woodpecker integration 2026-08-06 13:58:58 +02:00
mr.zeroandClaude Opus 4.6 b530dcd50d Update README template path to match platform directory structure
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-08-06 10:34:06 +02:00
mr.zero 068c4db3f8 platform files refact
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-06 10:00:34 +02:00
mr.zero 565c086a0d Pin the pipeline update server URL: base_url is http behind the host nginx 2026-08-06 08:58:18 +02:00
mr.zero dd69dd79ab Register the config extension endpoint globally on the Woodpecker server 2026-08-06 08:26:10 +02:00
mr.zero fd0b64850f Serve the tic80 pipeline on the existing tic80pro image
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-06 08:16:47 +02:00
mr.zero 61ad1a87f2 godot builder image version update 2026-08-06 07:59:07 +02:00
mr.zero 7c9c8ff510 Verify RFC 9421 signatures on the Woodpecker config endpoint
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-06 07:24:47 +02:00
mr.zero 4b32252d2b Translate code comments and admin strings to English
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-06 01:25:53 +02:00
mr.zero dc45f2eb35 Serve Woodpecker pipeline configs from the engine (/build/config) 2026-08-06 01:14:22 +02:00
mr.zero c067d303bb Remove the droparea: artifacts arrive over /build/upload
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-05 22:16:34 +02:00
mr.zero 0c981b4590 build endpoints
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-05 20:12:35 +02:00
mr.zero f8ff7c394c Drop the unused UPDATE_SECRET_SOURCE env plumbing 2026-08-05 19:01:44 +02:00
mr.zero 3ade17ce9a enable db secret mode 2026-08-05 18:55:32 +02:00
mr.zero d0ce26c0a3 Rename the update auth switch to application_token_source and drop its ENV default
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-05 18:53:04 +02:00
mr.zero b2c780b697 db mode for update secrets
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-05 18:46:49 +02:00
mr.zero a217bcc14e Add DB-backed application tokens for the update endpoint
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-05 18:30:20 +02:00
mr.zero b69eff7866 Drop the date and new badge from the engine cards
Engines are evergreen products, not dated posts — the meta row, its
styles, the unused isNew wiring and the new-badge translations go.
2026-08-05 07:47:28 +02:00
mr.zero 7737a59850 Compact the top navigation and translate Engines as Motorok
The nav has too many items for the old spacing: smaller type, tighter
gaps, icons only on xl+ screens, and the hamburger now takes over below
lg (the desktop row did not fit between md and lg anymore).

The Hungarian engine strings drop the hyphenated loanword forms:
Engine-ek -> Motorok, Saját Engine-jeink -> Saját motorjaink.
2026-08-05 07:44:06 +02:00
mr.zero 1501f7f0b6 Drop the engines RSS feed
The engines listing is a handful of curated pages, not a stream — no feed
needed. Removes the route, controller action, RssService#engines_feed and
the footer link. The WikiService#pages alias stays (blog/howtos feeds use
it).
2026-08-05 07:40:25 +02:00
mr.zero a15f0ce24b Point the engine Explore action at the git repository
The engine pages' repo metadata (new in the wiki pages API) drives the
Explore button and card title links, with the wiki page as fallback. The
never-deployed /engines/:slug detail page and its store/api plumbing are
gone, and the engines RSS feed links to the repos too.
2026-08-05 07:36:58 +02:00
mr.zero f2c07c83c5 Serve engine-tagged wiki pages on a new /engines section
Frontend: /engines index + /engines/:slug detail routes, nav menu item and
en/hu translations. Engine pages are few, so the index uses an emphasized
poster-style design (dark slate, emerald accents, numbered full-width cards
with content preview) instead of the blog/howtos layouts. Slugs are the last
wiki path segment, since engine pages live scattered in the wiki tree.

API: /api/rss/engines feed linking to the site's engine pages, and a
WikiService#pages alias for #index — RssService called the alias-less name,
so the blog and howtos feeds were raising NoMethodError.
2026-08-05 07:29:07 +02:00
mr.zero 2358fe22ab Fix Release date field casing in CatalogShowPage
release.UpdatedAt does not exist on the Release interface (or in the API
response) — the release dates rendered as invalid values and vue-tsc failed
the build.
2026-08-05 07:28:47 +02:00
mr.zero 6833ab2070 Add a runnable example compose stack for WarpEngine
ci/woodpecker/push/woodpecker Pipeline was successful
examples/compose boots everything the engine's workflow assumes: mysql, a
minimal Rails host consuming the engine as a path gem, an SSH drop area
sharing the softwares volume with the app, and — behind the ci profile —
gitea plus woodpecker (agent attached to the stack network so pipeline
steps reach droparea/app by service name).

host_app doubles as a reference for a brand-new host: Gemfile, the two
initializers, apipie + engine mounts in routes.rb; on first boot the
entrypoint runs the install generator and db:prepare.

The README gains a detailed bring-up walkthrough: quickstart, publishing
a release by hand over scp + /update (verified end to end from a clean
slate), and the full gitea/woodpecker OAuth wiring.
2026-08-05 06:56:12 +02:00
mr.zero b09610bc33 Rewrite WarpEngine README for standalone consumers
ci/woodpecker/push/woodpecker Pipeline was successful
The README is what the tools/warp_engine mirror shows: present the engine as
a standalone product installed from git or the gem registry, with the
monorepo workflow reduced to a short Development note.
2026-08-04 20:16:46 +02:00
mr.zeroandClaude Fable 5 211dcccbf3 Trim whitespace from the forge token before use
ci/woodpecker/manual/woodpecker Pipeline was successful
A trailing newline pasted into the Woodpecker secret broke the push URL
("credential url cannot be parsed"); strip all whitespace from the token in
both the mirror and the gem-publish steps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 20:07:13 +02:00
mr.zeroandClaude Fable 5 b1b619befa Allow manual pipeline runs for the split mirror
ci/woodpecker/manual/woodpecker Pipeline failed
Path-filtered push events hide the workflow for unrelated pushes and manual
restarts have no changed-files list; add event: manual so the mirror can be
triggered from the Woodpecker UI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 20:03:35 +02:00
mr.zeroandClaude Fable 5 af779b0988 Fix YAML parse error in the gem-publish step
The credentials printf line contains ': ' which YAML reads as a mapping;
use a literal block scalar for that command.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 19:57:15 +02:00
mr.zeroandClaude Fable 5 b4d0198d2a Add CI split-mirror pipeline for WarpEngine
- .woodpecker.yaml: on master pushes touching libs/ruby/warp_engine, split the
  subtree and force-push it to the read-only tools/warp_engine mirror; on
  warp_engine-v* tags, build and push the gem to the Forgejo rubygems registry
- engine README documents the monorepo-first workflow and the mirror

Requires a `forge_token` Woodpecker secret (repository:write + package:write).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 19:47:15 +02:00
152 changed files with 7411 additions and 1729 deletions
+62
View File
@@ -0,0 +1,62 @@
# Read-only split mirror: a libs/ruby/warp_engine alkönyvtárat kitükrözi a
# engines/warp_engine repóba (fejlesztés itt, a monorepóban történik; a tükör
# csak publikálásra való). Tag-elt release (warp_engine-v*) esetén a gem a
# Forgejo rubygems registry-be is felmegy.
#
# Szükséges Woodpecker secret: forge_token — Forgejo access token
# repository:write (engines/warp_engine) és package:write joggal.
when:
- event: push
branch: master
path: "libs/ruby/warp_engine/**"
- event: manual
- event: tag
ref: refs/tags/warp_engine-v*
clone:
git:
image: woodpeckerci/plugin-git
settings:
partial: false
depth: 0 # a subtree splithez teljes history kell
steps:
split-mirror:
image: alpine/git
environment:
FORGE_TOKEN:
from_secret: forge_token
commands:
- apk add --no-cache git-subtree
- git subtree split --prefix=libs/ruby/warp_engine HEAD -b warp-engine-split
# a secretbe másolt token végén lehet sortörés — levágjuk
- TOKEN="$$(printf '%s' "$${FORGE_TOKEN}" | tr -d '[:space:]')"
- git push --force "https://ci:$${TOKEN}@git.teletypegames.org/engines/warp_engine.git" warp-engine-split:master
when:
- event: push
branch: master
- event: manual
publish-gem:
image: ruby:3.3-slim
environment:
FORGE_TOKEN:
from_secret: forge_token
commands:
- cd libs/ruby/warp_engine
- gem build warp_engine.gemspec
- mkdir -p ~/.gem
- TOKEN="$$(printf '%s' "$${FORGE_TOKEN}" | tr -d '[:space:]')"
# Nevesített kulcs, nem a hoszt-URL: a RubyGems a credentials fájl
# kulcsait normalizálja (a pontokból __ lesz, záró perjelet kap), így egy
# URL-kulcs sosem egyezik a --host értékével. Ilyenkor a gem push
# bejelentkezni próbál a RubyGems sign_in végpontján, amit a Gitea nem
# ismer — ez adta a "404 page not found"-ot.
- |
printf -- '---\n:gitea: Bearer %s\n' "$${TOKEN}" > ~/.gem/credentials
- chmod 600 ~/.gem/credentials
- gem push --key gitea --host https://git.teletypegames.org/api/packages/engines/rubygems warp_engine-*.gem
when:
- event: tag
ref: refs/tags/warp_engine-v*
+1 -1
View File
@@ -17,7 +17,7 @@ The API is split in two layers:
- **WarpEngine** (`libs/ruby/warp_engine`) owns the software catalog: models
(softwares, releases, release assets, images, platform links, download stats),
the CI-callable `/update` endpoint, the public read-only JSON API
the CI-callable `/build/*` publishing endpoints, the public read-only JSON API
(`/api/software*`, `/api/builds*`, `/api/image`, `/api/download`, `/file/*`)
and the catalog ActiveAdmin resources. See its [README](libs/ruby/warp_engine/README.md).
- **The host app** (`apps/api`) owns everything TTG-specific: members, events,
+95 -94
View File
@@ -1,7 +1,7 @@
PATH
remote: ../libs/ruby/warp_engine
specs:
warp_engine (0.1.0)
warp_engine (0.3.0)
apipie-rails
blueprinter
rails (>= 8.0)
@@ -10,31 +10,31 @@ PATH
GEM
remote: https://rubygems.org/
specs:
action_text-trix (2.1.18)
action_text-trix (2.1.19)
railties
actioncable (8.1.3)
actionpack (= 8.1.3)
activesupport (= 8.1.3)
actioncable (8.1.3.1)
actionpack (= 8.1.3.1)
activesupport (= 8.1.3.1)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
actionmailbox (8.1.3)
actionpack (= 8.1.3)
activejob (= 8.1.3)
activerecord (= 8.1.3)
activestorage (= 8.1.3)
activesupport (= 8.1.3)
actionmailbox (8.1.3.1)
actionpack (= 8.1.3.1)
activejob (= 8.1.3.1)
activerecord (= 8.1.3.1)
activestorage (= 8.1.3.1)
activesupport (= 8.1.3.1)
mail (>= 2.8.0)
actionmailer (8.1.3)
actionpack (= 8.1.3)
actionview (= 8.1.3)
activejob (= 8.1.3)
activesupport (= 8.1.3)
actionmailer (8.1.3.1)
actionpack (= 8.1.3.1)
actionview (= 8.1.3.1)
activejob (= 8.1.3.1)
activesupport (= 8.1.3.1)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
actionpack (8.1.3)
actionview (= 8.1.3)
activesupport (= 8.1.3)
actionpack (8.1.3.1)
actionview (= 8.1.3.1)
activesupport (= 8.1.3.1)
nokogiri (>= 1.8.5)
rack (>= 2.2.4)
rack-session (>= 1.0.1)
@@ -42,21 +42,21 @@ GEM
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
actiontext (8.1.3)
actiontext (8.1.3.1)
action_text-trix (~> 2.1.15)
actionpack (= 8.1.3)
activerecord (= 8.1.3)
activestorage (= 8.1.3)
activesupport (= 8.1.3)
actionpack (= 8.1.3.1)
activerecord (= 8.1.3.1)
activestorage (= 8.1.3.1)
activesupport (= 8.1.3.1)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (8.1.3)
activesupport (= 8.1.3)
actionview (8.1.3.1)
activesupport (= 8.1.3.1)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
activeadmin (3.5.1)
activeadmin (3.5.2)
arbre (~> 1.2, >= 1.2.1)
csv
formtastic (>= 3.1)
@@ -68,22 +68,22 @@ GEM
ransack (>= 4.0)
activeadmin_blaze_theme (1.1.0)
activeadmin (>= 2.0, < 4)
activejob (8.1.3)
activesupport (= 8.1.3)
activejob (8.1.3.1)
activesupport (= 8.1.3.1)
globalid (>= 0.3.6)
activemodel (8.1.3)
activesupport (= 8.1.3)
activerecord (8.1.3)
activemodel (= 8.1.3)
activesupport (= 8.1.3)
activemodel (8.1.3.1)
activesupport (= 8.1.3.1)
activerecord (8.1.3.1)
activemodel (= 8.1.3.1)
activesupport (= 8.1.3.1)
timeout (>= 0.4.0)
activestorage (8.1.3)
actionpack (= 8.1.3)
activejob (= 8.1.3)
activerecord (= 8.1.3)
activesupport (= 8.1.3)
activestorage (8.1.3.1)
actionpack (= 8.1.3.1)
activejob (= 8.1.3.1)
activerecord (= 8.1.3.1)
activesupport (= 8.1.3.1)
marcel (~> 1.0)
activesupport (8.1.3)
activesupport (8.1.3.1)
base64
bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1)
@@ -107,18 +107,18 @@ GEM
bcrypt (3.1.22)
bigdecimal (4.1.2)
blueprinter (1.3.0)
bootsnap (1.24.3)
msgpack (~> 1.2)
bootsnap (1.25.0)
msgpack (~> 1.5)
builder (3.3.0)
concurrent-ruby (1.3.6)
concurrent-ruby (1.3.8)
connection_pool (3.0.2)
crass (1.0.6)
csv (3.3.5)
crass (1.0.7)
csv (3.3.6)
date (3.5.1)
debug (1.11.1)
irb (~> 1.10)
reline (>= 0.3.8)
devise (5.0.3)
devise (5.0.4)
bcrypt (~> 3.0)
orm_adapter (~> 0.1)
railties (>= 7.0)
@@ -126,7 +126,7 @@ GEM
warden (~> 1.2.3)
diff-lcs (1.6.2)
drb (2.2.3)
erb (6.0.4)
erb (6.0.7)
erubi (1.13.1)
factory_bot (6.6.0)
activesupport (>= 6.1.0)
@@ -138,19 +138,19 @@ GEM
formtastic (6.0.0)
actionpack (>= 7.2.0)
formtastic_i18n (0.7.0)
globalid (1.3.0)
globalid (1.4.0)
activesupport (>= 6.1)
has_scope (0.9.0)
actionpack (>= 7.0)
activesupport (>= 7.0)
i18n (1.14.8)
i18n (1.15.2)
concurrent-ruby (~> 1.0)
inherited_resources (1.14.0)
actionpack (>= 6.0)
has_scope (>= 0.6)
railties (>= 6.0)
responders (>= 2)
io-console (0.8.2)
io-console (0.9.1)
irb (1.18.0)
pp (>= 0.6.0)
prism (>= 1.3.0)
@@ -160,7 +160,7 @@ GEM
rails-dom-testing (>= 1, < 3)
railties (>= 4.2.0)
thor (>= 0.14, < 2.0)
json (2.19.5)
json (2.21.2)
kaminari (1.2.2)
activesupport (>= 4.1.0)
kaminari-actionview (= 1.2.2)
@@ -173,27 +173,27 @@ GEM
activerecord
kaminari-core (= 1.2.2)
kaminari-core (1.2.2)
language_server-protocol (3.17.0.5)
language_server-protocol (3.17.0.6)
lint_roller (1.1.0)
logger (1.7.0)
loofah (2.25.1)
loofah (2.25.2)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.9.0)
mail (2.9.1)
logger
mini_mime (>= 0.1.1)
net-imap
net-pop
net-smtp
marcel (1.1.0)
marcel (1.2.1)
mini_mime (1.1.5)
minitest (6.0.6)
drb (~> 2.0)
prism (~> 1.5)
msgpack (1.8.0)
msgpack (1.8.4)
mysql2 (0.5.7)
bigdecimal
net-imap (0.6.4)
net-imap (0.6.6)
date
net-protocol
net-pop (0.1.2)
@@ -203,23 +203,20 @@ GEM
net-smtp (0.5.1)
net-protocol
nio4r (2.7.5)
nokogiri (1.19.3-arm64-darwin)
nokogiri (1.19.4-arm64-darwin)
racc (~> 1.4)
nokogiri (1.19.3-x86_64-linux-gnu)
nokogiri (1.19.4-x86_64-linux-gnu)
racc (~> 1.4)
orm_adapter (0.5.0)
parallel (1.28.0)
parser (3.3.11.1)
parallel (2.1.0)
parser (3.3.12.0)
ast (~> 2.4.1)
racc
pp (0.6.3)
pp (0.6.4)
prettyprint
prettyprint (0.2.0)
prism (1.9.0)
psych (5.3.1)
date
stringio
puma (8.0.1)
puma (8.0.2)
nio4r (~> 2.0)
racc (1.8.1)
rack (3.2.6)
@@ -230,30 +227,30 @@ GEM
rack (>= 1.3)
rackup (2.3.1)
rack (>= 3)
rails (8.1.3)
actioncable (= 8.1.3)
actionmailbox (= 8.1.3)
actionmailer (= 8.1.3)
actionpack (= 8.1.3)
actiontext (= 8.1.3)
actionview (= 8.1.3)
activejob (= 8.1.3)
activemodel (= 8.1.3)
activerecord (= 8.1.3)
activestorage (= 8.1.3)
activesupport (= 8.1.3)
rails (8.1.3.1)
actioncable (= 8.1.3.1)
actionmailbox (= 8.1.3.1)
actionmailer (= 8.1.3.1)
actionpack (= 8.1.3.1)
actiontext (= 8.1.3.1)
actionview (= 8.1.3.1)
activejob (= 8.1.3.1)
activemodel (= 8.1.3.1)
activerecord (= 8.1.3.1)
activestorage (= 8.1.3.1)
activesupport (= 8.1.3.1)
bundler (>= 1.15.0)
railties (= 8.1.3)
railties (= 8.1.3.1)
rails-dom-testing (2.3.0)
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
rails-html-sanitizer (1.7.0)
loofah (~> 2.25)
rails-html-sanitizer (1.7.1)
loofah (~> 2.25, >= 2.25.2)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
railties (8.1.3)
actionpack (= 8.1.3)
activesupport (= 8.1.3)
railties (8.1.3.1)
actionpack (= 8.1.3.1)
activesupport (= 8.1.3.1)
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
@@ -266,12 +263,17 @@ GEM
activerecord (>= 7.2)
activesupport (>= 7.2)
i18n
rdoc (7.2.0)
rbs (4.1.2)
logger
prism (>= 1.6.0)
tsort
rdoc (8.0.0)
erb
psych (>= 4.0.0)
prism (>= 1.6.0)
rbs (>= 4.0.0)
tsort
regexp_parser (2.12.0)
reline (0.6.3)
reline (0.7.0)
io-console (~> 0.5)
responders (3.2.0)
actionpack (>= 7.0)
@@ -296,7 +298,7 @@ GEM
rspec-support (3.13.7)
rss (0.3.3)
rexml
rubocop (1.86.1)
rubocop (1.89.0)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
@@ -307,14 +309,14 @@ GEM
rubocop-ast (>= 1.49.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.49.1)
rubocop-ast (1.50.0)
parser (>= 3.3.7.2)
prism (~> 1.7)
rubocop-performance (1.26.1)
lint_roller (~> 1.1)
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.47.1, < 2.0)
rubocop-rails (2.34.3)
rubocop-rails (2.36.0)
activesupport (>= 4.2.0)
lint_roller (~> 1.1)
rack (>= 1.1)
@@ -346,9 +348,8 @@ GEM
actionpack (>= 6.1)
activesupport (>= 6.1)
sprockets (>= 3.0.0)
stringio (3.2.0)
thor (1.5.0)
tilt (2.7.0)
tilt (2.8.0)
timeout (0.6.1)
tsort (0.2.0)
tzinfo (2.0.6)
@@ -360,11 +361,11 @@ GEM
useragent (0.16.11)
warden (1.2.9)
rack (>= 2.0.9)
websocket-driver (0.8.0)
websocket-driver (0.8.2)
base64
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
zeitwerk (2.7.5)
zeitwerk (2.8.3)
PLATFORMS
arm64-darwin-22
@@ -4,7 +4,7 @@ class Api::WikiController < ApiController
end
api :GET, "/api/wiki/pages", "List wiki pages filtered by tag"
param :tag, String, required: false, desc: "Filter by tag (blog, howto)"
param :tag, String, required: false, desc: "Filter by tag (blog, howto, engine)"
param :limit, :number, required: false, desc: "Limit number of results"
param :body, String, required: false, desc: "Include body content (1 = yes)"
returns code: 200, desc: "Wiki pages response" do
@@ -20,6 +20,7 @@ class Api::WikiController < ApiController
property :locale, String, desc: "Locale code"
property :route, String, desc: "URL slug"
property :tags, Array, of: String, desc: "Tags"
property :repo, String, desc: "Git repository URL (from page metadata, engines)"
property :render, String, desc: "Rendered HTML content"
property :content, String, desc: "Raw markdown content"
end
+3
View File
@@ -43,4 +43,7 @@ class WikiService
rescue StandardError => e
{ "tag" => tag, "count" => 0, "pages" => [], "error" => e.message }
end
# Az RSS feedek ezen a néven hívják.
alias_method :pages, :index
end
+31 -2
View File
@@ -1,7 +1,36 @@
# WarpEngine host-konfiguráció. to_prepare: reload után is újrafut, ezért
# értékadás (nem <<), hogy idempotens legyen.
# WarpEngine host configuration. to_prepare: re-runs after reloads, hence
# assignment (not <<) to stay idempotent.
Rails.application.config.to_prepare do
WarpEngine.configure do |c|
# Owner of the /build/* DB tokens. Switching to :database mode
# (c.application_token_source = :database) must wait until CI uses DB
# tokens — the flip invalidates UPDATE_SECRET immediately.
c.application_token_source = :database
c.application_token_owner_class = "AdminUser"
# Woodpecker configuration extension (/build/config): the served platforms
# and their builder images. An image bump is one line here, rolled out to
# every repo by the deploy.
c.ci_platforms = {
"godot" => { builder: "git.teletypegames.org/build/godot-builder:4.7.1" },
"phaser" => { builder: "git.teletypegames.org/build/phaser-builder:latest" },
"love" => { builder: "git.teletypegames.org/build/love-builder:latest" },
"bevy" => { builder: "git.teletypegames.org/build/bevy-builder:latest" },
"c64" => { builder: "git.teletypegames.org/build/c64-builder:latest" },
"ebitengine" => { builder: "git.teletypegames.org/build/ebitengine-builder:latest" },
"tic80" => { builder: "git.teletypegames.org/build/tic80pro:latest" }
}
# Explicit URL: request.base_url would yield http:// behind the host nginx
# (no X-Forwarded-Proto reaches Rails), and the resulting 301 makes the
# pipeline's curl steps silently no-op.
c.ci_update_server = "https://teletypegames.org"
c.ci_extension_public_key_url = "https://ci.teletypegames.org/api/signature/public-key"
# Woodpecker CI management (repo sync, secret provisioning, pipeline control)
c.woodpecker_url = ENV["WOODPECKER_URL"] # e.g. "https://ci.teletypegames.org"
c.woodpecker_api_token = ENV["WOODPECKER_API_TOKEN"] # Woodpecker PAT with admin access
c.woodpecker_repo_owner = ENV["WOODPECKER_REPO_OWNER"] # forge org (e.g. "games")
c.image_owners = [
{
label: "member",
+1 -1
View File
@@ -14,7 +14,7 @@ Rails.application.routes.draw do
end
# Utolsó sor: a host route-jai nyernek, a katalógus-útvonalakat
# (/api/software*, /api/builds*, /api/image, /api/download, /update, /file/*)
# (/api/software*, /api/builds*, /api/image, /api/download, /build/*, /file/*)
# az engine adja.
mount WarpEngine::Engine => "/"
end
+41 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2026_08_04_000002) do
ActiveRecord::Schema[8.1].define(version: 2026_08_06_000002) do
create_table "admin_users", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "deleted_at", precision: 3
@@ -27,6 +27,24 @@ ActiveRecord::Schema[8.1].define(version: 2026_08_04_000002) do
t.index ["reset_password_token"], name: "index_admin_users_on_reset_password_token", unique: true
end
create_table "application_tokens", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.datetime "created_at", precision: 3
t.datetime "deleted_at", precision: 3
t.datetime "expires_at", precision: 3
t.datetime "last_used_at", precision: 3
t.string "name", limit: 128, null: false
t.bigint "owner_id", null: false, unsigned: true
t.string "owner_type", limit: 128, null: false
t.json "scopes"
t.string "token_digest", limit: 64, null: false
t.string "token_prefix", limit: 12, null: false
t.boolean "unrestricted", default: false, null: false
t.datetime "updated_at", precision: 3
t.index ["deleted_at"], name: "idx_application_tokens_deleted_at"
t.index ["owner_type", "owner_id"], name: "idx_application_tokens_owner"
t.index ["token_digest"], name: "idx_application_tokens_token_digest", unique: true
end
create_table "downloads", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.datetime "created_at", precision: 3
t.datetime "deleted_at", precision: 3
@@ -178,6 +196,24 @@ ActiveRecord::Schema[8.1].define(version: 2026_08_04_000002) do
t.index ["image_id"], name: "index_members_on_image_id"
end
create_table "pipelines", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.boolean "active", default: true, null: false
t.datetime "created_at", precision: 3
t.datetime "deleted_at", precision: 3
t.datetime "last_pipeline_at", precision: 3
t.string "last_pipeline_status", limit: 32
t.string "platform", limit: 32, null: false
t.string "repo_name", limit: 128, null: false
t.string "repo_owner", limit: 128, null: false
t.bigint "software_id", unsigned: true
t.datetime "updated_at", precision: 3
t.bigint "woodpecker_repo_id", null: false, unsigned: true
t.index ["deleted_at"], name: "idx_pipelines_deleted"
t.index ["repo_owner", "repo_name"], name: "idx_pipelines_owner_name", unique: true
t.index ["software_id"], name: "idx_pipelines_software"
t.index ["woodpecker_repo_id"], name: "idx_pipelines_wp_id", unique: true
end
create_table "platform_links", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.datetime "created_at", precision: 3
t.datetime "deleted_at", precision: 3
@@ -232,6 +268,8 @@ ActiveRecord::Schema[8.1].define(version: 2026_08_04_000002) do
t.boolean "highlighted", default: false
t.string "license", limit: 128
t.string "name", limit: 128
t.bigint "owner_id", unsigned: true
t.string "owner_type", limit: 128
t.string "platform", limit: 128
t.string "site"
t.string "status", limit: 20, default: "development"
@@ -240,12 +278,14 @@ ActiveRecord::Schema[8.1].define(version: 2026_08_04_000002) do
t.datetime "updated_at", precision: 3
t.index ["deleted_at"], name: "idx_softwares_deleted_at"
t.index ["name"], name: "idx_softwares_name", unique: true
t.index ["owner_type", "owner_id"], name: "idx_softwares_owner"
end
add_foreign_key "admin_users", "members"
add_foreign_key "downloads", "releases", name: "fk_downloads_release", on_delete: :nullify
add_foreign_key "external_links", "softwares", name: "fk_softwares_external_links", on_delete: :cascade
add_foreign_key "members", "images"
add_foreign_key "pipelines", "softwares", name: "fk_pipelines_software", on_delete: :nullify
add_foreign_key "release_assets", "releases", name: "fk_releases_release_assets", on_delete: :cascade
add_foreign_key "releases", "softwares", name: "fk_softwares_releases", on_delete: :cascade
add_foreign_key "software_images", "images"
+1576 -1190
View File
File diff suppressed because it is too large Load Diff
+17 -1
View File
@@ -15,6 +15,7 @@ interface RawWikiPage {
updatedAt: string
createdAt: string
locale: string
repo?: string | null
}
async function fetchPages(
@@ -67,6 +68,21 @@ const getBlogPage = async (slug: string): Promise<WikiPageContent | null> => {
}
}
const listEnginePages = async (): Promise<WikiPageWithContent[]> => {
const pages = await fetchPages('engine', { body: true })
return pages.map((p): WikiPageWithContent => ({
id: p.id,
path: p.path,
title: p.title || p.path,
description: p.description ?? '',
content: p.content ?? '',
updatedAt: p.updatedAt,
createdAt: p.createdAt,
locale: p.locale,
repo: p.repo ?? null,
}))
}
const listHowtoPages = async (): Promise<WikiPage[]> => {
const pages = await fetchPages('howto', { limit: 30 })
return pages.map((p): WikiPage => ({
@@ -81,4 +97,4 @@ const listHowtoPages = async (): Promise<WikiPage[]> => {
}
export { WIKI_BASE }
export default { listBlogPages, getBlogPage, listHowtoPages }
export default { listBlogPages, getBlogPage, listHowtoPages, listEnginePages }
+31
View File
@@ -4,6 +4,7 @@ export default {
catalog: 'Catalog',
blog: 'Blog',
howtos: 'How-tos',
engines: 'Engines',
code: 'Code',
team: 'Team',
contact: 'Contact us',
@@ -159,6 +160,7 @@ export default {
win_x64: 'Windows (64-bit)',
linux_x86: 'Linux (32-bit)',
linux_x64: 'Linux (64-bit)',
linux_arm64: 'Linux (ARM 64-bit)',
mac_x64: 'macOS (Intel)',
mac_arm64: 'macOS (Apple Silicon)',
mac_universal: 'macOS',
@@ -168,6 +170,17 @@ export default {
title: 'Our Team',
subtitle: 'Meet the brilliant minds behind Teletype Games.',
},
engines: {
badge: 'In-house Tech',
titleLead: 'Our',
titleAccent: 'Engines',
subtitle: 'The engines and frameworks we build, maintain and ship our games and services on.',
errorTitle: 'Failed to connect to Wiki',
noPagesTitle: 'No engines found',
noPagesDesc: "It seems like there aren't any engine pages available on the wiki at the moment.",
explore: 'Explore',
openWiki: 'Open in Wiki',
},
howtos: {
badge: 'Knowledge Base',
title: 'Tech HowTo Center',
@@ -185,6 +198,24 @@ export default {
title: 'Build Matrix',
subtitle: 'Which engines build for which platforms.',
},
batocera: {
title: 'Batocera Store',
subtitle: 'Our catalog, straight on your retro box.',
lead: 'ttg-batocera-store puts our catalog on Batocera, the plug-and-play retro gaming distribution. It pulls our games into the box\'s ROM folders together with box art and EmulationStation metadata, and can be re-run any time from the Ports menu to pick up new releases. Python 3 standard library only — nothing to install alongside it.',
repo: 'Git repository',
docs: 'Documentation',
batoceraProject: 'Batocera project',
installTitle: 'Install',
installDesc: 'Run this over SSH on the Batocera box. It installs the client, writes the config, creates the Ports entry and runs the first sync.',
installNote: 'Then restart EmulationStation so the games show up:',
useTitle: 'Use it',
useDesc: 'On the device: Ports ▸ "Teletype Games Store". Over SSH the client has a small CLI:',
platformsTitle: 'What it installs',
platformsDesc: 'A catalog platform is installable when its release asset boots directly on a Batocera system. Other platforms ship web and desktop builds instead, so they are not mapped.',
engineTitle: 'Run your own store',
engineDesc: 'Nothing here is specific to us. Our store is just a config file on top of warp-engine-batocera-store, an open engine that works with any WarpEngine-based site: point it at your own catalog, give it a name and a ROM subfolder of its own, and your games land on a Batocera box the same way. Several stores can live side by side on one machine without touching each other\'s games.',
copy: 'Copy',
},
code: {
title: 'Codebase',
subtitle: 'Explore our collection of open-source projects, study our source code, and contribute to our independent game development tools.',
+31
View File
@@ -4,6 +4,7 @@ export default {
catalog: 'Katalógus',
blog: 'Blog',
howtos: 'Hogyan csináld',
engines: 'Motorok',
code: 'Kód',
team: 'Csapat',
contact: 'Kapcsolat',
@@ -159,6 +160,7 @@ export default {
win_x64: 'Windows (64 bit)',
linux_x86: 'Linux (32 bit)',
linux_x64: 'Linux (64 bit)',
linux_arm64: 'Linux (ARM 64 bit)',
mac_x64: 'macOS (Intel)',
mac_arm64: 'macOS (Apple Silicon)',
mac_universal: 'macOS',
@@ -168,6 +170,17 @@ export default {
title: 'Csapatunk',
subtitle: 'Ismerd meg a Teletype Games mögött álló zseniális elméket.',
},
engines: {
badge: 'Saját technológia',
titleLead: 'Saját',
titleAccent: 'motorjaink',
subtitle: 'Az általunk épített és karbantartott motorok és keretrendszerek, amelyekre a játékaink és szolgáltatásaink épülnek.',
errorTitle: 'Nem sikerült csatlakozni a Wikihez',
noPagesTitle: 'Nem találhatók motorok',
noPagesDesc: 'Úgy tűnik, jelenleg nincsenek motoroldalak a wikin.',
explore: 'Felfedezés',
openWiki: 'Megnyitás a Wikiben',
},
howtos: {
badge: 'Tudásbázis',
title: 'Tech HowTo Központ',
@@ -185,6 +198,24 @@ export default {
title: 'Build mátrix',
subtitle: 'Melyik engine melyik platformra fordít.',
},
batocera: {
title: 'Batocera Store',
subtitle: 'A katalógusunk közvetlenül a retró gépeden.',
lead: 'A ttg-batocera-store a katalógusunkat teszi fel a Batocerára, a plug-and-play retró gamer disztribúcióra. A játékainkat a gép ROM mappáiba tölti le, borítóképpel és EmulationStation metaadatokkal együtt, és a Ports menüből bármikor újrafuttatható az új kiadásokért. Csak a Python 3 alapkönyvtárát használja — nem kell mellé semmit telepíteni.',
repo: 'Git tároló',
docs: 'Dokumentáció',
batoceraProject: 'Batocera projekt',
installTitle: 'Telepítés',
installDesc: 'Futtasd ezt SSH-n a Batocera gépen. Telepíti a klienst, kiírja a konfigurációt, létrehozza a Ports bejegyzést és lefuttatja az első szinkront.',
installNote: 'Utána indítsd újra az EmulationStationt, hogy megjelenjenek a játékok:',
useTitle: 'Használat',
useDesc: 'A gépen: Ports ▸ „Teletype Games Store”. SSH-n keresztül egy egyszerű CLI áll rendelkezésre:',
platformsTitle: 'Mit telepít',
platformsDesc: 'Egy katalógus-platform akkor telepíthető, ha a kiadás fájlja közvetlenül elindul egy Batocera rendszeren. A többi platform webes és asztali buildeket ad, ezért nincsenek leképezve.',
engineTitle: 'Csinálj saját store-t',
engineDesc: 'Itt semmi sem ránk van szabva. A mi store-unk csak egy konfigurációs fájl a warp-engine-batocera-store fölött, ami bármilyen WarpEngine alapú oldallal működik: állítsd a saját katalógusodra, adj neki nevet és saját ROM almappát, és a játékaid ugyanígy kerülnek fel egy Batocera gépre. Egy gépen több store is megfér egymás mellett anélkül, hogy egymás játékaihoz nyúlnának.',
copy: 'Másolás',
},
code: {
title: 'Kódbázis',
subtitle: 'Fedezd fel nyílt forráskódú projektek gyűjteményét, tanulmányozd forráskódunkat, és járulj hozzá független játékfejlesztő eszközeinkhez.',
+12 -5
View File
@@ -9,6 +9,7 @@
<RouterLink to="/catalog" class="nav-link"><i class="fa-solid fa-gamepad nav-icon"></i>{{ t('nav.catalog') }}</RouterLink>
<RouterLink to="/blog" class="nav-link"><i class="fa-solid fa-newspaper nav-icon"></i>{{ t('nav.blog') }}</RouterLink>
<RouterLink to="/howtos" class="nav-link"><i class="fa-solid fa-lightbulb nav-icon"></i>{{ t('nav.howtos') }}</RouterLink>
<RouterLink to="/engines" class="nav-link"><i class="fa-solid fa-cubes nav-icon"></i>{{ t('nav.engines') }}</RouterLink>
<RouterLink to="/code" class="nav-link"><i class="fa-solid fa-code nav-icon"></i>{{ t('nav.code') }}</RouterLink>
<RouterLink to="/team" class="nav-link"><i class="fa-solid fa-users nav-icon"></i>{{ t('nav.team') }}</RouterLink>
<RouterLink to="/contact" class="nav-link"><i class="fa-solid fa-envelope nav-icon"></i>{{ t('nav.contact') }}</RouterLink>
@@ -33,6 +34,7 @@
<RouterLink to="/catalog" class="nav-mobile-link"><i class="fa-solid fa-gamepad nav-icon"></i>{{ t('nav.catalog') }}</RouterLink>
<RouterLink to="/blog" class="nav-mobile-link"><i class="fa-solid fa-newspaper nav-icon"></i>{{ t('nav.blog') }}</RouterLink>
<RouterLink to="/howtos" class="nav-mobile-link"><i class="fa-solid fa-lightbulb nav-icon"></i>{{ t('nav.howtos') }}</RouterLink>
<RouterLink to="/engines" class="nav-mobile-link"><i class="fa-solid fa-cubes nav-icon"></i>{{ t('nav.engines') }}</RouterLink>
<RouterLink to="/code" class="nav-mobile-link"><i class="fa-solid fa-code nav-icon"></i>{{ t('nav.code') }}</RouterLink>
<RouterLink to="/team" class="nav-mobile-link"><i class="fa-solid fa-users nav-icon"></i>{{ t('nav.team') }}</RouterLink>
<RouterLink to="/contact" class="nav-mobile-link"><i class="fa-solid fa-envelope nav-icon"></i>{{ t('nav.contact') }}</RouterLink>
@@ -114,13 +116,13 @@ function switchLocale(lang: string) {
@apply text-xl font-bold;
}
.nav-desktop {
@apply hidden md:flex items-center space-x-4;
@apply hidden lg:flex items-center space-x-1;
}
.nav-link {
@apply p-2 hover:text-purple-300 transition-colors duration-200;
@apply px-2.5 py-2 text-sm hover:text-purple-300 transition-colors duration-200 whitespace-nowrap;
}
.nav-link-admin {
@apply p-2 hover:text-yellow-300 transition-colors duration-200;
@apply px-2.5 py-2 text-sm hover:text-yellow-300 transition-colors duration-200 whitespace-nowrap;
}
.locale-switcher {
@apply flex items-center gap-1 text-sm font-bold ml-2 border-l border-gray-600 pl-4;
@@ -131,14 +133,19 @@ function switchLocale(lang: string) {
.locale-separator {
@apply text-gray-600;
}
/* Az ikonok viszik a legtöbb helyet a sok menüpont mellett — desktopon csak
extra széles kijelzőn jelennek meg, a mobil menüben mindig. */
.nav-desktop .nav-icon {
@apply hidden xl:inline-block;
}
.nav-icon {
@apply mr-1.5 text-xs opacity-70;
}
.hamburger-btn {
@apply md:hidden p-2 focus:outline-none focus:ring-2 focus:ring-purple-500 rounded-md;
@apply lg:hidden p-2 focus:outline-none focus:ring-2 focus:ring-purple-500 rounded-md;
}
.nav-mobile {
@apply md:hidden absolute top-full left-0 w-full bg-gray-800 flex flex-col items-center py-4 space-y-2 z-50;
@apply lg:hidden absolute top-full left-0 w-full bg-gray-800 flex flex-col items-center py-4 space-y-2 z-50;
}
.nav-mobile-link {
@apply block p-2 w-full text-center hover:bg-gray-700 transition-colors duration-200;
@@ -6,6 +6,7 @@ export interface WikiPage {
updatedAt: string
createdAt: string
locale: string
repo?: string | null
}
export interface WikiPageWithContent extends WikiPage {
@@ -0,0 +1,201 @@
<template>
<header class="hero-section-gradient from-emerald-700 to-teal-800 py-16">
<div class="hero-container">
<h1 class="hero-title">{{ t('batocera.title') }}</h1>
<p class="hero-subtitle text-emerald-50">{{ t('batocera.subtitle') }}</p>
</div>
</header>
<main class="main-container py-12 px-4 mt-0">
<div class="bat-panel">
<p class="bat-lead">{{ t('batocera.lead') }}</p>
<div class="bat-links">
<a :href="REPO_URL" target="_blank" rel="noopener noreferrer" class="bat-link bat-link-dark">
<i class="fa-solid fa-code-branch"></i> {{ t('batocera.repo') }}
</a>
<a :href="WIKI_URL" target="_blank" rel="noopener noreferrer" class="bat-link bat-link-indigo">
<i class="fa-solid fa-book"></i> {{ t('batocera.docs') }}
</a>
<a :href="BATOCERA_URL" target="_blank" rel="noopener noreferrer" class="bat-link bat-link-emerald">
<i class="fa-solid fa-tv"></i> {{ t('batocera.batoceraProject') }}
</a>
</div>
</div>
<section class="bat-section">
<h2 class="bat-section-title">
<span class="bat-step">1</span> {{ t('batocera.installTitle') }}
</h2>
<p class="bat-section-desc">{{ t('batocera.installDesc') }}</p>
<div class="bat-code">
<pre><code>{{ INSTALL_CMD }}</code></pre>
<button class="bat-copy" :title="t('batocera.copy')" @click="copy(INSTALL_CMD)">
<i :class="copied === INSTALL_CMD ? 'fa-solid fa-check' : 'fa-regular fa-copy'"></i>
</button>
</div>
<p class="bat-note">{{ t('batocera.installNote') }}</p>
<div class="bat-code">
<pre><code>{{ RESTART_CMD }}</code></pre>
<button class="bat-copy" :title="t('batocera.copy')" @click="copy(RESTART_CMD)">
<i :class="copied === RESTART_CMD ? 'fa-solid fa-check' : 'fa-regular fa-copy'"></i>
</button>
</div>
</section>
<section class="bat-section">
<h2 class="bat-section-title">
<span class="bat-step">2</span> {{ t('batocera.useTitle') }}
</h2>
<p class="bat-section-desc">{{ t('batocera.useDesc') }}</p>
<div class="bat-code">
<pre><code>{{ CLI_SNIPPET }}</code></pre>
<button class="bat-copy" :title="t('batocera.copy')" @click="copy(CLI_SNIPPET)">
<i :class="copied === CLI_SNIPPET ? 'fa-solid fa-check' : 'fa-regular fa-copy'"></i>
</button>
</div>
</section>
<section class="bat-section">
<h2 class="bat-section-title">{{ t('batocera.platformsTitle') }}</h2>
<p class="bat-section-desc">{{ t('batocera.platformsDesc') }}</p>
<ul class="bat-platforms">
<li v-for="p in platforms" :key="p.platform" class="bat-platform">
<i :class="p.icon" class="bat-platform-icon"></i>
<span class="bat-platform-name">{{ p.label }}</span>
<code class="bat-platform-ext">{{ p.ext }}</code>
</li>
</ul>
</section>
<section class="bat-section bat-engine">
<h2 class="bat-section-title">
<i class="fa-solid fa-cubes bat-engine-icon"></i> {{ t('batocera.engineTitle') }}
</h2>
<p class="bat-section-desc">{{ t('batocera.engineDesc') }}</p>
<a :href="ENGINE_URL" target="_blank" rel="noopener noreferrer" class="bat-link bat-link-slate">
<i class="fa-solid fa-code-branch"></i> warp-engine-batocera-store
</a>
</section>
<div class="bat-back">
<RouterLink to="/catalog" class="bat-back-link">{{ t('catalogShow.back') }}</RouterLink>
</div>
</main>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { RouterLink } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { CONFIG } from '../../lib/config'
const { t } = useI18n()
const FORGE = 'https://git.teletypegames.org/tools'
const REPO_URL = `${FORGE}/ttg-batocera-store`
// The store is only a config; the client itself is a reusable engine that runs
// against any WarpEngine site, so it gets its own repository and its own link.
const ENGINE_URL = `${FORGE}/warp-engine-batocera-store`
const WIKI_URL = `${CONFIG.wikiBase}/others/ttg-batocera-store`
const BATOCERA_URL = 'https://batocera.org'
// The installer is served straight from the forge, so this one line is the
// whole install on a Batocera box.
const INSTALL_CMD = `curl -fsSL ${REPO_URL}/raw/branch/master/install.sh | bash`
const RESTART_CMD = 'batocera-es-swissknife --restart'
const STORE_CLI = '/userdata/system/batocera-store/ttg-store'
const CLI_SNIPPET = [
`${STORE_CLI} list # compatible catalog entries`,
`${STORE_CLI} sync # download everything new`,
`${STORE_CLI} remove c64demo`,
].join('\n')
const platforms = [
{ platform: 'c64', label: 'Commodore 64 (VICE)', ext: '.prg', icon: 'fa-solid fa-floppy-disk' },
{ platform: 'tic80', label: 'TIC-80', ext: '.tic', icon: 'fa-solid fa-tv' },
]
const copied = ref('')
async function copy(text: string) {
try {
await navigator.clipboard.writeText(text)
copied.value = text
setTimeout(() => { if (copied.value === text) copied.value = '' }, 2000)
} catch {
// Clipboard API needs a secure context — the command stays selectable anyway.
}
}
</script>
<style scoped>
.bat-panel {
@apply bg-white rounded-2xl shadow-xl border border-gray-100 p-6 md:p-8;
}
.bat-lead {
@apply text-gray-600 leading-relaxed mb-6;
}
.bat-links {
@apply flex flex-wrap gap-3;
}
.bat-link {
@apply inline-flex items-center gap-2 font-bold py-2.5 px-5 rounded-xl text-sm text-white transition-all active:scale-95;
}
.bat-link-dark { @apply bg-slate-800 hover:bg-slate-900 shadow-lg shadow-slate-800/20; }
.bat-link-slate { @apply bg-slate-600 hover:bg-slate-700 shadow-lg shadow-slate-600/20; }
.bat-link-indigo { @apply bg-indigo-600 hover:bg-indigo-700 shadow-lg shadow-indigo-600/20; }
.bat-link-emerald { @apply bg-emerald-600 hover:bg-emerald-700 shadow-lg shadow-emerald-600/20; }
.bat-section {
@apply bg-white rounded-2xl shadow-xl border border-gray-100 p-6 md:p-8 mt-6;
}
.bat-section-title {
@apply flex items-center gap-3 text-xl font-bold text-gray-900 mb-2;
}
.bat-step {
@apply inline-flex items-center justify-center w-7 h-7 rounded-full bg-emerald-100 text-emerald-700 text-sm font-bold;
}
.bat-section-desc {
@apply text-gray-600 mb-4;
}
.bat-note {
@apply text-gray-600 mt-4 mb-3 text-sm;
}
.bat-code {
@apply relative bg-gray-900 text-gray-100 rounded-xl overflow-x-auto;
}
.bat-code pre {
@apply p-4 pr-14 font-mono text-sm leading-relaxed;
}
.bat-copy {
@apply absolute top-2 right-2 w-9 h-9 rounded-lg bg-gray-800 text-gray-300 hover:bg-gray-700 hover:text-white transition-colors;
}
.bat-engine {
@apply bg-gray-50;
}
.bat-engine-icon {
@apply text-gray-400 text-lg;
}
.bat-platforms {
@apply grid grid-cols-1 sm:grid-cols-2 gap-3;
}
.bat-platform {
@apply flex items-center gap-3 bg-gray-50 border border-gray-100 rounded-xl px-4 py-3;
}
.bat-platform-icon {
@apply text-gray-400;
}
.bat-platform-name {
@apply font-medium text-gray-800 flex-grow;
}
.bat-platform-ext {
@apply font-mono text-xs text-purple-600 bg-purple-50 px-2 py-0.5 rounded;
}
.bat-back {
@apply mt-8 text-center;
}
.bat-back-link {
@apply text-gray-400 hover:text-gray-600 text-sm font-medium transition-colors;
}
</style>
@@ -120,6 +120,7 @@ const columnGroups = [
color: 'group-amber',
kinds: [
{ kind: 'linux_x64', sub: '64 bit' },
{ kind: 'linux_arm64', sub: 'ARM 64 bit' },
],
},
{
@@ -66,6 +66,10 @@
<RouterLink to="/builds" class="builds-link">
<i class="fa-solid fa-table-cells"></i> {{ t('builds.title') }}
</RouterLink>
<span class="builds-link-sep">|</span>
<RouterLink to="/batocera" class="builds-link">
<i class="fa-solid fa-gamepad"></i> {{ t('batocera.title') }}
</RouterLink>
</div>
</main>
</template>
@@ -162,9 +166,12 @@ onMounted(() => store.fetch())
@apply text-xs text-gray-400 font-medium ml-1;
}
.builds-link-row {
@apply text-center mt-10;
@apply flex flex-wrap items-center justify-center gap-3 mt-10;
}
.builds-link {
@apply text-sm text-gray-400 hover:text-purple-600 font-medium transition-colors;
}
.builds-link-sep {
@apply text-gray-200 text-sm;
}
</style>
@@ -96,7 +96,7 @@
<span class="latest-release-badge">{{ t('catalogShow.latestStable') }}</span>
</div>
<h2 class="latest-release-version">{{ latestStable.version }}</h2>
<p class="latest-release-date"><i class="fa-solid fa-calendar mr-1"></i>{{ t('catalogShow.released') }} {{ formatDateTime(latestStable.UpdatedAt) }}</p>
<p class="latest-release-date"><i class="fa-solid fa-calendar mr-1"></i>{{ t('catalogShow.released') }} {{ formatDateTime(latestStable.updatedAt) }}</p>
</div>
<a v-if="latestStable.htmlFolderPath" :href="latestStable.htmlFolderPath" target="_blank" class="latest-release-play-btn"><i class="fa-solid fa-play mr-2"></i>{{ t('catalogShow.playNow') }}</a>
@@ -155,7 +155,7 @@
</div>
<a v-if="release.htmlFolderPath" :href="release.htmlFolderPath" target="_blank" class="release-play-link">{{ t('catalogShow.play') }}</a>
</td>
<td class="release-date-col">{{ formatDateTime(release.UpdatedAt) }}</td>
<td class="release-date-col">{{ formatDateTime(release.updatedAt) }}</td>
</tr>
<tr v-if="tableAssets(release).length">
<td colspan="2" class="px-8 pb-4 pt-0">
@@ -185,7 +185,7 @@
<div class="dev-release-version">{{ release.version }}</div>
<a v-if="release.htmlFolderPath" :href="release.htmlFolderPath" target="_blank" class="release-play-link">{{ t('catalogShow.play') }}</a>
</td>
<td class="release-date-col">{{ formatDateTime(release.UpdatedAt) }}</td>
<td class="release-date-col">{{ formatDateTime(release.updatedAt) }}</td>
</tr>
<tr v-if="tableAssets(release).length" class="bg-yellow-50/10">
<td colspan="2" class="px-8 pb-4 pt-0">
@@ -218,7 +218,7 @@
<div class="mobile-release-header">
<div>
<div class="mobile-release-version">{{ release.version }}</div>
<div class="mobile-release-date">{{ formatDateTime(release.UpdatedAt) }}</div>
<div class="mobile-release-date">{{ formatDateTime(release.updatedAt) }}</div>
</div>
<span v-if="release.version.startsWith('dev-')" class="dev-badge">Dev</span>
</div>
@@ -314,7 +314,7 @@ const downloadUrl = (path: string) =>
// az asset-táblázat sorai és sorrendjük; a html nem letöltés, azt a Play gomb viszi
const TABLE_KIND_ORDER = [
'cartridge', 'source', 'docs',
'win_x64', 'win_x86', 'linux_x64', 'linux_x86',
'win_x64', 'win_x86', 'linux_x64', 'linux_arm64', 'linux_x86',
'mac_universal', 'mac_arm64', 'mac_x64',
]
@@ -0,0 +1,186 @@
<template>
<div class="engines-container">
<header class="hero-section-slate">
<div class="engines-header-decor">
<div class="hero-decor-blob -top-24 -left-24 h-96 w-96 bg-emerald-600"></div>
<div class="hero-decor-blob -bottom-24 -right-24 h-96 w-96 bg-teal-600"></div>
</div>
<div class="hero-container">
<div class="hero-badge">{{ t('engines.badge') }}</div>
<h1 class="hero-title">
{{ t('engines.titleLead') }} <span class="text-transparent bg-clip-text bg-gradient-to-r from-emerald-400 to-teal-300">{{ t('engines.titleAccent') }}</span>
</h1>
<p class="hero-subtitle mb-10">{{ t('engines.subtitle') }}</p>
</div>
</header>
<main class="engines-main">
<div v-if="error" class="error-banner" role="alert">
<i class="fa-solid fa-triangle-exclamation text-2xl text-yellow-500"></i>
<div>
<h3 class="error-banner-title">{{ t('engines.errorTitle') }}</h3>
<p class="error-banner-desc">{{ error }}</p>
</div>
</div>
<SkeletonCard v-else-if="loading" :count="3" />
<div v-else-if="enginePages.length === 0" class="empty-state">
<div class="empty-state-icon"><i class="fa-solid fa-inbox"></i></div>
<h2 class="empty-state-title">{{ t('engines.noPagesTitle') }}</h2>
<p class="empty-state-desc">{{ t('engines.noPagesDesc') }}</p>
</div>
<div v-else class="engine-list">
<article v-for="({ page, digest }, index) in cards" :key="page.id" class="engine-card group">
<div class="engine-card-index">{{ String(index + 1).padStart(2, '0') }}</div>
<div class="engine-card-body">
<h2 class="engine-card-title">
<a :href="exploreUrl(page)" target="_blank" rel="noopener">{{ page.title }}</a>
</h2>
<p v-if="page.description" class="engine-card-desc">{{ page.description }}</p>
<p v-if="digest.intro" class="engine-card-preview">{{ digest.intro }}</p>
<p v-else-if="page.content" class="engine-card-preview">{{ getCleanPreview(page.content) }}</p>
<section v-if="digest.highlights.length" class="engine-highlights">
<h3 v-if="digest.highlightsTitle" class="engine-highlights-title">{{ digest.highlightsTitle }}</h3>
<ul class="engine-highlights-grid">
<li v-for="(item, i) in digest.highlights" :key="i" class="engine-highlight">
<span class="engine-highlight-icon"><i class="fa-solid fa-check"></i></span>
<p class="engine-highlight-body">
<strong v-if="item.title" class="engine-highlight-lead">{{ item.title }}</strong>
<template v-if="item.title && item.text"> </template>{{ item.text }}
</p>
</li>
</ul>
</section>
<div class="engine-card-actions">
<a :href="exploreUrl(page)" target="_blank" rel="noopener" class="engine-explore-btn">
<i class="fa-solid fa-code-branch text-sm"></i>
{{ t('engines.explore') }}
<i class="fa-solid fa-arrow-right text-sm"></i>
</a>
<a :href="`${WIKI_BASE}/${page.path}`" target="_blank" rel="noopener" class="engine-wiki-link">
<i class="fa-solid fa-book mr-1"></i>{{ t('engines.openWiki') }}
</a>
</div>
</div>
</article>
</div>
</main>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import { WIKI_BASE } from '../../api/wiki.api'
import { useEnginesStore, getEngineDigest } from '../../stores/engines.store'
import type { WikiPageWithContent } from '../../lib/interfaces/wiki.interface'
import SkeletonCard from '../../components/SkeletonCard.vue'
const { t } = useI18n()
const store = useEnginesStore()
const { pages: enginePages, loading, error } = storeToRefs(store)
const { getCleanPreview } = store
const cards = computed(() =>
enginePages.value.map((page) => ({ page, digest: getEngineDigest(page.content) })),
)
// Explore points at the engine's git repository (from wiki metadata);
// pages without one fall back to their wiki page.
const exploreUrl = (page: WikiPageWithContent): string =>
page.repo || `${WIKI_BASE}/${page.path}`
onMounted(() => store.fetch())
</script>
<style scoped>
.engines-container {
@apply bg-slate-950 min-h-screen pb-24;
}
.engines-header-decor {
@apply absolute inset-0 opacity-30;
}
.engines-main {
@apply max-w-5xl mx-auto px-4 md:px-8 mt-16 relative z-10;
}
.banner-base {
@apply max-w-7xl mx-auto border-l-4 p-6 rounded-r-xl shadow-lg mb-12 flex items-start gap-4;
}
.error-banner { @apply banner-base bg-red-50 border-red-500; }
.error-banner-title { @apply text-red-800 font-bold text-lg; }
.error-banner-desc { @apply text-red-700 mt-1; }
.empty-state {
@apply max-w-7xl mx-auto bg-slate-900 rounded-2xl shadow-xl p-12 text-center border border-slate-800;
}
.empty-state-icon { @apply text-6xl mb-4; }
.empty-state-title { @apply text-2xl font-bold text-white mb-2; }
.empty-state-desc { @apply text-slate-400 max-w-md mx-auto; }
/* Few engines, so every one of them gets a full-width, poster-like card. */
.engine-list {
@apply flex flex-col gap-10;
}
.engine-card {
@apply relative overflow-hidden bg-slate-900 rounded-3xl border border-slate-800 border-l-4 border-l-emerald-500 shadow-2xl transition-all hover:border-l-teal-300 hover:-translate-y-1;
}
.engine-card-index {
@apply absolute -top-6 right-4 text-[9rem] leading-none font-black text-slate-800/60 select-none pointer-events-none transition-colors;
}
.engine-card:hover .engine-card-index {
@apply text-slate-800;
}
.engine-card-body {
@apply relative p-8 md:p-12;
}
.engine-card-title {
@apply text-3xl md:text-5xl font-black text-white mb-4 leading-tight;
}
.engine-card-title a {
@apply hover:text-emerald-400 transition-colors;
}
.engine-card-desc {
@apply text-lg md:text-xl text-emerald-100/90 font-semibold mb-3 leading-relaxed max-w-3xl;
}
.engine-card-preview {
@apply text-base text-slate-400 leading-relaxed mb-8 max-w-3xl;
}
.engine-highlights {
@apply mb-10;
}
.engine-highlights-title {
@apply text-xs font-bold uppercase tracking-[0.2em] text-emerald-400 mb-5;
}
.engine-highlights-grid {
@apply grid md:grid-cols-2 gap-x-10 gap-y-4 max-w-4xl;
}
.engine-highlight {
@apply flex items-start gap-3;
}
.engine-highlight-icon {
@apply mt-1 flex h-5 w-5 flex-none items-center justify-center rounded-md bg-emerald-500/15 text-emerald-400 text-[10px];
}
.engine-highlight-body {
@apply text-sm text-slate-400 leading-relaxed;
}
.engine-highlight-lead {
@apply text-white font-semibold;
}
.engine-card-actions {
@apply flex flex-wrap items-center gap-6;
}
.engine-explore-btn {
@apply inline-flex items-center gap-2 bg-emerald-500 text-slate-950 px-6 py-3 rounded-xl font-bold hover:bg-emerald-400 transition-all shadow-lg shadow-emerald-900/40 group-hover:translate-x-1;
}
.engine-wiki-link {
@apply text-slate-400 font-semibold hover:text-white transition-colors;
}
</style>
@@ -0,0 +1,5 @@
import type { RouteRecordRaw } from 'vue-router'
export const batoceraRouter: RouteRecordRaw[] = [
{ path: '/batocera', name: 'batoceraIndex', component: () => import('../page/batocera/BatoceraIndexPage.vue') },
]
@@ -0,0 +1,5 @@
import type { RouteRecordRaw } from 'vue-router'
export const enginesRouter: RouteRecordRaw[] = [
{ path: '/engines', name: 'enginesIndex', component: () => import('../page/engines/EnginesIndexPage.vue') },
]
+4
View File
@@ -4,9 +4,11 @@ import { blogRouter } from './blog.router'
import { catalogRouter } from './catalog.router'
import { codeRouter } from './code.router'
import { contactRouter } from './contact.router'
import { enginesRouter } from './engines.router'
import { howtosRouter } from './howtos.router'
import { teamRouter } from './team.router'
import { buildsRouter } from './builds.router'
import { batoceraRouter } from './batocera.router'
export const router = createRouter({
history: createWebHistory(),
@@ -16,9 +18,11 @@ export const router = createRouter({
...catalogRouter,
...codeRouter,
...contactRouter,
...enginesRouter,
...howtosRouter,
...teamRouter,
...buildsRouter,
...batoceraRouter,
],
scrollBehavior() {
return { top: 0 }
@@ -0,0 +1,79 @@
import { describe, it, expect } from 'vitest'
import { getEngineDigest } from '../engines.store'
// Generic markdown in the shape every engine-tagged wiki page must follow:
// intro paragraph, then "What You Get" as the first heading with bullets.
// Dummy text only — real content always comes from the wiki at runtime.
const SAMPLE_MARKDOWN = `
> Example Engine is a **sample** framework that turns markdown into landing cards.
# What You Get
- **First feature**: some \`inline code\` — with a longer explanation.
- **Second feature**: a [link](https://example.org) inside the text.
- Plain bullet without a bold lead.
# Later Section
- **Not picked up**: a list under a later heading.
`
describe('getEngineDigest', () => {
it('extracts the intro from the leading blockquote with inline markdown stripped', () => {
const digest = getEngineDigest(SAMPLE_MARKDOWN)
expect(digest.intro).toBe(
'Example Engine is a sample framework that turns markdown into landing cards.',
)
})
it('takes the What You Get heading as the highlights title', () => {
expect(getEngineDigest(SAMPLE_MARKDOWN).highlightsTitle).toBe('What You Get')
})
it('parses bold-lead bullets into title and text', () => {
const [first, second] = getEngineDigest(SAMPLE_MARKDOWN).highlights
expect(first).toEqual({
title: 'First feature',
text: 'some inline code — with a longer explanation.',
})
expect(second).toEqual({
title: 'Second feature',
text: 'a link inside the text.',
})
})
it('keeps bullets without a bold lead as plain text', () => {
const third = getEngineDigest(SAMPLE_MARKDOWN).highlights[2]
expect(third).toEqual({ title: '', text: 'Plain bullet without a bold lead.' })
})
it('stops at the heading after the What You Get section', () => {
expect(getEngineDigest(SAMPLE_MARKDOWN).highlights).toHaveLength(3)
})
it('matches the heading case-insensitively', () => {
const digest = getEngineDigest('# WHAT you get\n- **A**: b.')
expect(digest.highlights).toEqual([{ title: 'A', text: 'b.' }])
})
it('yields no highlights when the first heading is not What You Get', () => {
const digest = getEngineDigest('Intro line.\n\n# Features\n- **A**: b.\n\n# What You Get\n- **C**: d.')
expect(digest.intro).toBe('Intro line.')
expect(digest.highlights).toEqual([])
})
it('caps the highlights at six items', () => {
const many = '# What You Get\n' + Array.from({ length: 9 }, (_, i) => `- item ${i}`).join('\n')
expect(getEngineDigest(many).highlights).toHaveLength(6)
})
it('uses the first paragraph as intro when there is no blockquote', () => {
const digest = getEngineDigest('Just a plain paragraph.\n\n---\n\n# What You Get\n- one')
expect(digest.intro).toBe('Just a plain paragraph.')
expect(digest.highlights).toEqual([{ title: '', text: 'one' }])
})
it('returns an empty digest for empty content', () => {
expect(getEngineDigest('')).toEqual({ intro: '', highlightsTitle: '', highlights: [] })
})
})
+92
View File
@@ -0,0 +1,92 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import wikiApi from '../api/wiki.api'
import { useLoadable } from '../composables/useLoadable'
import type { WikiPageWithContent } from '../lib/interfaces/wiki.interface'
export interface EngineHighlight {
title: string
text: string
}
// Landing-page digest of a wiki engine page. Convention: every engine-tagged
// page opens with an intro paragraph, then a "What You Get" heading as its
// first heading, with the feature bullets underneath.
export interface EngineDigest {
intro: string
highlightsTitle: string
highlights: EngineHighlight[]
}
const HIGHLIGHTS_HEADING = 'what you get'
const MAX_HIGHLIGHTS = 6
// Inline markdown (links, bold, code) stripped so the text reads as plain prose.
function stripInline(md: string): string {
return md
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
.replace(/\*\*([^*]+)\*\*/g, '$1')
.replace(/[*_`]/g, '')
.replace(/\s+/g, ' ')
.trim()
}
export function getEngineDigest(content: string): EngineDigest {
const digest: EngineDigest = { intro: '', highlightsTitle: '', highlights: [] }
let inHighlights = false
for (const raw of (content || '').split('\n')) {
const line = raw.trim()
if (!line || /^([-*_])\1{2,}$/.test(line)) continue
const heading = line.match(/^#{1,6}\s+(.*)$/)
if (heading) {
// The section ends at the next heading; and if the page's first heading
// is not "What You Get", it doesn't follow the convention — no highlights.
if (inHighlights) break
const title = stripInline(heading[1])
if (title.toLowerCase() !== HIGHLIGHTS_HEADING) break
inHighlights = true
digest.highlightsTitle = title
continue
}
if (inHighlights) {
const bullet = line.match(/^[-*]\s+(.*)$/)
if (!bullet) continue
const lead = bullet[1].match(/^\*\*([^*]+)\*\*\s*[:—–-]?\s*(.*)$/)
digest.highlights.push(
lead
? { title: stripInline(lead[1]), text: stripInline(lead[2]) }
: { title: '', text: stripInline(bullet[1]) },
)
continue
}
// First prose line (paragraph or blockquote) before the heading is the intro.
if (!digest.intro) {
digest.intro = stripInline(line.replace(/^>\s*/, ''))
}
}
digest.highlights = digest.highlights.slice(0, MAX_HIGHLIGHTS)
return digest
}
export const useEnginesStore = defineStore('engines', () => {
const pages = ref<WikiPageWithContent[]>([])
const { loading, error, withCache, invalidate } = useLoadable()
async function fetch() {
await withCache(async () => {
pages.value = await wikiApi.listEnginePages()
})
}
function getCleanPreview(content: string): string {
if (!content) return ''
return content.replace(/[#*`_[\]()>|-]/g, '').replace(/\s+/g, ' ').trim().slice(0, 260) + '...'
}
return { pages, loading, error, fetch, getCleanPreview, getEngineDigest, invalidate }
})
+8 -20
View File
@@ -54,6 +54,10 @@ services:
WOODPECKER_GITEA_SECRET: ${GITEA_CLIENT_SECRET}
WOODPECKER_SERVER_ADDR: ":8000"
WOODPECKER_AGENT_SECRET: ${WOODPECKER_AGENT_SECRET}
# Global configuration extension: every pipeline start asks the update
# server for the config; non-marker repos fall back to their own YAML (204).
WOODPECKER_CONFIG_EXTENSION_ENDPOINT: "https://${WEBAPP_DOMAIN}/build/config"
WOODPECKER_ADMIN: mr.zero
volumes:
- ./data/woodpecker:/var/lib/woodpecker
labels:
@@ -162,6 +166,9 @@ services:
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@teletype.hu}
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-password123}
- WIKI_GRAV_URL=${WIKI_GRAV_URL:-https://wiki.teletypegames.org}
- WOODPECKER_URL=${WOODPECKER_URL:-http://woodpecker-server:8000}
- WOODPECKER_API_TOKEN=${WOODPECKER_API_TOKEN:-}
- WOODPECKER_REPO_OWNER=${WOODPECKER_REPO_OWNER:-}
depends_on:
mysql:
condition: service_healthy
@@ -178,7 +185,7 @@ services:
- "traefik.http.middlewares.api-cors.headers.accesscontrolallowheaders=Content-Type,Authorization,Accept,X-Requested-With"
- "traefik.http.middlewares.api-cors.headers.accesscontrolmaxage=3600"
- "traefik.http.middlewares.api-cors.headers.addvaryheader=true"
- "traefik.http.routers.api.rule=Host(`${WEBAPP_DOMAIN}`) && (PathPrefix(`/api`) || PathPrefix(`/file`) || PathPrefix(`/update`) || PathPrefix(`/admin`))"
- "traefik.http.routers.api.rule=Host(`${WEBAPP_DOMAIN}`) && (PathPrefix(`/api`) || PathPrefix(`/file`) || PathPrefix(`/build`) || PathPrefix(`/admin`))"
- "traefik.http.routers.api.entrypoints=web"
- "traefik.http.routers.api.priority=10"
- "traefik.http.routers.api.middlewares=api-cors"
@@ -192,25 +199,6 @@ services:
- proxy
- interstack
droparea:
image: linuxserver/openssh-server
container_name: droparea
environment:
PUID: 1
PGID: 1
TZ: Europe/Budapest
SUDO_ACCESS: "false"
PASSWORD_ACCESS: "true"
USER_NAME: drop
USER_PASSWORD: ${DROP_PASSWORD}
volumes:
- ./data/softwares:/home/drop
ports:
- "${DROPAREA_SSH_PORT}:2222"
networks:
- proxy
- interstack
volumes:
gitea:
woodpecker:
-2
View File
@@ -13,7 +13,6 @@ PHPMYADMIN_DOMAIN=db.teletype.hu
# Ports
GITEA_SSH_PORT=2222
DROPAREA_SSH_PORT=2223
TRAEFIK_WEB_PORT=9100
TRAEFIK_API_PORT=9101
@@ -23,7 +22,6 @@ WOODPECKER_AGENT_SECRET=
MYSQL_ROOT_PASSWORD=
DB_PASSWORD=
UPDATE_SECRET=
DROP_PASSWORD=
WEBAPP_WIKIJS_TOKEN=
DISCORD_INVITE_LINK=
+1
View File
@@ -2,3 +2,4 @@ log/
spec/dummy/log/
spec/dummy/tmp/
Gemfile.lock
.bundle/
+1
View File
@@ -8,5 +8,6 @@ group :development, :test do
gem "rspec-rails", "~> 7.0"
gem "factory_bot_rails"
gem "shoulda-matchers", "~> 6.0"
gem "webmock", "~> 3.0"
gem "debug", platforms: %i[mri windows]
end
+395 -46
View File
@@ -1,89 +1,438 @@
# WarpEngine
Mountable Rails engine: a retro software catalog with a CI-pipeline-callable
release updater, a public read-only JSON API, and ActiveAdmin resources that
load into the host application's admin.
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.
## What it provides
Repository: `https://git.teletypegames.org/tools/warp_engine`
- **Models**: `Software`, `Release`, `ReleaseAsset`, `ExternalLink`,
`PlatformLink`, `Image`, `SoftwareImage`, `Download` (all under
`WarpEngine::`, with unprefixed table names)
- **Updater**: `GET /update?platform=&name=&version=` (auth via the
`X-Update-Secret` header or `?secret=`) — CI copies build artifacts under
`file_container_path` using the `<name>-<version>*` naming convention, then
calls the endpoint; the updater extracts archives, parses metadata, and
upserts the Software/Release/ReleaseAsset/ExternalLink records.
Supported platforms: tic80, ebitengine, love, c64, godot, bevy, phaser.
- **Public API**: `/api/software`, `/api/software/highlighted`, `/api/builds`,
`/api/softwares/:name/builds`, `/api/image/:id`, `/api/download?path=`,
`/file/*path`
- **Admin**: ActiveAdmin resource files (softwares with a 3-level nested form,
releases, external links, platform links, images with orphan management, a
Files file-manager page with picker mode, download stats) — loaded into the
host's single ActiveAdmin instance.
## Features
- **Catalog domain**: `Software`, `Release`, `ReleaseAsset`, `ExternalLink`,
`PlatformLink`, `Image`, `SoftwareImage`, `Download` models with soft-delete
semantics and download statistics.
- **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 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, image serving, 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, an
image library with orphan cleanup, a file manager with a picker mode, and
download statistics. Without ActiveAdmin the engine runs headless
(API + updater only).
- **Woodpecker CI management (optional)**: with a Woodpecker API token
configured, the admin also gains repo sync, per-repo pipeline history
with manual triggers, and automatic provisioning of application tokens
as Woodpecker secrets.
## 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
```sh
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:
```sh
# 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:
```sh
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:
```yaml
# .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`](https://git.teletypegames.org) 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:
```ruby
# Gemfile
gem "warp_engine", path: "../../libs/ruby/warp_engine"
gem "warp_engine", git: "https://git.teletypegames.org/tools/warp_engine.git"
```
Or from the Forgejo rubygems registry (tagged releases):
```ruby
source "https://git.teletypegames.org/api/packages/tools/rubygems" do
gem "warp_engine"
end
```
Then:
```sh
rails g warp_engine:install # initializer + create_warp_engine_tables migration
rails db:migrate
```
```ruby
# config/routes.rb — keep it the last entry so host routes win
# config/routes.rb — keep it the last entry so your own routes win
mount WarpEngine::Engine => "/"
```
## Configuration
```ruby
# config/initializers/warp_engine.rb
Rails.application.config.to_prepare do
WarpEngine.configure do |c|
# Where CI drops build artifacts and where images are stored
c.file_container_path = ENV.fetch("FILE_CONTAINER_PATH", "/softwares")
c.image_container_path = ENV.fetch("IMAGE_CONTAINER_PATH", "/images")
c.update_secret = ENV["UPDATE_SECRET"] # nil => /update rejects everything
# If host models also reference catalog images:
c.image_owners = [
{
label: "member",
image_ids: -> { Member.where.not(image_id: nil).distinct.pluck(:image_id) },
usage_label: ->(image) { "member" if Member.where(image_id: image.id).exists? }
}
]
# 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
# If your app's own models reference catalog images, register them so the
# admin Images page counts them as "in use":
# c.image_owners = [
# {
# label: "member",
# image_ids: -> { Member.where.not(image_id: nil).distinct.pluck(:image_id) },
# usage_label: ->(image) { "member" if Member.where(image_id: image.id).exists? }
# }
# ]
end
end
```
## Host expectations
## The updater contract
- **ActiveAdmin + Devise live in the host**: authentication, theme, assets and
the `/admin` routes are the host's responsibility; the engine only appends
its resource files to `ActiveAdmin.application.load_paths`.
- **Files picker JS**: the file-picker next to release-asset path inputs relies
on a few lines of JS in the host's `active_admin.js` (an iframe pointing at
`/admin/files?picker=1&field=<dom_id>`) — copy that over to a new host too.
- **apipie**: if the host generates apipie docs, add the engine to the matcher:
`"#{WarpEngine::Engine.root}/app/controllers/**/*.rb"`.
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:
```sh
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**:
```sh
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 pipeline configs (Woodpecker)
WarpEngine can act as a [Woodpecker configuration extension](https://woodpecker-ci.org/docs/usage/extensions/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):
```yaml
# .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
`ci_extension_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.
Configuration: `ci_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 in
`app/services/warp_engine/platforms/<platform>/pipeline.yaml.erb`.
## Woodpecker CI management
Beyond serving pipeline configs, WarpEngine can drive the Woodpecker REST API
itself. Set `woodpecker_url` and `woodpecker_api_token` — while either is nil
(the default), every management feature stays inactive and the admin pages
hide themselves:
```ruby
c.woodpecker_url = ENV["WOODPECKER_URL"] # e.g. "https://ci.example.org"
c.woodpecker_api_token = ENV["WOODPECKER_API_TOKEN"] # PAT of a Woodpecker *instance admin*
c.woodpecker_repo_owner = ENV["WOODPECKER_REPO_OWNER"] # forge org the game repos live under
```
What it unlocks (all surfaced in the admin):
- **Repo sync** (*Pipelines → Sync from Woodpecker*): mirrors the Woodpecker
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` Woodpecker 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:
```ruby
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:
```ruby
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.
## 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:
```ruby
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?`.
## Public API
| Endpoint | Purpose |
| --- | --- |
| `GET /api/software` | Full catalog with releases, assets, links, download counts; `?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 catalog images |
| `GET /api/download?path=` | Serves an artifact and logs a download record |
| `GET /file/*path` | Serves static build output (web-playable games, docs) |
## 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 updater "resurrects" re-submitted, previously deleted records via
`.unscoped`.
- The JSON shape is intentionally bug-compatible with the former Go backend
(Go zero-time timestamps, camelCase keys, legacy flat path fields).
- 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.
## Tests
The engine ships an RSpec suite running against a bundled dummy app:
```sh
bundle install
bundle exec rake app:db:prepare RAILS_ENV=test # warp_engine_test DB for the dummy app
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`](https://git.teletypegames.org/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.
@@ -0,0 +1,133 @@
ActiveAdmin.register WarpEngine::ApplicationToken, as: "Application Token" do
actions :index, :show, :new, :create, :edit, :update, :destroy
permit_params :name, :owner_id, :expires_at, :scopes_string, :unrestricted
menu parent: "🌀 WarpEngine", priority: 9, label: "🎟️ App Tokens"
config.sort_order = "created_at_desc"
config.batch_actions = false
scope :all, default: true
scope("Active") { |scope| scope.where("expires_at IS NULL OR expires_at > ?", Time.current) }
scope("Expired") { |scope| scope.where("expires_at <= ?", Time.current) }
index do
id_column
column :name
column("Token") { |t| code "#{t.token_prefix}", style: "font-family:monospace;" }
column("Owner") { |t| t.owner.try(:email) || t.owner.try(:name) || "#{t.owner_type} ##{t.owner_id}" }
column("Scopes") { |t| t.scopes_string }
column :unrestricted
column :expires_at
column :last_used_at
column :created_at
actions
end
filter :name_cont, label: "Name"
filter :token_prefix_cont, label: "Token prefix"
filter :expires_at
filter :last_used_at
form do |f|
owner_class = WarpEngine.config.application_token_owner_class&.safe_constantize
f.inputs do
if f.object.new_record?
if owner_class
f.input :owner_id, as: :select, label: owner_class.name,
collection: owner_class.all.map { |o| [ o.try(:email) || o.try(:name) || "##{o.id}", o.id ] },
include_blank: false
else
f.template.concat(f.template.content_tag(:li,
"application_token_owner_class is not configured — tokens cannot be created.",
class: "flash flash_error"))
end
end
f.input :name
f.input :scopes_string, label: "Scopes (comma separated)",
hint: %(The "update" scope is required for /build/publish, the "upload" scope for /build/upload.)
f.input :unrestricted, hint: "Internal token: exempt from owner isolation (enforce_software_ownership)."
f.input :expires_at, hint: "Leave empty for a token that never expires."
end
f.actions
end
action_item :rotate, only: :show do
if WarpEngine.woodpecker_configured?
link_to "Rotate Token", rotate_admin_application_token_path(resource),
method: :post, data: { confirm: "This will revoke the current token, create a new one, and push it to Woodpecker. Continue?" }
end
end
member_action :rotate, method: :post do
result = WarpEngine::SecretSyncService.new.rotate(resource)
if result[:rotated]
session[:warp_engine_plain_token] = result[:new_token].plain_token
redirect_to resource_path(result[:new_token]),
notice: "Token rotated and synced to #{result.dig(:sync_result, :synced)&.size || 0} pipeline(s)"
else
redirect_to resource_path(resource),
alert: "Rotation failed: #{result[:reason]}"
end
end
show do
if (plain = controller.instance_variable_get(:@plain_token))
panel "⚠️ Token — shown only once, copy it now!" do
pre plain, style: "font-family:monospace;font-size:14px;padding:8px;background:#fff3cd;user-select:all;"
end
end
attributes_table do
row :id
row :name
row("Token") { |t| code "#{t.token_prefix}… (SHA256 digest stored)" }
row("Owner") { |t| "#{t.owner_type} ##{t.owner_id}#{t.owner.try(:email) || t.owner.try(:name)}" }
row("Scopes") { |t| t.scopes_string }
row :unrestricted
row :expires_at
row :last_used_at
row :created_at
row :updated_at
end
end
controller do
# The plain token only exists right after creation; it travels via the
# session to its one-time display (flash is unsuitable: the AA layout
# renders every flash key as a message bar).
def create
create! do |success, _failure|
success.html do
session[:warp_engine_plain_token] = resource.plain_token
if WarpEngine.woodpecker_configured?
service = WarpEngine::SecretSyncService.new
pipelines = service.pipelines_for_token(resource)
if pipelines.any?
result = service.provision(resource.plain_token, pipelines: pipelines)
flash[:notice] = "Token created and synced to #{result[:synced].size} pipeline(s)."
if result[:failed].any?
flash[:alert] = "Failed to sync to #{result[:failed].size} pipeline(s)."
end
end
end
redirect_to resource_path(resource) and return
end
end
end
def show
@plain_token = session.delete(:warp_engine_plain_token)
show!
end
def destroy
if WarpEngine.woodpecker_configured?
WarpEngine::SecretSyncService.new.deprovision(resource)
end
resource.revoke!
redirect_to collection_path, notice: "Token revoked and Woodpecker secrets cleaned up."
end
end
end
+1 -1
View File
@@ -1,7 +1,7 @@
ActiveAdmin.register WarpEngine::Download, as: "Download" do
actions :index, :show
menu priority: 7, label: "📊 Download Stats"
menu parent: "🌀 WarpEngine", priority: 7, label: "📊 Download Stats"
scope :all, default: true
scope("Today") { |scope| scope.where("downloads.created_at >= ?", Date.current.beginning_of_day) }
+1 -1
View File
@@ -1,5 +1,5 @@
ActiveAdmin.register_page "Files" do
menu priority: 6, label: "📁 Files"
menu parent: "🌀 WarpEngine", priority: 6, label: "📁 Files"
content do
service = WarpEngine::FileManagerService.new
+1 -1
View File
@@ -1,7 +1,7 @@
ActiveAdmin.register WarpEngine::Image, as: "Image" do
permit_params :file_upload
menu priority: 5, label: "🖼️ Images"
menu parent: "🌀 WarpEngine", priority: 5, label: "🖼️ Images"
# SoftwareImage a natív használó; a hoston regisztrált image_owners
# (WarpEngine.config) további használókat adhat hozzá (pl. TTG Member).
@@ -0,0 +1,126 @@
ActiveAdmin.register WarpEngine::Pipeline, as: "Pipeline" do
actions :index, :show, :edit, :update
menu parent: "🌀 WarpEngine", priority: 10, label: "🚀 Pipelines"
config.sort_order = "repo_name_asc"
config.batch_actions = false
scope :all, default: true
scope("Active") { |s| s.where(active: true) }
scope("Inactive") { |s| s.where(active: false) }
WarpEngine::PlatformLink::SUPPORTED_PLATFORMS.each do |p|
scope(p.capitalize) { |s| s.where(platform: p) }
end
index do
id_column
column :repo_owner
column :repo_name
column :platform
column("Software") { |r| r.software ? link_to(r.software.title, admin_software_path(r.software)) : "-" }
column(:active) { |r| status_tag(r.active ? "active" : "inactive", class: r.active ? "yes" : "no") }
column("Pipeline") { |r|
if r.last_pipeline_status
status_tag r.last_pipeline_status,
class: r.last_pipeline_status == "success" ? "yes" : "no"
else
"-"
end
}
column :last_pipeline_at
actions defaults: true do |pipeline|
if pipeline.active && WarpEngine.woodpecker_configured?
item "Trigger", trigger_admin_pipeline_path(pipeline), method: :post, class: "member_link"
end
end
end
filter :repo_name
filter :platform, as: :select, collection: WarpEngine::PlatformLink::SUPPORTED_PLATFORMS
filter :active
form do |f|
f.inputs do
f.input :platform, as: :select, collection: WarpEngine::PlatformLink::SUPPORTED_PLATFORMS
f.input :software_id, as: :select,
collection: WarpEngine::Software.order(:title).map { |s| [ s.title, s.id ] },
include_blank: "- none -"
end
f.actions
end
sidebar "Details", only: :show do
attributes_table_for resource do
row :id
row :woodpecker_repo_id
row :repo_owner
row :repo_name
row :platform
row("Software") { |r| r.software ? link_to(r.software.title, admin_software_path(r.software)) : "-" }
row(:active) { |r| status_tag(r.active ? "active" : "inactive", class: r.active ? "yes" : "no") }
row :last_pipeline_status
row :last_pipeline_at
row :created_at
row :updated_at
end
end
show do
panel "Pipelines" do
if !WarpEngine.woodpecker_configured?
para "Woodpecker is not configured. Set woodpecker_url and woodpecker_api_token in the WarpEngine initializer.",
style: "color:#999;"
elsif !resource.active
para "This repository is inactive.", style: "color:#999;"
else
begin
pipelines = WarpEngine::PipelineService.new.list_pipelines(resource, page: 1)
if pipelines.is_a?(Array) && pipelines.any?
table_for pipelines.first(10) do
column("Number") { |p| p["number"] }
column("Status") { |p| status_tag p["status"], class: p["status"] == "success" ? "yes" : "no" }
column("Branch") { |p| p["branch"] }
column("Message") { |p| p["message"]&.truncate(60) }
# Woodpecker returns unix epoch seconds in "created"
column("Created") { |p| p["created"] ? Time.zone.at(p["created"]).strftime("%Y-%m-%d %H:%M") : "-" }
end
else
para "No pipelines found.", style: "color:#999;"
end
rescue => e
para "Error fetching pipelines: #{e.message}", style: "color:red;"
end
end
end
end
member_action :trigger, method: :post do
pipeline = WarpEngine::Pipeline.find(params[:id])
WarpEngine::PipelineService.new.trigger(pipeline)
redirect_to resource_path(pipeline), notice: "Pipeline triggered for #{pipeline.full_name}"
rescue => e
redirect_to resource_path(pipeline), alert: "Trigger failed: #{e.message}"
end
collection_action :sync, method: :post do
result = WarpEngine::PipelineSyncService.new.sync_all
redirect_to collection_path,
notice: "Synced: #{result[:created].size} new, #{result[:updated].size} updated, #{result[:deactivated].size} deactivated"
rescue => e
redirect_to collection_path, alert: "Sync failed: #{e.message}"
end
action_item :sync_repos, only: :index do
if WarpEngine.woodpecker_configured?
link_to "Sync from Woodpecker", sync_admin_pipelines_path, method: :post
end
end
controller do
def scoped_collection
super.includes(:software)
end
end
end
@@ -1,7 +1,7 @@
ActiveAdmin.register WarpEngine::PlatformLink, as: "Platform Link" do
permit_params :name, :url, :platform, :position
menu priority: 5, label: "🔗 Platform Links"
menu parent: "🌀 WarpEngine", priority: 5, label: "🔗 Platform Links"
config.sort_order = "platform_asc"
+10 -1
View File
@@ -6,7 +6,7 @@ ActiveAdmin.register WarpEngine::Software, as: "Software" do
releases_attributes: [ :id, :version, :_destroy,
{ release_assets_attributes: [ :id, :kind, :path, :_destroy ] } ]
menu priority: 2, label: "🎮 Softwares"
menu parent: "🌀 WarpEngine", priority: 2, label: "🎮 Softwares"
actions :all, except: [ :edit ]
@@ -52,6 +52,15 @@ ActiveAdmin.register WarpEngine::Software, as: "Software" do
text_node "View on site"
end
end
if resource.pipeline
div style: "margin-bottom:8px;" do
a href: admin_pipeline_path(resource.pipeline), style: "display:inline-flex;align-items:center;gap:6px;font-weight:bold;color:#5850ec;" do
span "🚀", style: "font-size:16px;"
text_node "Pipelines"
end
end
end
end
sidebar "Download Statistics", only: :show do
@@ -0,0 +1,71 @@
module WarpEngine
# Token authentication for the publishing (/build/*) endpoints.
# The auth source is exclusive: in :database mode the shared secret is not
# accepted, in :env mode DB tokens are not.
module UpdateAuthentication
extend ActiveSupport::Concern
private
attr_reader :current_application_token
# The token is accepted from the X-Update-Secret header only — in the URL
# it would leak into proxy and access logs.
def update_authorized?(required_scope:)
token = request.headers["X-Update-Secret"].presence
return false if token.blank?
case WarpEngine.config.application_token_source
when :database then database_token_authorized?(token, required_scope)
else env_secret_authorized?(token)
end
end
def env_secret_authorized?(token)
expected = WarpEngine.config.update_secret
# With no secret configured the endpoint stays closed.
expected.present? && ActiveSupport::SecurityUtils.secure_compare(token, expected)
end
def database_token_authorized?(token, required_scope)
if WarpEngine.config.application_token_owner_class.blank?
Rails.logger.error("[#{self.class.name}] application_token_source=:database but application_token_owner_class is not set — rejecting every request")
return false
end
record = WarpEngine::ApplicationToken.authenticate(token, required_scope: required_scope)
return false if record.nil?
record.touch_last_used!
@current_application_token = record
true
end
# Ownership enforcement applies only in :database mode (there is a token)
# with enforce_software_ownership on. An ownerless software is up for grabs
# until the backfill — backfill before enabling the enforcement.
def software_ownership_authorized?(name)
return true unless WarpEngine.config.enforce_software_ownership
token = current_application_token
return true if token.nil? || token.unrestricted?
software = WarpEngine::Software.find_by(name: name)
return true if software.nil? || software.owner_id.nil?
software.owner_type == token.owner_type && software.owner_id == token.owner_id
end
# A first-published (or pre-backfill, ownerless) software gets the
# submitting token's owner. Unrestricted (internal) tokens claim nothing.
def claim_software_ownership(name)
token = current_application_token
return if token.nil? || token.unrestricted?
software = WarpEngine::Software.find_by(name: name)
return if software.nil? || software.owner_id.present?
software.update_columns(owner_type: token.owner_type, owner_id: token.owner_id)
end
end
end
@@ -0,0 +1,74 @@
module WarpEngine
module Api
class CiController < ApiController
include UpdateAuthentication
before_action :require_woodpecker!
resource_description do
short "CI pipeline management"
end
api :GET, "/api/ci/pipelines", "List active pipelines"
returns code: 200, desc: "JSON array of tracked pipelines"
error code: 503, desc: "Woodpecker not configured"
def pipelines
records = Pipeline.active.includes(:software)
render json: records.map { |p| pipeline_json(p) }
end
api :GET, "/api/ci/pipelines/:id/status", "Get a pipeline with its latest run"
param :id, :number, required: true, desc: "Pipeline id"
returns code: 200, desc: "JSON with pipeline and latest run data"
error code: 503, desc: "Woodpecker not configured"
def status
pipeline = Pipeline.find(params[:id])
latest_run = begin
PipelineService.new.pipeline_detail(pipeline, "latest")
rescue WoodpeckerClient::ApiError
nil
end
render json: { pipeline: pipeline_json(pipeline), latest_run: latest_run }
end
api :POST, "/api/ci/pipelines/:id/trigger", "Trigger a pipeline"
header "X-Update-Secret", "Shared secret or application token (update scope)", required: true
param :id, :number, required: true, desc: "Pipeline id"
param :branch, String, required: false, desc: "Branch to build (default: main)"
returns code: 200, desc: "JSON with triggered run data"
error code: 401, desc: "Invalid secret"
error code: 503, desc: "Woodpecker not configured"
def trigger
unless update_authorized?(required_scope: ApplicationToken::UPDATE_SCOPE)
return render json: { error: "Unauthorized" }, status: :unauthorized
end
pipeline = Pipeline.find(params[:id])
result = PipelineService.new.trigger(pipeline, branch: params[:branch] || "main")
render json: { triggered: true, pipeline: result }
end
private
def require_woodpecker!
return if WarpEngine.woodpecker_configured?
render json: { error: "Woodpecker not configured" }, status: :service_unavailable
end
def pipeline_json(pipeline)
{
id: pipeline.id,
woodpecker_repo_id: pipeline.woodpecker_repo_id,
repo_owner: pipeline.repo_owner,
repo_name: pipeline.repo_name,
platform: pipeline.platform,
active: pipeline.active,
software_name: pipeline.software&.name,
last_pipeline_status: pipeline.last_pipeline_status,
last_pipeline_at: pipeline.last_pipeline_at
}
end
end
end
end
@@ -8,23 +8,26 @@ module WarpEngine
api :GET, "/api/download", "Download a file by path"
param :path, String, required: true, desc: "File path to download"
returns code: 200, desc: "File binary data"
returns code: 302, desc: "Redirect to the storage location (non-local storage adapter)"
error code: 400, desc: "Path is blank"
error code: 404, desc: "File not found"
def show
path = params[:path]
return render(json: { error: "Path is required" }, status: :bad_request) if path.blank?
full_path = WarpEngine::DownloadService.new.create(
location = WarpEngine::DownloadService.new.locate(
path: path,
ip: request.remote_ip,
user_agent: request.user_agent,
referer: request.referer
)
if full_path
send_file full_path, disposition: "attachment", type: resolve_mime(full_path)
else
if location.nil?
render json: { error: "Not found" }, status: :not_found
elsif location.redirect?
redirect_to location.url, allow_other_host: true
else
send_file location.path, disposition: "attachment", type: resolve_mime(location.path)
end
end
end
@@ -29,6 +29,7 @@ module WarpEngine
end
api :GET, "/api/software", "List all software entries with releases"
param :owner_id, :number, required: false, desc: "Filter to the softwares of one owner (publisher)"
returns code: 200, desc: "Wrapper object with softwares array" do
property :softwares, Array, desc: "Array of software entries" do
property :ID, Integer, desc: "Software ID"
@@ -55,7 +56,7 @@ module WarpEngine
end
end
def index
render json: WarpEngine::SoftwareService.new.index
render json: WarpEngine::SoftwareService.new.index(owner_id: params[:owner_id])
end
end
end
@@ -0,0 +1,83 @@
module WarpEngine
module Build
# Woodpecker configuration-extension endpoint: on every pipeline start the
# CI server POSTs the repo's marker file and receives the platform's full
# pipeline YAML. GET renders the same thing as a preview.
class ConfigsController < ApiController
resource_description do
short "Woodpecker CI pipeline configs"
end
api :GET, "/build/config", "Preview the generated pipeline config for a platform"
param :platform, String, required: true, desc: "Platform (a configured ci_platforms key, e.g. tic80)"
param :name, String, required: false, desc: "Software name substituted into the pipeline (default: example)"
returns code: 200, desc: "Pipeline YAML (text/yaml)"
error code: 404, desc: "Unknown platform"
def show
yaml = render_config(platform: params[:platform], name: params[:name].presence || "example")
return render json: { error: "Unknown platform" }, status: :not_found if yaml.nil?
render plain: yaml, content_type: "text/yaml"
end
api :POST, "/build/config", "Woodpecker configuration extension endpoint"
description <<~DESC
Called by the Woodpecker server on every pipeline start (httpsig-signed request).
If the repo's .woodpecker.yaml is a marker (has a `platform:` key), responds with
the generated pipeline; otherwise responds 204 so the repo's own config runs.
DESC
returns code: 200, desc: %(JSON: {"configs": [{"name": ..., "data": "<pipeline YAML>"}]})
returns code: 204, desc: "Not a marker config — keep the repo's own configuration"
error code: 403, desc: "Missing or invalid request signature"
error code: 422, desc: "Marker requests an unknown platform"
def create
unless WarpEngine::CiSignatureVerifier.new(request).valid?
return render json: { error: "Invalid signature" }, status: :forbidden
end
marker = find_marker
return head :no_content if marker.nil?
platform = marker["platform"].to_s
name = marker["name"].presence || repo_name
yaml = render_config(platform: platform, name: name)
if yaml.nil?
return render json: { error: "Unknown platform: #{platform}" }, status: :unprocessable_entity
end
render json: { configs: [ { name: platform, data: yaml } ] }
end
private
def render_config(platform:, name:)
WarpEngine::CiConfigService.new.render(
platform: platform,
name: name,
update_server: WarpEngine.config.ci_update_server.presence || request.base_url
)
end
# The first submitted config that parses as a marker (Hash with a `platform`
# key). The docs call the key "configuration", the example-config-service
# uses "configs" — accept both.
def find_marker
configs = params[:configuration].presence || params[:configs].presence || []
configs.each do |config|
data = config[:data].to_s
parsed = begin
YAML.safe_load(data)
rescue Psych::Exception
nil
end
return parsed if parsed.is_a?(Hash) && parsed.key?("platform")
end
nil
end
def repo_name
params.dig(:repo, :name).to_s
end
end
end
end
@@ -0,0 +1,45 @@
module WarpEngine
module Build
class PublishController < ApiController
include UpdateAuthentication
resource_description do
short "Build release publishing"
end
api :POST, "/build/publish", "Register an uploaded build as a release"
header "X-Update-Secret", "Shared secret or application token (update scope)", required: true
param :name, String, required: true, desc: "Software name"
param :platform, String, required: true, desc: "Platform (tic80, love, ebitengine, c64, godot, bevy, phaser)"
param :version, String, required: true, desc: "Version string"
returns code: 200, desc: "JSON with the published name/platform/version"
error code: 401, desc: "Invalid secret"
error code: 403, desc: "Token does not own this software"
error code: 400, desc: "Missing or invalid arguments"
def create
unless update_authorized?(required_scope: WarpEngine::ApplicationToken::UPDATE_SCOPE)
return render json: { error: "Unauthorized" }, status: :unauthorized
end
%i[name platform version].each do |key|
return render json: { error: "#{key.to_s.capitalize} not provided" }, status: :bad_request if params[key].blank?
end
unless software_ownership_authorized?(params[:name])
return render json: { error: "Forbidden" }, status: :forbidden
end
input = WarpEngine::PublishInputDto.new(
platform: params[:platform],
name: params[:name],
version: params[:version]
)
WarpEngine::PublishService.new.publish(input)
claim_software_ownership(params[:name])
render json: { published: true, name: params[:name], platform: params[:platform], version: params[:version] }
end
end
end
end
@@ -0,0 +1,65 @@
require "digest"
module WarpEngine
module Build
class UploadsController < ApiController
include UpdateAuthentication
resource_description do
short "Build artifact upload"
end
# Release file naming convention: <name>-<version>.<ext> or
# <name>-<version>-<target>.zip — the updater looks for these too.
NAME_FORMAT = /\A[A-Za-z0-9._-]+\z/
api :POST, "/build/upload", "Upload a build artifact into the artifact directory"
header "X-Update-Secret", "Shared secret or application token (upload scope)", required: true
param :name, String, required: true, desc: "Software name (filename must be prefixed with <name>-<version>)"
param :version, String, required: true, desc: "Version string"
param :file, File, required: true, desc: "Artifact file (multipart)"
param :sha256, String, required: false, desc: "Expected SHA256 hex digest; on mismatch the upload is rejected"
returns code: 200, desc: "JSON with stored file name, size and sha256"
error code: 401, desc: "Invalid secret"
error code: 403, desc: "Token does not own this software"
error code: 400, desc: "Missing or invalid arguments"
error code: 413, desc: "File larger than max_upload_size"
error code: 422, desc: "SHA256 mismatch"
def create
unless update_authorized?(required_scope: WarpEngine::ApplicationToken::UPLOAD_SCOPE)
return render json: { error: "Unauthorized" }, status: :unauthorized
end
name = params[:name].to_s
version = params[:version].to_s
file = params[:file]
return render json: { error: "Invalid name" }, status: :bad_request unless name.match?(NAME_FORMAT)
return render json: { error: "Invalid version" }, status: :bad_request unless version.match?(NAME_FORMAT)
return render json: { error: "File not provided" }, status: :bad_request unless file.respond_to?(:original_filename)
unless software_ownership_authorized?(name)
return render json: { error: "Forbidden" }, status: :forbidden
end
filename = File.basename(file.original_filename.to_s)
unless filename.start_with?("#{name}-#{version}.", "#{name}-#{version}-")
return render json: { error: "Filename must be prefixed with #{name}-#{version}" }, status: :bad_request
end
max = WarpEngine.config.max_upload_size
if file.size > max
return render json: { error: "File too large (max #{max / (1024 * 1024)}MB)" }, status: :payload_too_large
end
digest = Digest::SHA256.file(file.tempfile.path).hexdigest
if params[:sha256].present? && !ActiveSupport::SecurityUtils.secure_compare(params[:sha256].downcase, digest)
return render json: { error: "SHA256 mismatch" }, status: :unprocessable_entity
end
stored = WarpEngine::FileManagerService.new.upload("", file)
render json: { file: stored, size: file.size, sha256: digest }
end
end
end
end
@@ -1,49 +0,0 @@
module WarpEngine
class UpdateController < ApiController
resource_description do
short "Software updater"
formats [ "text" ]
end
rescue_from ArgumentError do |e|
render plain: e.message, status: :bad_request
end
rescue_from StandardError do |e|
Rails.logger.error("[UpdateController] #{e.class}: #{e.message}\n#{e.backtrace.first(5).join("\n")}")
render plain: "Internal server error", status: :internal_server_error
end
api :GET, "/update", "Update software version in database"
param :secret, String, required: true, desc: "Authorization secret"
param :platform, String, required: false, desc: "Platform (tic80, love, ebitengine, c64, godot, bevy, phaser)"
param :name, String, required: false, desc: "Software name"
param :version, String, required: true, desc: "Version string"
returns code: 200, desc: "Plain text 'Updated'"
error code: 401, desc: "Invalid secret"
error code: 400, desc: "Version not provided or invalid arguments"
error code: 500, desc: "Internal server error"
def update
return render plain: "Unauthorized", status: :unauthorized unless authorized?
return render plain: "Version not provided", status: :bad_request if params[:version].blank?
input = WarpEngine::UpdateInputDto.new(
platform: params[:platform],
name: params[:name],
version: params[:version]
)
WarpEngine::UpdateService.new.update(input)
render plain: "Updated"
end
private
def authorized?
secret = request.headers["X-Update-Secret"].presence || params[:secret]
expected = WarpEngine.config.update_secret
# Konfigurálatlan secret esetén az endpoint zárva marad.
expected.present? && secret == expected
end
end
end
@@ -1,5 +1,5 @@
module WarpEngine
UpdateInputDto = Struct.new(:platform, :name, :version, keyword_init: true) do
PublishInputDto = Struct.new(:platform, :name, :version, keyword_init: true) do
def initialize(platform:, name:, version: nil)
super
end
@@ -0,0 +1,8 @@
module WarpEngine
class ApplicationJob < ActiveJob::Base
retry_on WoodpeckerClient::ConnectionError, wait: 30.seconds, attempts: 3
discard_on WoodpeckerClient::ApiError do |job, error|
Rails.logger.error("[#{job.class.name}] discarded: #{error.message}")
end
end
end
@@ -0,0 +1,9 @@
module WarpEngine
class PipelineSyncJob < ApplicationJob
queue_as :default
def perform
PipelineSyncService.new.sync_all
end
end
end
@@ -0,0 +1,100 @@
require "digest"
module WarpEngine
class ApplicationToken < ApplicationRecord
self.table_name = "application_tokens"
UPDATE_SCOPE = "update".freeze
UPLOAD_SCOPE = "upload".freeze
# The generated token is only available in memory at creation time — the DB
# stores nothing but the SHA256 digest and the non-secret prefix.
attr_reader :plain_token
belongs_to :owner, polymorphic: true
default_scope { where(deleted_at: nil) }
scope :active, -> { where("expires_at IS NULL OR expires_at > ?", Time.current) }
before_validation :assign_owner_type, on: :create
before_validation :generate_token, on: :create
after_initialize { self.scopes = [] if new_record? && scopes.nil? }
validates :name, presence: true
validates :token_digest, presence: true, uniqueness: true
validates :token_prefix, presence: true
validates :scopes, presence: true
validate :owner_type_matches_configuration
def self.digest(token)
Digest::SHA256.hexdigest(token)
end
# The live (not deleted, not expired) token carrying the required scope, else nil.
def self.authenticate(token, required_scope: nil)
return nil if token.blank?
record = active.find_by(token_digest: digest(token))
return nil if record.nil?
return nil if required_scope.present? && !Array(record.scopes).include?(required_scope)
record
end
def expired?
expires_at.present? && expires_at <= Time.current
end
# Revocation = soft delete, the audit trail stays.
def revoke!
update_column(:deleted_at, Time.current)
end
def touch_last_used!
update_column(:last_used_at, Time.current)
end
# Admin form: comma separated scope list
def scopes_string
Array(scopes).join(", ")
end
def scopes_string=(value)
self.scopes = value.to_s.split(",").map(&:strip).reject(&:blank?).uniq
end
def self.ransackable_attributes(auth_object = nil)
%w[created_at deleted_at expires_at id last_used_at name owner_id owner_type token_prefix unrestricted updated_at]
end
# Ransack cannot filter on the polymorphic owner association.
def self.ransackable_associations(auth_object = nil)
[]
end
private
def assign_owner_type
self.owner_type = WarpEngine.config.application_token_owner_class if owner_type.blank?
end
def generate_token
return if token_digest.present?
@plain_token = SecureRandom.hex(24)
self.token_prefix = @plain_token.first(8)
self.token_digest = self.class.digest(@plain_token)
end
def owner_type_matches_configuration
expected = WarpEngine.config.application_token_owner_class
if expected.blank?
errors.add(:base, "application_token_owner_class is not configured")
elsif owner_type != expected
errors.add(:owner_type, "must be #{expected}")
end
end
ActiveSupport.run_load_hooks(:warp_engine_application_token, self)
end
end
@@ -0,0 +1,36 @@
module WarpEngine
class Pipeline < ApplicationRecord
self.table_name = "pipelines"
# Repos synced from Woodpecker without a matching Software land here
# until a platform is assigned by hand.
UNKNOWN_PLATFORM = "unknown".freeze
belongs_to :software, class_name: "WarpEngine::Software", optional: true
default_scope { where(deleted_at: nil) }
validates :woodpecker_repo_id, presence: true, uniqueness: true
validates :repo_owner, presence: true
validates :repo_name, presence: true
validates :platform, presence: true,
inclusion: { in: WarpEngine::PlatformLink::SUPPORTED_PLATFORMS + [ UNKNOWN_PLATFORM ] }
scope :active, -> { where(active: true) }
def full_name
"#{repo_owner}/#{repo_name}"
end
def self.ransackable_attributes(auth_object = nil)
%w[active created_at deleted_at id last_pipeline_at last_pipeline_status
platform repo_name repo_owner software_id woodpecker_repo_id]
end
def self.ransackable_associations(auth_object = nil)
%w[software]
end
ActiveSupport.run_load_hooks(:warp_engine_pipeline, self)
end
end
@@ -1,7 +1,7 @@
module WarpEngine
class ReleaseAsset < ApplicationRecord
KINDS = %w[cartridge source html docs
win_x86 win_x64 linux_x86 linux_x64
win_x86 win_x64 linux_x86 linux_x64 linux_arm64
mac_x64 mac_arm64 mac_universal].freeze
belongs_to :release
@@ -2,11 +2,16 @@ module WarpEngine
class Software < ApplicationRecord
self.table_name = "softwares"
# Owner of the publishing token (e.g. AdminUser) — for 3rd-party isolation,
# see enforce_software_ownership. nil = internal / pre-backfill software.
belongs_to :owner, polymorphic: true, optional: true
has_many :software_images, foreign_key: :software_id, dependent: :destroy
has_many :images, through: :software_images
has_many :releases, foreign_key: :software_id
has_many :downloads, through: :releases
has_many :external_links, foreign_key: :software_id
has_one :pipeline, foreign_key: :software_id
accepts_nested_attributes_for :software_images, allow_destroy: true
accepts_nested_attributes_for :external_links, allow_destroy: true
@@ -19,7 +24,7 @@ module WarpEngine
default_scope { where(deleted_at: nil) }
def self.ransackable_attributes(auth_object = nil)
%w[author created_at desc highlighted id license name platform site status story title updated_at]
%w[author created_at desc highlighted id license name owner_id owner_type platform site status story title updated_at]
end
def self.ransackable_associations(auth_object = nil)
@@ -14,6 +14,8 @@ module WarpEngine
field(:license) { |sw| sw.license.to_s }
field :platform
field :status
# Public owner id — for the /api/software?owner_id= filter.
field(:ownerId) { |sw| sw.owner_id }
field(:highlighted) { |sw| sw.highlighted ? true : false }
field(:externalLinks) { |sw| ExternalLinkSerializer.render_as_hash(sw.external_links) }
field(:platformLinks) { |sw| PlatformLinkSerializer.render_as_hash(WarpEngine::PlatformLink.for_platform(sw.platform)) }
@@ -2,7 +2,7 @@ require "zip"
require "fileutils"
module WarpEngine
module SoftwareUpdater
module Platforms
module ArchiveExtraction
private
@@ -1,5 +1,5 @@
module WarpEngine
module SoftwareUpdater
module Platforms
module Builds
module BuildCartridge
extend ActiveSupport::Concern
@@ -1,5 +1,5 @@
module WarpEngine
module SoftwareUpdater
module Platforms
module Builds
module BuildDocs
extend ActiveSupport::Concern
@@ -0,0 +1,28 @@
module WarpEngine
module Platforms
module Builds
# Linux on 64-bit ARM: Raspberry Pi 4/5, Odroid, and the retro handhelds.
# Batocera and the ES-family distributions run on these as much as on
# x86_64, and an x64 binary installs there but will not start — which is
# why this is a separate kind rather than something linux_x64 can cover.
#
# Include this in a platform service only once its pipeline actually
# produces the artifact: registering the kind makes /api/builds report it
# as missing for every release until then.
module BuildLinuxArm64
extend ActiveSupport::Concern
included do
register_expected_kind "linux_arm64"
end
private
def linux_arm64_asset_path(versioned)
path = full_path("#{versioned}-linux-arm64.zip")
{ "linux_arm64" => path } if File.file?(path)
end
end
end
end
end
@@ -1,5 +1,5 @@
module WarpEngine
module SoftwareUpdater
module Platforms
module Builds
module BuildLinuxX64
extend ActiveSupport::Concern
@@ -1,5 +1,5 @@
module WarpEngine
module SoftwareUpdater
module Platforms
module Builds
module BuildMacArm64
extend ActiveSupport::Concern
@@ -1,5 +1,5 @@
module WarpEngine
module SoftwareUpdater
module Platforms
module Builds
module BuildMacUniversal
extend ActiveSupport::Concern
@@ -1,5 +1,5 @@
module WarpEngine
module SoftwareUpdater
module Platforms
module Builds
module BuildMacX64
extend ActiveSupport::Concern
@@ -1,5 +1,5 @@
module WarpEngine
module SoftwareUpdater
module Platforms
module Builds
module BuildSource
extend ActiveSupport::Concern
@@ -1,5 +1,5 @@
module WarpEngine
module SoftwareUpdater
module Platforms
module Builds
module BuildWeb
extend ActiveSupport::Concern
@@ -1,5 +1,5 @@
module WarpEngine
module SoftwareUpdater
module Platforms
module Builds
module BuildWinX64
extend ActiveSupport::Concern
@@ -1,5 +1,5 @@
module WarpEngine
module SoftwareUpdater
module Platforms
module Builds
module BuildWinX86
extend ActiveSupport::Concern
@@ -1,7 +1,7 @@
require "json"
module WarpEngine
module SoftwareUpdater
module Platforms
module MetadataParsing
METADATA_KEYS = %i[name title author desc site repo license].freeze
@@ -1,5 +1,5 @@
module WarpEngine
module SoftwareUpdater
module Platforms
module SoftwarePersistence
private
@@ -1,5 +1,5 @@
module WarpEngine
module SoftwareUpdater
module Platforms
module Updatable
extend ActiveSupport::Concern
include ArchiveExtraction
@@ -9,7 +9,7 @@ module WarpEngine
class_methods do
def platform(value = nil)
@platform = value if value
@platform ||= name.demodulize.delete_suffix("Service").downcase
@platform ||= name.deconstantize.demodulize.downcase
end
def label(value = nil)
@@ -2,7 +2,7 @@ module WarpEngine
class BuildsService
def index
platforms = WarpEngine::PlatformLink::SUPPORTED_PLATFORMS.each_with_object({}) do |platform, hash|
service_class = "WarpEngine::SoftwareUpdater::#{platform.camelize}Service".constantize
service_class = "WarpEngine::Platforms::#{platform.camelize}::Service".constantize
hash[platform] = {
label: service_class.label,
kinds: service_class.expected_kinds
@@ -16,7 +16,7 @@ module WarpEngine
def show(name)
software = WarpEngine::Software.find_by!(name: name)
service_class = "WarpEngine::SoftwareUpdater::#{software.platform.camelize}Service".constantize
service_class = "WarpEngine::Platforms::#{software.platform.camelize}::Service".constantize
expected = service_class.expected_kinds
releases = software.releases.includes(:release_assets).order(updated_at: :desc)
@@ -0,0 +1,40 @@
require "erb"
module WarpEngine
# Renders the /build/config platform templates: the pipeline logic lives in
# app/services/warp_engine/platforms/<platform>/pipeline.yaml.erb, the
# per-platform builder images come from WarpEngine.config.ci_platforms.
class CiConfigService
PLATFORM_FORMAT = /\A[a-z0-9_-]+\z/
# The rendered pipeline YAML, or nil when the platform is not served.
def render(platform:, name:, update_server:)
platform = platform.to_s
return nil unless platform.match?(PLATFORM_FORMAT)
spec = platform_spec(platform)
return nil if spec.nil?
path = templates_dir.join(platform, "pipeline.yaml.erb")
return nil unless path.exist?
ERB.new(path.read, trim_mode: "-").result_with_hash(
name: name.to_s,
update_server: update_server.to_s,
builder: spec[:builder],
exporter: spec[:exporter]
)
end
private
def platform_spec(platform)
spec = WarpEngine.config.ci_platforms.stringify_keys[platform]
spec&.symbolize_keys
end
def templates_dir
WarpEngine::Engine.root.join("app", "services", "warp_engine", "platforms")
end
end
end
@@ -0,0 +1,154 @@
require "openssl"
require "base64"
require "net/http"
require "digest"
module WarpEngine
# Verifies the signature of Woodpecker configuration-extension requests.
# Woodpecker 3.x signs with RFC 9421 HTTP message signatures (ed25519, via
# yaronf/httpsign): Signature-Input + Signature + Content-Digest headers,
# covered components "@request-target" and "content-digest". Older versions
# used draft-cavage http-signatures (a single Signature header) — kept as a
# fallback.
class CiSignatureVerifier
CAVAGE_PARAM = /(\w+)="([^"]*)"/
@key_cache = {}
@key_mutex = Mutex.new
class << self
# The downloaded key is cached process-wide (per URL).
def fetch_public_key(url)
@key_mutex.synchronize do
@key_cache[url] ||= Net::HTTP.get(URI.parse(url))
end
end
def reset_key_cache!
@key_mutex.synchronize { @key_cache = {} }
end
end
def initialize(request)
@request = request
end
def valid?
pem = public_key_pem
if pem.blank?
Rails.logger.error("[CiSignatureVerifier] no ci_extension_public_key(_url) configured — rejecting request")
return false
end
key = OpenSSL::PKey.read(pem)
if @request.headers["Signature-Input"].present?
rfc9421_valid?(key)
else
cavage_valid?(key)
end
rescue OpenSSL::PKey::PKeyError, ArgumentError => e
Rails.logger.error("[CiSignatureVerifier] #{e.class}: #{e.message}")
false
end
private
def public_key_pem
config = WarpEngine.config
return config.ci_extension_public_key if config.ci_extension_public_key.present?
return nil if config.ci_extension_public_key_url.blank?
self.class.fetch_public_key(config.ci_extension_public_key_url)
rescue StandardError => e
Rails.logger.error("[CiSignatureVerifier] public key fetch failed: #{e.class}: #{e.message}")
nil
end
# --- RFC 9421 ---
def rfc9421_valid?(key)
input = @request.headers["Signature-Input"].to_s
match = input.match(/\A\s*([\w.-]+)=(\(.*)\z/m)
return false if match.nil?
label, inner = match[1], match[2]
components = inner[/\((.*?)\)/m, 1].to_s.scan(/"([^"]*)"/).flatten
return false if components.empty?
signature = @request.headers["Signature"].to_s[/#{Regexp.escape(label)}=:([A-Za-z0-9+\/=]+):/, 1]
return false if signature.blank?
return false unless content_digest_valid?(components)
lines = components.map do |component|
value = component_value(component)
return false if value.nil?
%("#{component}": #{value})
end
lines << %("@signature-params": #{inner})
key.verify(nil, Base64.decode64(signature), lines.join("\n"))
end
def component_value(name)
case name
when "@request-target" then @request.fullpath
when "@method" then @request.request_method
when "@target-uri" then @request.original_url
when "@authority" then @request.host_with_port
when "@path" then @request.path
when "@query" then "?#{@request.query_string}"
when /\A@/ then nil
else @request.headers[name]
end
end
# When content-digest is a covered component, the body itself must match
# the digest header — this is what ties the signature to the payload.
def content_digest_valid?(components)
return true unless components.include?("content-digest")
digest = @request.headers["Content-Digest"].to_s[/sha-256=:([A-Za-z0-9+\/=]+):/, 1]
return false if digest.blank?
expected = Digest::SHA256.base64digest(@request.raw_post)
ActiveSupport::SecurityUtils.secure_compare(digest, expected)
end
# --- draft-cavage fallback ---
def cavage_valid?(key)
params = cavage_params
return false if params.nil? || params["signature"].blank?
signing_string = cavage_signing_string(params.fetch("headers", "date"))
return false if signing_string.nil?
key.verify(nil, Base64.decode64(params["signature"]), signing_string)
end
# Parameters of the Signature header (or the "Authorization: Signature ..." form).
def cavage_params
header = @request.headers["Signature"].presence
if header.nil?
auth = @request.headers["Authorization"].to_s
header = auth.delete_prefix("Signature ") if auth.start_with?("Signature ")
end
return nil if header.blank?
header.scan(CAVAGE_PARAM).to_h
end
def cavage_signing_string(headers_list)
lines = headers_list.split(" ").map do |name|
if name == "(request-target)"
"(request-target): #{@request.request_method.downcase} #{@request.fullpath}"
else
value = @request.headers[name]
return nil if value.nil?
"#{name.downcase}: #{value}"
end
end
lines.join("\n")
end
end
end
@@ -9,26 +9,45 @@ module WarpEngine
Pathname.new(container_base).realpath
end
def create(path:, ip:, user_agent:, referer:)
sanitized = path.to_s
base_path = self.class.base_path
full_path = base_path.join(sanitized).realpath
return nil unless full_path.to_s.start_with?(base_path.to_s)
return nil unless File.file?(full_path)
# A letöltés helyét adja vissza (fájl vagy aláírt URL) és naplózza a
# letöltést. A hely feloldása a storage adapteren megy — alapból :local,
# tehát változatlanul lemezről.
def locate(path:, ip:, user_agent:, referer:)
relative = path.to_s
return nil unless storage.file?(relative)
escaped = sanitized.gsub("%", "\\%").gsub("_", "\\_")
asset = WarpEngine::ReleaseAsset.find_by(path: File.join(self.class.container_base, sanitized)) ||
log_download(relative, ip: ip, user_agent: user_agent, referer: referer)
storage.locate(relative, filename: File.basename(relative))
end
# Visszafelé kompatibilis felület: az abszolút fájlútvonalat adja vissza
# (vagy nil-t). Nem lemezes adapternél nincs útvonal — ott a #locate való.
def create(path:, ip:, user_agent:, referer:)
location = locate(path: path, ip: ip, user_agent: user_agent, referer: referer)
return nil if location.nil?
location.file? ? location.path : nil
end
private
def storage
WarpEngine.storage
end
def log_download(relative, ip:, user_agent:, referer:)
escaped = relative.gsub("%", "\\%").gsub("_", "\\_")
asset = WarpEngine::ReleaseAsset.find_by(path: File.join(self.class.container_base, relative)) ||
WarpEngine::ReleaseAsset.where("path LIKE ?", "%#{escaped}%").first
WarpEngine::Download.create!(
file_path: sanitized,
file_path: relative,
release: asset&.release,
ip_address: ip,
user_agent: user_agent&.truncate(500),
referer: referer&.truncate(500)
)
full_path
end
end
end
@@ -22,10 +22,9 @@ module WarpEngine
end
end
MAX_UPLOAD_SIZE = 100 * 1024 * 1024 # 100MB
def upload(relative_dir, uploaded_file)
raise ArgumentError, "File too large (max 100MB)" if uploaded_file.size > MAX_UPLOAD_SIZE
max = WarpEngine.config.max_upload_size
raise ArgumentError, "File too large (max #{max / (1024 * 1024)}MB)" if uploaded_file.size > max
dir = safe_path!(relative_dir)
raise ArgumentError, "Not a directory" unless dir.directory?
@@ -5,31 +5,32 @@ module WarpEngine
Pathname.new(WarpEngine.config.file_container_path).realpath
end
# A fájlok helyét a storage adapter adja (alapból :local, azaz a lemez) —
# így a host az objektumtárból is kiszolgálhat anélkül, hogy az engine-t
# patchelné. Lásd WarpEngine::Storage.
def show(input)
full_path = base_path.join(input.path.to_s)
return FileResultDto.not_found unless safe_path?(full_path)
relative = input.path.to_s
if File.directory?(full_path)
index_path = full_path.join("index.html")
return FileResultDto.not_found unless File.file?(index_path)
return FileResultDto.redirect("/file/#{input.path.to_s.chomp("/")}/index.html")
if storage.directory?(relative)
index = File.join(relative.chomp("/"), "index.html")
return FileResultDto.not_found unless storage.file?(index)
return FileResultDto.redirect("/file/#{index}")
end
if File.file?(full_path)
FileResultDto.file(full_path)
else
FileResultDto.not_found
end
return FileResultDto.not_found unless storage.file?(relative)
to_result(storage.locate(relative, filename: File.basename(relative)))
end
private
def base_path
@base_path ||= self.class.base_path
def storage
WarpEngine.storage
end
def safe_path?(path)
File.exist?(path) && Pathname.new(path).realpath.to_s.start_with?(base_path.to_s)
def to_result(location)
location.redirect? ? FileResultDto.redirect(location.url) : FileResultDto.file(location.path)
end
end
end
@@ -0,0 +1,35 @@
module WarpEngine
class PipelineService
def initialize(client: WoodpeckerClient.new)
@client = client
end
def trigger(pipeline, branch: "main")
@client.trigger_pipeline(pipeline.woodpecker_repo_id, branch: branch)
end
# Fetches one page of a pipeline's runs; the newest one refreshes the
# cached last_pipeline_* columns (Pipelines index and the CI API).
def list_pipelines(pipeline, page: 1)
runs = @client.list_pipelines(pipeline.woodpecker_repo_id, page: page)
refresh_last_pipeline(pipeline, runs.first) if page == 1 && runs.is_a?(Array)
runs
end
def pipeline_detail(pipeline, number)
@client.get_pipeline(pipeline.woodpecker_repo_id, number)
end
private
# Woodpecker returns unix epoch seconds in "created".
def refresh_last_pipeline(pipeline, run)
return unless run && pipeline.persisted?
pipeline.update_columns(
last_pipeline_status: run["status"],
last_pipeline_at: run["created"] ? Time.zone.at(run["created"]) : nil
)
end
end
end
@@ -0,0 +1,73 @@
module WarpEngine
class PipelineSyncService
def initialize(client: WoodpeckerClient.new)
@client = client
end
def sync_all
remote_repos = @client.list_repos
results = { created: [], updated: [], deactivated: [] }
remote_ids = remote_repos.map { |r| r["id"] }
remote_repos.each do |remote|
record = Pipeline.unscoped.find_or_initialize_by(
woodpecker_repo_id: remote["id"]
)
was_new = record.new_record?
record.assign_attributes(
repo_name: remote["name"],
repo_owner: remote["owner"],
active: remote["active"],
deleted_at: nil
)
if record.platform.blank? || record.platform == Pipeline::UNKNOWN_PLATFORM
sw = Software.find_by(name: remote["name"])
record.platform = sw&.platform || Pipeline::UNKNOWN_PLATFORM
record.software = sw if sw
end
next unless record.save
results[was_new ? :created : :updated] << record
end
Pipeline.where.not(woodpecker_repo_id: remote_ids).find_each do |orphan|
orphan.update!(active: false) if orphan.active?
results[:deactivated] << orphan
end
results
end
def activate(repo_id)
@client.activate_repo(repo_id)
sync_single(repo_id)
end
def deactivate(repo_id)
@client.deactivate_repo(repo_id)
record = Pipeline.find_by!(woodpecker_repo_id: repo_id)
record.update!(active: false)
end
private
def sync_single(repo_id)
remote = @client.get_repo(repo_id)
record = Pipeline.unscoped.find_or_initialize_by(woodpecker_repo_id: repo_id)
record.assign_attributes(
repo_name: remote["name"], repo_owner: remote["owner"],
active: remote["active"], deleted_at: nil
)
if record.platform.blank?
sw = Software.find_by(name: remote["name"])
record.platform = sw&.platform || Pipeline::UNKNOWN_PLATFORM
record.software = sw if sw
end
record.save!
record
end
end
end
@@ -0,0 +1,112 @@
# Generated pipeline — WarpEngine /build/config (platform: bevy, name: <%= name %>)
steps:
- name: version
image: alpine
commands:
- apk add --no-cache git jq
- |
if [ -f metadata.json ]; then
VERSION=$(jq -r '.version' metadata.json)
else
VERSION=$(git rev-parse --short HEAD)
fi
BRANCH=$(git rev-parse --abbrev-ref HEAD)
if [ "$BRANCH" != "main" ] && [ "$BRANCH" != "master" ]; then
VERSION="dev-$VERSION-$BRANCH"
fi
echo "VERSION is: $VERSION"
echo $VERSION > .version
- name: build
image: <%= builder %>
pull: true
commands:
- |
VERSION=$(cat .version)
mkdir -p dist
cargo build --release --target wasm32-unknown-unknown
wasm-bindgen --target web --no-typescript \
--out-dir dist --out-name game target/wasm32-unknown-unknown/release/<%= name %>.wasm
echo "==> Downloading index.html"
curl -sSL https://git.teletypegames.org/tools/bevy-tools/raw/branch/master/web/index.html -o dist/index.html
echo "==> Packaging HTML/WASM for $VERSION"
zip -r "<%= name %>-$VERSION.html.zip" -j dist/game_bg.wasm dist/game.js dist/index.html
echo "==> Cleaning temporary files"
rm -f dist/game_bg.wasm dist/game.js dist/index.html
- name: binaries
image: <%= builder %>
pull: true
commands:
- |
VERSION=$(cat .version)
# Native binaries. linux-x64: glibc build in the debian-based builder
# image; win-x64: mingw-w64 cross-compile (x86_64-pc-windows-gnu).
# Mac needs osxcross, it is not built here.
# The zip gets the assets/ dir too if the project has one — bevy loads
# it at runtime, it is not embedded in the binary.
set -e
pack_binary() {
P_SLUG="$1"; P_BIN="$2"; P_NAME="$3"
PKG_DIR="<%= name %>-$VERSION-$P_SLUG"
rm -rf "$PKG_DIR" "$PKG_DIR.zip"
mkdir -p "$PKG_DIR"
cp "$P_BIN" "$PKG_DIR/$P_NAME"
chmod +x "$PKG_DIR/$P_NAME"
if [ -d assets ]; then cp -r assets "$PKG_DIR/assets"; fi
zip -qr "$PKG_DIR.zip" "$PKG_DIR"
rm -rf "$PKG_DIR"
echo "==> $PKG_DIR.zip kesz"
}
echo "==> Building linux-x64 binary"
cargo build --release
pack_binary "linux-x64" "target/release/<%= name %>" "<%= name %>"
echo "==> Building win-x64 binary"
CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER=x86_64-w64-mingw32-gcc \
cargo build --release --target x86_64-pc-windows-gnu
pack_binary "win-x64" "target/x86_64-pc-windows-gnu/release/<%= name %>.exe" "<%= name %>.exe"
# linux-arm64: Raspberry Pi, Odroid, retro handhelds. pkg-config has to
# be told it may cross, and pointed at the arm64 .pc files, otherwise
# alsa-sys/libudev-sys pick up the host x86_64 libraries.
echo "==> Building linux-arm64 binary"
PKG_CONFIG_ALLOW_CROSS=1 \
PKG_CONFIG_PATH=/usr/lib/aarch64-linux-gnu/pkgconfig \
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc \
cargo build --release --target aarch64-unknown-linux-gnu
pack_binary "linux-arm64" "target/aarch64-unknown-linux-gnu/release/<%= name %>" "<%= name %>"
- name: upload
image: alpine
environment:
UPDATE_SERVER: <%= update_server %>
UPDATE_SECRET:
from_secret: application_token
commands:
- apk add --no-cache curl
- |
VERSION=$(cat .version)
FILE="<%= name %>-$VERSION.html.zip"
META_SRC="metadata.json"
META_DST="<%= name %>-$VERSION.metadata.json"
cp $META_SRC $META_DST
BINS=""
for slug in win-x64 linux-x64 linux-arm64; do
[ -f "<%= name %>-$VERSION-$slug.zip" ] && BINS="$BINS <%= name %>-$VERSION-$slug.zip"
done
for f in $FILE $META_DST $BINS; do
curl -fsS -H "X-Update-Secret: $UPDATE_SECRET" \
-F "file=@$f" \
"$UPDATE_SERVER/build/upload?name=<%= name %>&version=$VERSION" || exit 1
done
- name: publish
image: alpine
environment:
UPDATE_SERVER: <%= update_server %>
UPDATE_SECRET:
from_secret: application_token
commands:
- apk add --no-cache curl
- |
VERSION=$(cat .version)
curl -fsS -X POST -H "X-Update-Secret: $UPDATE_SECRET" "$UPDATE_SERVER/build/publish?name=<%= name %>&platform=bevy&version=$VERSION"
@@ -0,0 +1,15 @@
module WarpEngine
module Platforms
module Bevy
class Service
include Updatable
include Builds::BuildWeb
include Builds::BuildWinX64
include Builds::BuildLinuxX64
include Builds::BuildLinuxArm64
label "Bevy"
end
end
end
end
@@ -0,0 +1,59 @@
# Generated pipeline — WarpEngine /build/config (platform: c64, name: <%= name %>)
steps:
- name: version
image: alpine
commands:
- |
VERSION=$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' metadata.json | head -n 1)
if [ -z "$VERSION" ]; then
echo "ERROR: no \"version\" field in metadata.json!"
exit 1
fi
BRANCH=${CI_COMMIT_BRANCH:-${WOODPECKER_BRANCH}}
BRANCH=$(echo "$BRANCH" | tr '/' '-')
if [ "$BRANCH" != "main" ] && [ "$BRANCH" != "master" ] && [ -n "$BRANCH" ]; then
VERSION="dev-$VERSION-$BRANCH"
fi
echo "VERSION is: $VERSION"
echo $VERSION > .version
- name: build
image: <%= builder %>
commands:
- |
VERSION=$(cat .version)
acme -f cbm -o <%= name %>.prg main.asm
echo "==> Creating versioned files for $VERSION"
cp <%= name %>.prg <%= name %>-$VERSION.prg
cp metadata.json <%= name %>-$VERSION.metadata.json
ls -lh <%= name %>-$VERSION.*
- name: artifact
image: alpine
environment:
UPDATE_SERVER: <%= update_server %>
UPDATE_SECRET:
from_secret: application_token
commands:
- apk add --no-cache curl
- |
VERSION=$(cat .version)
echo "==> Uploading artifacts for version $VERSION"
for f in <%= name %>-$VERSION.prg <%= name %>-$VERSION.metadata.json; do
curl -fsS -H "X-Update-Secret: $UPDATE_SECRET" \
-F "file=@$f" \
"$UPDATE_SERVER/build/upload?name=<%= name %>&version=$VERSION" || exit 1
done
- name: publish
image: alpine
environment:
UPDATE_SERVER: <%= update_server %>
UPDATE_SECRET:
from_secret: application_token
commands:
- apk add --no-cache curl
- |
VERSION=$(cat .version)
echo "==> Publishing version $VERSION"
curl -fsS -X POST -H "X-Update-Secret: $UPDATE_SECRET" "$UPDATE_SERVER/build/publish?name=<%= name %>&platform=c64&version=$VERSION"
@@ -0,0 +1,14 @@
module WarpEngine
module Platforms
module C64
class Service
include Updatable
include Builds::BuildCartridge
label "C64"
def cartridge_ext = ".prg"
end
end
end
end
@@ -0,0 +1,101 @@
# Generated pipeline — WarpEngine /build/config (platform: ebitengine, name: <%= name %>)
steps:
- name: version
image: alpine
commands:
- apk add --no-cache git jq
- |
if [ -f metadata.json ]; then
VERSION=$(jq -r '.version' metadata.json)
else
VERSION=$(git rev-parse --short HEAD)
fi
BRANCH=$(git rev-parse --abbrev-ref HEAD)
if [ "$BRANCH" != "main" ] && [ "$BRANCH" != "master" ]; then
VERSION="dev-$VERSION-$BRANCH"
fi
echo "VERSION is: $VERSION"
echo $VERSION > .version
- name: build
image: <%= builder %>
pull: true
commands:
- |
VERSION=$(cat .version)
mkdir -p dist
GOOS=js GOARCH=wasm go build -o dist/game.wasm .
cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" dist/wasm_exec.js
echo "==> Downloading index.html"
curl -sSL https://git.teletypegames.org/tools/ebitengine-tools/raw/branch/master/web/index.html -o dist/index.html
echo "==> Packaging HTML/WASM for $VERSION"
zip -r "<%= name %>-$VERSION.html.zip" -j dist/game.wasm dist/wasm_exec.js dist/index.html
echo "==> Cleaning temporary files"
rm -f dist/game.wasm dist/wasm_exec.js dist/index.html
- name: binaries
image: <%= builder %>
pull: true
commands:
- |
VERSION=$(cat .version)
# win-x86 / win-x64: pure Go cross-compile (Windowson nem kell cgo)
# linux-x64: cgo build, linux/amd64 hoston fut (builder image, X11/GL dev libekkel)
# helper: builds one target + zips it with a single root folder
# (unix zip keeps the executable bit)
binary_build() {
B_GOOS="$1"; B_GOARCH="$2"; B_CGO="$3"; B_EXT="$4"; B_TARGET="$5"; B_CC="$6"
PKG_DIR="<%= name %>-$VERSION-$B_TARGET"
echo "==> Building $PKG_DIR"
rm -rf "$PKG_DIR" "$PKG_DIR.zip"
mkdir -p "$PKG_DIR"
# cgo cross-compile needs an explicit cross gcc; a native build must
# not see CC at all, otherwise go picks the wrong compiler
if [ -n "$B_CC" ]; then export CC="$B_CC"; else unset CC; fi
CGO_ENABLED=$B_CGO GOOS=$B_GOOS GOARCH=$B_GOARCH go build -o "$PKG_DIR/<%= name %>$B_EXT" .
if [ -f LICENSE ]; then cp LICENSE "$PKG_DIR/"; fi
if [ -f README.md ]; then cp README.md "$PKG_DIR/"; fi
zip -r "$PKG_DIR.zip" "$PKG_DIR" >/dev/null
rm -rf "$PKG_DIR"
echo "==> $PKG_DIR.zip kesz"
}
# CI (linux builder) builds these four:
binary_build "windows" "386" "0" ".exe" "win-x86"
binary_build "windows" "amd64" "0" ".exe" "win-x64"
binary_build "linux" "amd64" "1" "" "linux-x64"
# linux-arm64: Raspberry Pi, Odroid, retro handhelds — cgo cross-build
# against the arm64 X11/GL/ALSA headers in the builder image
binary_build "linux" "arm64" "1" "" "linux-arm64" "aarch64-linux-gnu-gcc"
- name: artifact
image: alpine
environment:
UPDATE_SERVER: <%= update_server %>
UPDATE_SECRET:
from_secret: application_token
commands:
- apk add --no-cache curl
- |
VERSION=$(cat .version)
FILE="<%= name %>-$VERSION.html.zip"
META_SRC="metadata.json"
META_DST="<%= name %>-$VERSION.metadata.json"
BINS=$(ls <%= name %>-$VERSION-*.zip 2>/dev/null || true)
cp $META_SRC $META_DST
for f in $FILE $META_DST $BINS; do
curl -fsS -H "X-Update-Secret: $UPDATE_SECRET" \
-F "file=@$f" \
"$UPDATE_SERVER/build/upload?name=<%= name %>&version=$VERSION" || exit 1
done
- name: publish
image: alpine
environment:
UPDATE_SERVER: <%= update_server %>
UPDATE_SECRET:
from_secret: application_token
commands:
- apk add --no-cache curl
- |
VERSION=$(cat .version)
curl -fsS -X POST -H "X-Update-Secret: $UPDATE_SECRET" "$UPDATE_SERVER/build/publish?name=<%= name %>&platform=ebitengine&version=$VERSION"
@@ -0,0 +1,18 @@
module WarpEngine
module Platforms
module Ebitengine
class Service
include Updatable
include Builds::BuildWeb
include Builds::BuildWinX86
include Builds::BuildWinX64
include Builds::BuildLinuxX64
include Builds::BuildLinuxArm64
include Builds::BuildMacX64
include Builds::BuildMacArm64
label "Ebitengine"
end
end
end
end
@@ -0,0 +1,100 @@
# Generated pipeline — WarpEngine /build/config (platform: godot, name: <%= name %>)
steps:
- name: version
image: alpine
commands:
- apk add --no-cache git jq
- |
if [ -f metadata.json ]; then
VERSION=$(jq -r '.version' metadata.json)
else
VERSION=$(git rev-parse --short HEAD)
fi
BRANCH=$(git rev-parse --abbrev-ref HEAD)
if [ "$BRANCH" != "main" ] && [ "$BRANCH" != "master" ]; then
VERSION="dev-$VERSION-$BRANCH"
fi
echo "VERSION is: $VERSION"
echo $VERSION > .version
- name: build
image: <%= builder %>
commands:
- |
VERSION=$(cat .version)
echo "==> Importing project"
godot --headless --import
echo "==> Exporting web build (Web preset)"
mkdir -p dist/web
godot --headless --export-release "Web" dist/web/index.html
echo "==> Packaging web build for $VERSION"
(cd dist/web && zip -r "../../<%= name %>-$VERSION.html.zip" .)
rm -rf dist/web
- |
VERSION=$(cat .version)
# exports a win/linux target + zips it with a single root folder
# (embed_pck makes the export a single executable)
binary_build() {
B_PRESET="$1"; B_EXT="$2"; B_TARGET="$3"
PKG_DIR="<%= name %>-$VERSION-$B_TARGET"
echo "==> Building $PKG_DIR"
rm -rf "$PKG_DIR" "$PKG_DIR.zip"
mkdir -p "$PKG_DIR"
godot --headless --export-release "$B_PRESET" "$(pwd)/$PKG_DIR/<%= name %>$B_EXT"
if [ -f LICENSE ]; then cp LICENSE "$PKG_DIR/"; fi
if [ -f README.md ]; then cp README.md "$PKG_DIR/"; fi
zip -r "$PKG_DIR.zip" "$PKG_DIR" >/dev/null
rm -rf "$PKG_DIR"
echo "==> $PKG_DIR.zip kesz"
}
# mac: from linux Godot can only export macOS into a .zip (holding the
# .app); repackage it to the root-folder convention (zip -ry keeps
# exec bits and symlinks)
binary_build_mac() {
B_PRESET="$1"; B_TARGET="$2"
PKG_DIR="<%= name %>-$VERSION-$B_TARGET"
echo "==> Building $PKG_DIR"
rm -rf "$PKG_DIR" "$PKG_DIR.zip"
mkdir -p "$PKG_DIR"
godot --headless --export-release "$B_PRESET" "$(pwd)/$PKG_DIR/<%= name %>-mac-tmp.zip"
(cd "$PKG_DIR" && unzip -q "<%= name %>-mac-tmp.zip" && rm "<%= name %>-mac-tmp.zip")
if [ -f LICENSE ]; then cp LICENSE "$PKG_DIR/"; fi
if [ -f README.md ]; then cp README.md "$PKG_DIR/"; fi
zip -ry "$PKG_DIR.zip" "$PKG_DIR" >/dev/null
rm -rf "$PKG_DIR"
echo "==> $PKG_DIR.zip kesz"
}
binary_build "Windows x86" ".exe" "win-x86"
binary_build "Windows x64" ".exe" "win-x64"
binary_build "Linux x64" "" "linux-x64"
binary_build_mac "Mac universal" "mac-universal"
- name: upload
image: alpine
environment:
UPDATE_SERVER: <%= update_server %>
UPDATE_SECRET:
from_secret: application_token
commands:
- apk add --no-cache curl
- |
VERSION=$(cat .version)
cp metadata.json "<%= name %>-$VERSION.metadata.json"
BINS=$(ls <%= name %>-$VERSION-*.zip 2>/dev/null || true)
for f in "<%= name %>-$VERSION.html.zip" "<%= name %>-$VERSION.metadata.json" $BINS; do
curl -fsS -H "X-Update-Secret: $UPDATE_SECRET" \
-F "file=@$f" \
"$UPDATE_SERVER/build/upload?name=<%= name %>&version=$VERSION" || exit 1
done
- name: publish
image: alpine
environment:
UPDATE_SERVER: <%= update_server %>
UPDATE_SECRET:
from_secret: application_token
commands:
- apk add --no-cache curl
- |
VERSION=$(cat .version)
curl -fsS -X POST -H "X-Update-Secret: $UPDATE_SECRET" "$UPDATE_SERVER/build/publish?name=<%= name %>&platform=godot&version=$VERSION"
@@ -0,0 +1,16 @@
module WarpEngine
module Platforms
module Godot
class Service
include Updatable
include Builds::BuildWeb
include Builds::BuildWinX86
include Builds::BuildWinX64
include Builds::BuildLinuxX64
include Builds::BuildMacUniversal
label "Godot"
end
end
end
end
@@ -0,0 +1,174 @@
# Generated pipeline — WarpEngine /build/config (platform: love, name: <%= name %>)
steps:
- name: version
image: alpine
commands:
- apk add --no-cache git jq
- |
if [ -f metadata.json ]; then
VERSION=$(jq -r '.version' metadata.json)
else
VERSION=$(git rev-parse --short HEAD)
fi
BRANCH=$(git rev-parse --abbrev-ref HEAD)
if [ "$BRANCH" != "main" ] && [ "$BRANCH" != "master" ]; then
VERSION="dev-$VERSION-$BRANCH"
fi
echo "VERSION is: $VERSION"
echo $VERSION > .version
- name: export
image: <%= builder %>
pull: true
commands:
- |
VERSION=$(cat .version)
mkdir -p dist
echo "==> Building .love package"
zip -r dist/<%= name %>.love . \
--exclude "*.git*" \
--exclude "bin/*" \
--exclude "dist/*" \
--exclude "Makefile" \
--exclude ".version" \
--exclude "metadata.json" \
--exclude "*.zip"
mkdir -p dist/web
# The love-builder CI image pre-fetches love.js here; local builds
# fall back to GitHub.
if [ -f /opt/lovejs.zip ]; then
echo "==> Using cached love.js (/opt/lovejs.zip)"
cp /opt/lovejs.zip dist/lovejs.zip
else
echo "==> Downloading love.js (2dengine)"
curl -sSL https://github.com/2dengine/love.js/archive/refs/heads/master.zip -o dist/lovejs.zip
fi
unzip -o dist/lovejs.zip -d dist/lovejs-src
rm -f dist/lovejs.zip
echo "==> Assembling web bundle"
cp -r dist/lovejs-src/*/. dist/web/
rm -rf dist/lovejs-src
cp dist/<%= name %>.love dist/web/<%= name %>.love
echo "==> Patching player.js"
sed -i.bak "s|uri = 'nogame\.love'|uri = '<%= name %>.love'|g" dist/web/player.js && rm dist/web/player.js.bak
echo "==> Patching index.html"
sed -i.bak 's|<base href="/play/">|<base href="/file/<%= name %>-'"$VERSION"'/">|g' dist/web/index.html && rm dist/web/index.html.bak
echo "==> Web build ready in dist/web"
echo "==> Packaging Love2D for $VERSION"
zip -r <%= name %>-$VERSION.love.zip dist/<%= name %>.love
echo "==> Packaging web build for $VERSION"
(cd dist/web && zip -r ../../<%= name %>-$VERSION.html.zip .)
echo "==> Cleaning temporary files"
rm -f dist/<%= name %>.love
rm -rf dist/web
- name: binaries
image: <%= builder %>
pull: true
commands:
- |
VERSION=$(cat .version)
# The export step deleted the .love, rebuild it here (in make the
# binary-* targets' love prerequisite did the same).
mkdir -p dist
zip -r dist/<%= name %>.love . \
--exclude "*.git*" \
--exclude "bin/*" \
--exclude "dist/*" \
--exclude "Makefile" \
--exclude ".version" \
--exclude "metadata.json" \
--exclude "*.zip"
# The love-builder CI image pre-fetches the dist files to
# /opt/love-dist; local builds fall back to GitHub.
fetch_love() {
if [ -f "/opt/love-dist/$1" ]; then
echo "==> Using cached $1"
cp "/opt/love-dist/$1" "dist/$1"
elif [ ! -f "dist/$1" ]; then
echo "==> Downloading $1"
curl -sSL "https://github.com/love2d/love/releases/download/11.5/$1" -o "dist/$1"
fi
}
echo "==> Fusing windows binary"
fetch_love love-11.5-win64.zip
PKG_DIR="<%= name %>-$VERSION-win-x64"
rm -rf "$PKG_DIR" "$PKG_DIR.zip" dist/win64
unzip -q dist/love-11.5-win64.zip -d dist/win64
SRC=$(dirname $(find dist/win64 -name love.exe | head -n 1))
mkdir -p "$PKG_DIR"
cat "$SRC/love.exe" dist/<%= name %>.love > "$PKG_DIR/<%= name %>.exe"
cp "$SRC"/*.dll "$PKG_DIR/"
cp "$SRC/license.txt" "$PKG_DIR/" 2>/dev/null || true
zip -qr "$PKG_DIR.zip" "$PKG_DIR"
rm -rf "$PKG_DIR" dist/win64
echo "==> $PKG_DIR.zip kesz"
echo "==> Fusing macOS app bundle"
fetch_love love-11.5-macos.zip
PKG_DIR="<%= name %>-$VERSION-mac-universal"
rm -rf "$PKG_DIR" "$PKG_DIR.zip" dist/macos
unzip -q dist/love-11.5-macos.zip -d dist/macos
mkdir -p "$PKG_DIR"
mv dist/macos/love.app "$PKG_DIR/<%= name %>.app"
cp dist/<%= name %>.love "$PKG_DIR/<%= name %>.app/Contents/Resources/"
PLIST="$PKG_DIR/<%= name %>.app/Contents/Info.plist"
sed -i.bak "s|<string>LÖVE</string>|<string><%= name %></string>|g" "$PLIST" && rm "$PLIST.bak"
sed -i.bak "s|org\.love2d\.love|org.teletypegames.<%= name %>|g" "$PLIST" && rm "$PLIST.bak"
zip -qry "$PKG_DIR.zip" "$PKG_DIR"
rm -rf "$PKG_DIR" dist/macos
echo "==> $PKG_DIR.zip kesz"
# The AppImage runtime is glibc-dynamic and cannot run on alpine
# (musl), so we do not run the runtime: the offset is computed from
# readelf (shoff + shentsize*shnum) and the squashfs is extracted
# with unsquashfs -o.
echo "==> Fusing linux AppImage"
fetch_love love-11.5-x86_64.AppImage
PKG_DIR="<%= name %>-$VERSION-linux-x64"
APPIMAGE="dist/love-11.5-x86_64.AppImage"
rm -rf "$PKG_DIR" "$PKG_DIR.zip" squashfs-root dist/game.squashfs dist/runtime
OFFSET=$(readelf -h "$APPIMAGE" | awk '/Start of section headers/{o=$5} /Size of section headers/{s=$5} /Number of section headers/{n=$5} END{print o+s*n}')
unsquashfs -q -o $OFFSET -d squashfs-root "$APPIMAGE" >/dev/null
cat squashfs-root/bin/love dist/<%= name %>.love > squashfs-root/bin/love.fused
mv squashfs-root/bin/love.fused squashfs-root/bin/love
chmod +x squashfs-root/bin/love
mksquashfs squashfs-root dist/game.squashfs -root-owned -noappend -quiet -comp gzip
head -c $OFFSET "$APPIMAGE" > dist/runtime
mkdir -p "$PKG_DIR"
cat dist/runtime dist/game.squashfs > "$PKG_DIR/<%= name %>.AppImage"
chmod +x "$PKG_DIR/<%= name %>.AppImage"
zip -qr "$PKG_DIR.zip" "$PKG_DIR"
rm -rf "$PKG_DIR" squashfs-root dist/game.squashfs dist/runtime
echo "==> $PKG_DIR.zip kesz"
- name: upload
image: alpine
environment:
UPDATE_SERVER: <%= update_server %>
UPDATE_SECRET:
from_secret: application_token
commands:
- apk add --no-cache curl
- |
VERSION=$(cat .version)
cp metadata.json "<%= name %>-$VERSION.metadata.json"
BINS=""
for slug in win-x64 mac-universal linux-x64; do
[ -f "<%= name %>-$VERSION-$slug.zip" ] && BINS="$BINS <%= name %>-$VERSION-$slug.zip"
done
for f in "<%= name %>-$VERSION.love.zip" "<%= name %>-$VERSION.html.zip" "<%= name %>-$VERSION.metadata.json" $BINS; do
curl -fsS -H "X-Update-Secret: $UPDATE_SECRET" \
-F "file=@$f" \
"$UPDATE_SERVER/build/upload?name=<%= name %>&version=$VERSION" || exit 1
done
- name: publish
image: alpine
environment:
UPDATE_SERVER: <%= update_server %>
UPDATE_SECRET:
from_secret: application_token
commands:
- apk add --no-cache curl
- |
VERSION=$(cat .version)
curl -fsS -X POST -H "X-Update-Secret: $UPDATE_SECRET" "$UPDATE_SERVER/build/publish?name=<%= name %>&platform=love&version=$VERSION"
@@ -0,0 +1,15 @@
module WarpEngine
module Platforms
module Love
class Service
include Updatable
include Builds::BuildWeb
include Builds::BuildWinX64
include Builds::BuildLinuxX64
include Builds::BuildMacUniversal
label "LÖVE"
end
end
end
end
@@ -0,0 +1,69 @@
# Generated pipeline — WarpEngine /build/config (platform: phaser, name: <%= name %>)
steps:
- name: version
image: alpine
commands:
- apk add --no-cache git jq
- |
if [ -f metadata.json ]; then
VERSION=$(jq -r '.version' metadata.json)
else
VERSION=$(git rev-parse --short HEAD)
fi
BRANCH=$(git rev-parse --abbrev-ref HEAD)
if [ "$BRANCH" != "main" ] && [ "$BRANCH" != "master" ]; then
VERSION="dev-$VERSION-$BRANCH"
fi
echo "VERSION is: $VERSION"
echo $VERSION > .version
- name: build
image: <%= builder %>
commands:
- |
VERSION=$(cat .version)
echo "==> Checking JS syntax"
for f in src/*.js; do [ -e "$f" ] || continue; node --check "$f"; done
mkdir -p dist/web
echo "==> Downloading Phaser 3.90.0"
curl -sSL https://cdn.jsdelivr.net/npm/phaser@3.90.0/dist/phaser.min.js -o dist/web/phaser.min.js
echo "==> Downloading index.html"
curl -sSL https://git.teletypegames.org/tools/phaser-tools/raw/branch/master/web/index.html -o dist/web/index.html
echo "==> Bundling game sources"
cat src/*.js > dist/web/game.js
echo "==> Packaging web build for $VERSION"
(cd dist/web && zip -r "../../<%= name %>-$VERSION.html.zip" .)
echo "==> Cleaning temporary files"
rm -rf dist/web
- name: upload
image: alpine
environment:
UPDATE_SERVER: <%= update_server %>
UPDATE_SECRET:
from_secret: application_token
commands:
- apk add --no-cache curl
- |
VERSION=$(cat .version)
FILE="<%= name %>-$VERSION.html.zip"
META_SRC="metadata.json"
META_DST="<%= name %>-$VERSION.metadata.json"
cp $META_SRC $META_DST
for f in $FILE $META_DST; do
curl -fsS -H "X-Update-Secret: $UPDATE_SECRET" \
-F "file=@$f" \
"$UPDATE_SERVER/build/upload?name=<%= name %>&version=$VERSION" || exit 1
done
- name: publish
image: alpine
environment:
UPDATE_SERVER: <%= update_server %>
UPDATE_SECRET:
from_secret: application_token
commands:
- apk add --no-cache curl
- |
VERSION=$(cat .version)
curl -fsS -X POST -H "X-Update-Secret: $UPDATE_SECRET" "$UPDATE_SERVER/build/publish?name=<%= name %>&platform=phaser&version=$VERSION"
@@ -0,0 +1,12 @@
module WarpEngine
module Platforms
module Phaser
class Service
include Updatable
include Builds::BuildWeb
label "Phaser"
end
end
end
end
@@ -0,0 +1,199 @@
# Generated pipeline — WarpEngine /build/config (platform: tic80, name: <%= name %>)
# The version comes from the source (inc/meta/meta.header.lua "-- version:"
# comment) — WarpEngine parses tic80 metadata from the Lua header too, hence
# no metadata.json.
steps:
- name: version
image: alpine
commands:
- |
VERSION=$(sed -n "s/^-- version: //p" inc/meta/meta.header.lua | head -n 1 | tr -d "[:space:]")
BRANCH=${CI_COMMIT_BRANCH:-${WOODPECKER_BRANCH}}
BRANCH=$(echo "$BRANCH" | tr '/' '-')
if [ "$BRANCH" != "main" ] && [ "$BRANCH" != "master" ] && [ -n "$BRANCH" ]; then
VERSION=dev-$VERSION-$BRANCH
fi
echo "VERSION is: $VERSION"
echo $VERSION > .version
- name: lint
image: alpine
commands:
- apk add --no-cache lua5.4 lua5.4-dev luarocks gcc musl-dev
- ln -sf /usr/bin/lua5.4 /usr/bin/lua
- ln -sf /usr/bin/luarocks-5.4 /usr/bin/luarocks
- luarocks install luacheck
- |
echo "==> Merging..."
rm -f /tmp/_lint_combined.lua /tmp/_lint_map.txt
touch /tmp/_lint_combined.lua
line=1
while IFS= read -r f || [ -n "$f" ]; do
f=$(printf '%s' "$f" | tr -d '\r')
[ -z "$f" ] && continue
before=$(wc -l < /tmp/_lint_combined.lua)
cat "inc/$f" >> /tmp/_lint_combined.lua
printf '\n' >> /tmp/_lint_combined.lua
after=$(wc -l < /tmp/_lint_combined.lua)
linecount=$((after - before))
echo "$line $linecount inc/$f" >> /tmp/_lint_map.txt
line=$((line + linecount))
done < <%= name %>.inc
echo "==> luacheck..."
LINT_OUTPUT=$(luacheck --no-max-line-length /tmp/_lint_combined.lua 2>&1 | awk -v map=/tmp/_lint_map.txt '
BEGIN {
NR_map = 0;
while ((getline line < map) > 0) {
n = split(line, a, " ");
start[NR_map] = a[1]+0;
count[NR_map] = a[2]+0;
fname[NR_map] = a[3];
NR_map++;
}
}
/^[^:]+:[0-9]+:[0-9]+:/ {
colon1 = index($0, ":");
rest1 = substr($0, colon1+1);
colon2 = index(rest1, ":");
absline = substr(rest1, 1, colon2-1) + 0;
rest2 = substr(rest1, colon2+1);
colon3 = index(rest2, ":");
col = substr(rest2, 1, colon3-1);
rest = substr(rest2, colon3);
found = 0;
for (i = 0; i < NR_map; i++) {
end_line = start[i] + count[i] -1;
if (absline >= start[i] && absline <= end_line) {
relline = absline - start[i] + 1;
print fname[i] ":" relline ":" col ":" rest;
found = 1;
break;
}
}
if (!found) print $0;
next;
}
{ print }
')
echo "$LINT_OUTPUT"
NUM_ISSUES=$(echo "$LINT_OUTPUT" | grep -cE "^[^:]+:[0-9]+:[0-9]+:" || true)
if [ "$NUM_ISSUES" -gt 0 ]; then
echo "Total: $NUM_ISSUES issue(s) found, commit aborted."
exit 1
else
echo "Checking /tmp/_lint_combined.lua OK"
echo "Total: 0 warnings / 0 errors in 1 file"
fi
rm -f /tmp/_lint_combined.lua /tmp/_lint_map.txt
- name: minify
image: alpine
commands:
- apk add --no-cache lua5.4 curl
- ln -sf /usr/bin/lua5.4 /usr/bin/lua
- |
rm -f <%= name %>.lua
sed 's/\r$//' <%= name %>.inc | while read f; do
cat "inc/$f" >> <%= name %>.lua
echo "" >> <%= name %>.lua
done
test -f minify.lua || { echo "==> Downloading minify.lua"; curl -fsSL https://raw.githubusercontent.com/ztimar31/lua-minify-tic80/refs/heads/master/minify.lua -o minify.lua; }
echo "==> Minifying <%= name %>.lua"
cp <%= name %>.lua <%= name %>.original.lua
lua minify.lua minify <%= name %>.original.lua > <%= name %>.lua
- name: docs
image: alpine
commands:
- apk add --no-cache lua5.4 lua5.4-dev luarocks gcc musl-dev zip
- ln -sf /usr/bin/lua5.4 /usr/bin/lua
- ln -sf /usr/bin/luarocks-5.4 /usr/bin/luarocks
- luarocks install ldoc
- |
VERSION=$(cat .version)
echo "==> Generating docs from <%= name %>.original.lua"
ldoc <%= name %>.original.lua -d docs
echo "==> Zipping docs for version $VERSION"
(cd docs && zip -r ../<%= name %>-$VERSION-docs.zip .)
cp <%= name %>-$VERSION-docs.zip <%= name %>-docs.zip
echo "==> Docs zip created"
- name: export
image: <%= builder %>
environment:
XDG_RUNTIME_DIR: /tmp
commands:
- |
VERSION=$(cat .version)
echo "==> Exporting HTML for version $VERSION"
tic80 --cli --skip --fs=. \
--cmd="load <%= name %>.lua & save <%= name %>-$VERSION & export html <%= name %>-$VERSION.html & exit"
if [ -f "<%= name %>-$VERSION.tic" ]; then
cp <%= name %>-$VERSION.tic <%= name %>.tic
fi
if [ -f "<%= name %>-$VERSION.html.zip" ]; then
cp <%= name %>-$VERSION.html.zip <%= name %>.html.zip
fi
echo "==> Generated files:"
ls -lh <%= name %>-$VERSION.* <%= name %>.tic <%= name %>.html.zip 2>/dev/null || true
- name: binaries
image: <%= builder %>
environment:
XDG_RUNTIME_DIR: /tmp
commands:
- |
VERSION=$(cat .version)
echo "==> Exporting native players for version $VERSION"
tic80 --cli --skip --fs=. \
--cmd="load <%= name %>.lua & export win <%= name %>-win & export linux <%= name %>-linux & export mac <%= name %>-mac & exit"
# unix zip preserves the executable bit
pack_binary() {
SLUG="$1"; SRC_FILE="$2"; DST_FILE="$3"
PKG_DIR="<%= name %>-$VERSION-$SLUG"
rm -rf "$PKG_DIR" "$PKG_DIR.zip"
mkdir -p "$PKG_DIR"
mv "$SRC_FILE" "$PKG_DIR/$DST_FILE"
chmod +x "$PKG_DIR/$DST_FILE"
zip -r "$PKG_DIR.zip" "$PKG_DIR" >/dev/null
rm -rf "$PKG_DIR"
echo "==> $PKG_DIR.zip kesz"
}
pack_binary win-x64 <%= name %>-win.exe <%= name %>.exe
pack_binary linux-x64 <%= name %>-linux <%= name %>
pack_binary mac-x64 <%= name %>-mac <%= name %>
- name: upload
image: alpine
environment:
UPDATE_SERVER: <%= update_server %>
UPDATE_SECRET:
from_secret: application_token
commands:
- apk add --no-cache curl
- |
VERSION=$(cat .version)
echo "==> Uploading artifacts for version $VERSION"
cp <%= name %>.lua <%= name %>-$VERSION.lua
BINS=""
for slug in win-x64 linux-x64 mac-x64; do
[ -f "<%= name %>-$VERSION-$slug.zip" ] && BINS="$BINS <%= name %>-$VERSION-$slug.zip"
done
for f in <%= name %>-$VERSION.lua <%= name %>-$VERSION.tic <%= name %>-$VERSION.html.zip <%= name %>-$VERSION-docs.zip $BINS; do
curl -fsS -H "X-Update-Secret: $UPDATE_SECRET" \
-F "file=@$f" \
"$UPDATE_SERVER/build/upload?name=<%= name %>&version=$VERSION" || exit 1
done
- name: publish
image: alpine
environment:
UPDATE_SERVER: <%= update_server %>
UPDATE_SECRET:
from_secret: application_token
commands:
- apk add --no-cache curl
- |
VERSION=$(cat .version)
echo "==> Publishing version $VERSION"
curl -fsS -X POST -H "X-Update-Secret: $UPDATE_SECRET" "$UPDATE_SERVER/build/publish?name=<%= name %>&platform=tic80&version=$VERSION"
@@ -0,0 +1,40 @@
module WarpEngine
module Platforms
module Tic80
class Service
include Updatable
include Builds::BuildCartridge
include Builds::BuildSource
include Builds::BuildWeb
include Builds::BuildDocs
include Builds::BuildWinX64
include Builds::BuildLinuxX64
include Builds::BuildMacX64
label "TIC-80"
def cartridge_ext = ".tic"
def source_ext = ".lua"
private
def parse_metadata(versioned)
parse_lua_metadata(full_path("#{versioned}.lua"))
end
def parse_lua_metadata(source_path)
metadata = {}
File.foreach(source_path) do |line|
break unless line.start_with?("--")
parts = line[2..].split(":", 2)
next if parts.length != 2
key = parts[0].strip.downcase.to_sym
value = parts[1].strip
metadata[key] = value
end
metadata.slice(*MetadataParsing::METADATA_KEYS)
end
end
end
end
end
@@ -0,0 +1,31 @@
module WarpEngine
class PublishService
# Az esemény neve, amit a host lehallgathat. A payload:
# platform: String, name: String, version: String,
# software: WarpEngine::Software, release: WarpEngine::Release
NOTIFICATION = "warp_engine.publish".freeze
def publish(input)
unless WarpEngine::PlatformLink::SUPPORTED_PLATFORMS.include?(input.platform)
raise ArgumentError, "Unsupported platform: #{input.platform}"
end
release = "WarpEngine::Platforms::#{input.platform.camelize}::Service".constantize
.new.update(input.name, input.version)
# A publikálás az egyetlen pont, ahol új build kerül a katalógusba —
# a host innen tud rá reagálni (értesítés, feed, csatorna-előléptetés)
# anélkül, hogy modell-callbackre kellene kapaszkodnia.
ActiveSupport::Notifications.instrument(
NOTIFICATION,
platform: input.platform,
name: input.name,
version: input.version,
software: release.respond_to?(:software) ? release.software : nil,
release: release
)
release
end
end
end
@@ -0,0 +1,80 @@
module WarpEngine
class SecretSyncService
SECRET_NAME = "application_token".freeze
def initialize(client: WoodpeckerClient.new)
@client = client
end
def provision(plain_token, pipelines:)
results = { synced: [], failed: [] }
pipelines.each do |pipeline|
if secret_exists?(pipeline.woodpecker_repo_id)
@client.update_secret(pipeline.woodpecker_repo_id, SECRET_NAME, value: plain_token)
else
@client.create_secret(pipeline.woodpecker_repo_id, name: SECRET_NAME, value: plain_token)
end
results[:synced] << pipeline
rescue WoodpeckerClient::ApiError, WoodpeckerClient::ConnectionError => e
Rails.logger.error("[SecretSyncService] failed for #{pipeline.full_name}: #{e.message}")
results[:failed] << { pipeline: pipeline, error: e.message }
end
results
end
def deprovision(application_token)
pipelines_for_token(application_token).each do |pipeline|
@client.delete_secret(pipeline.woodpecker_repo_id, SECRET_NAME)
rescue WoodpeckerClient::ApiError => e
Rails.logger.warn("[SecretSyncService] delete failed for #{pipeline.full_name}: #{e.message}")
end
end
def rotate(application_token)
pipelines = pipelines_for_token(application_token)
return { rotated: false, reason: "no pipelines" } if pipelines.empty?
new_token = ApplicationToken.create!(
name: "#{application_token.name} (rotated #{Date.current})",
owner_id: application_token.owner_id,
owner_type: application_token.owner_type,
scopes: application_token.scopes,
expires_at: application_token.expires_at,
unrestricted: application_token.unrestricted?
)
result = provision(new_token.plain_token, pipelines: pipelines)
if result[:synced].any?
application_token.revoke!
{ rotated: true, new_token: new_token, sync_result: result }
else
new_token.revoke!
{ rotated: false, reason: "all pipelines failed", sync_result: result }
end
end
def pipelines_for_token(application_token)
if application_token.unrestricted?
Pipeline.active.to_a
else
software_ids = Software.where(
owner_type: application_token.owner_type,
owner_id: application_token.owner_id
).pluck(:id)
Pipeline.active.where(software_id: software_ids).to_a
end
end
private
def secret_exists?(repo_id)
secrets = @client.list_secrets(repo_id)
secrets.any? { |s| s["name"] == SECRET_NAME }
rescue WoodpeckerClient::ApiError
false
end
end
end
@@ -2,8 +2,9 @@ module WarpEngine
class SoftwareService
include SoftwareResponseBuilder
def index
def index(owner_id: nil)
softwares = WarpEngine::Software.includes(releases: [ :release_assets ]).includes(:external_links, :software_images).all
softwares = softwares.where(owner_id: owner_id) if owner_id.present?
counts = download_counts_for(softwares.flat_map { |sw| sw.releases.map(&:id) })
{ softwares: softwares.map { |sw| build_response(sw, sw.releases.to_a, counts) } }
end
@@ -1,12 +0,0 @@
module WarpEngine
module SoftwareUpdater
class BevyService
include Updatable
include Builds::BuildWeb
include Builds::BuildWinX64
include Builds::BuildLinuxX64
label "Bevy"
end
end
end
@@ -1,12 +0,0 @@
module WarpEngine
module SoftwareUpdater
class C64Service
include Updatable
include Builds::BuildCartridge
label "C64"
def cartridge_ext = ".prg"
end
end
end
@@ -1,15 +0,0 @@
module WarpEngine
module SoftwareUpdater
class EbitengineService
include Updatable
include Builds::BuildWeb
include Builds::BuildWinX86
include Builds::BuildWinX64
include Builds::BuildLinuxX64
include Builds::BuildMacX64
include Builds::BuildMacArm64
label "Ebitengine"
end
end
end
@@ -1,14 +0,0 @@
module WarpEngine
module SoftwareUpdater
class GodotService
include Updatable
include Builds::BuildWeb
include Builds::BuildWinX86
include Builds::BuildWinX64
include Builds::BuildLinuxX64
include Builds::BuildMacUniversal
label "Godot"
end
end
end
@@ -1,13 +0,0 @@
module WarpEngine
module SoftwareUpdater
class LoveService
include Updatable
include Builds::BuildWeb
include Builds::BuildWinX64
include Builds::BuildLinuxX64
include Builds::BuildMacUniversal
label "LÖVE"
end
end
end
@@ -1,10 +0,0 @@
module WarpEngine
module SoftwareUpdater
class PhaserService
include Updatable
include Builds::BuildWeb
label "Phaser"
end
end
end
@@ -1,38 +0,0 @@
module WarpEngine
module SoftwareUpdater
class Tic80Service
include Updatable
include Builds::BuildCartridge
include Builds::BuildSource
include Builds::BuildWeb
include Builds::BuildDocs
include Builds::BuildWinX64
include Builds::BuildLinuxX64
include Builds::BuildMacX64
label "TIC-80"
def cartridge_ext = ".tic"
def source_ext = ".lua"
private
def parse_metadata(versioned)
parse_lua_metadata(full_path("#{versioned}.lua"))
end
def parse_lua_metadata(source_path)
metadata = {}
File.foreach(source_path) do |line|
break unless line.start_with?("--")
parts = line[2..].split(":", 2)
next if parts.length != 2
key = parts[0].strip.downcase.to_sym
value = parts[1].strip
metadata[key] = value
end
metadata.slice(*MetadataParsing::METADATA_KEYS)
end
end
end
end
@@ -1,11 +0,0 @@
module WarpEngine
class UpdateService
def update(input)
unless WarpEngine::PlatformLink::SUPPORTED_PLATFORMS.include?(input.platform)
raise ArgumentError, "Unsupported platform: #{input.platform}"
end
"WarpEngine::SoftwareUpdater::#{input.platform.camelize}Service".constantize.new.update(input.name, input.version)
end
end
end

Some files were not shown because too many files have changed in this diff Show More