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
122 changed files with 5660 additions and 1686 deletions
+10 -5
View File
@@ -1,10 +1,10 @@
# Read-only split mirror: a libs/ruby/warp_engine alkönyvtárat kitükrözi a
# tools/warp_engine repóba (fejlesztés itt, a monorepóban történik; a tükör
# 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 (tools/warp_engine) és package:write joggal.
# repository:write (engines/warp_engine) és package:write joggal.
when:
- event: push
@@ -32,7 +32,7 @@ steps:
- 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/tools/warp_engine.git" warp-engine-split:master
- git push --force "https://ci:$${TOKEN}@git.teletypegames.org/engines/warp_engine.git" warp-engine-split:master
when:
- event: push
branch: master
@@ -48,10 +48,15 @@ steps:
- 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:https://git.teletypegames.org/api/packages/tools/rubygems: Bearer %s\n' "$${TOKEN}" > ~/.gem/credentials
printf -- '---\n:gitea: Bearer %s\n' "$${TOKEN}" > ~/.gem/credentials
- chmod 600 ~/.gem/credentials
- gem push --host https://git.teletypegames.org/api/packages/tools/rubygems warp_engine-*.gem
- 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*
+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
+28 -5
View File
@@ -1,13 +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|
# A /build/* DB-tokenjeinek tulajdonosa. A :database módra váltás
# (c.application_token_source = :database) csak azután jöhet, hogy a CI már
# DB-tokent használ — az átkapcsolás azonnal érvényteleníti az UPDATE_SECRET-et.
# 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",
+20 -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_05_000003) 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
@@ -196,6 +196,24 @@ ActiveRecord::Schema[8.1].define(version: 2026_08_05_000003) 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
@@ -267,6 +285,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_08_05_000003) do
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
+19
View File
@@ -160,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',
@@ -197,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.',
+19
View File
@@ -160,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',
@@ -197,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.',
@@ -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>
@@ -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',
]
@@ -33,7 +33,7 @@
</div>
<div v-else class="engine-list">
<article v-for="(page, index) in enginePages" :key="page.id" class="engine-card group">
<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">
@@ -42,7 +42,21 @@
</h2>
<p v-if="page.description" class="engine-card-desc">{{ page.description }}</p>
<p v-if="page.content" class="engine-card-preview">{{ getCleanPreview(page.content) }}</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">
@@ -62,11 +76,11 @@
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { computed, onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import { WIKI_BASE } from '../../api/wiki.api'
import { useEnginesStore } from '../../stores/engines.store'
import { useEnginesStore, getEngineDigest } from '../../stores/engines.store'
import type { WikiPageWithContent } from '../../lib/interfaces/wiki.interface'
import SkeletonCard from '../../components/SkeletonCard.vue'
@@ -76,6 +90,10 @@ 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 =>
@@ -135,6 +153,27 @@ onMounted(() => store.fetch())
.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;
}
@@ -0,0 +1,5 @@
import type { RouteRecordRaw } from 'vue-router'
export const batoceraRouter: RouteRecordRaw[] = [
{ path: '/batocera', name: 'batoceraIndex', component: () => import('../page/batocera/BatoceraIndexPage.vue') },
]
+2
View File
@@ -8,6 +8,7 @@ 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(),
@@ -21,6 +22,7 @@ export const router = createRouter({
...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: [] })
})
})
+70 -1
View File
@@ -4,6 +4,75 @@ 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()
@@ -19,5 +88,5 @@ export const useEnginesStore = defineStore('engines', () => {
return content.replace(/[#*`_[\]()>|-]/g, '').replace(/\s+/g, ' ').trim().slice(0, 260) + '...'
}
return { pages, loading, error, fetch, getCleanPreview, invalidate }
return { pages, loading, error, fetch, getCleanPreview, getEngineDigest, invalidate }
})
+7 -19
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
@@ -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
+137 -14
View File
@@ -12,12 +12,18 @@ Repository: `https://git.teletypegames.org/tools/warp_engine`
- **Catalog domain**: `Software`, `Release`, `ReleaseAsset`, `ExternalLink`,
`PlatformLink`, `Image`, `SoftwareImage`, `Download` models with soft-delete
semantics and download statistics.
- **CI-callable updater**: your build pipeline drops artifacts into a
directory and calls one endpoint — WarpEngine extracts archives, parses
metadata and upserts the catalog records. Supported platforms out of the
- **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.
@@ -26,6 +32,10 @@ Repository: `https://git.teletypegames.org/tools/warp_engine`
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
@@ -36,19 +46,18 @@ Repository: `https://git.teletypegames.org/tools/warp_engine`
## Example stack (docker compose)
`examples/compose` boots everything the engine's workflow assumes, end to
end: the catalog app itself, the SSH drop area the updater contract feeds
from and — behind a compose profile — a Gitea forge with Woodpecker CI, so
you can watch a pipeline publish a release into the catalog.
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 |
| `droparea` | SSH server where pipelines drop build artifacts; shares the `softwares` volume with `app` | `ssh drop@localhost -p 2222` |
| `gitea` | Git forge (profile `ci`) | `http://gitea:3000` |
| `woodpecker` + agent | CI wired to gitea (profile `ci`) | `http://woodpecker:8000` |
### Quickstart — catalog + drop area
### Quickstart — catalog only
```sh
cd examples/compose
@@ -89,10 +98,6 @@ curl -X POST -H "X-Update-Secret: example-update-secret" \
"http://localhost:8080/build/publish?platform=love&name=demo&version=0.1.0"
```
(Dropping the files in over the SSH drop area — `scp -P 2222 demo-0.1.0.*
drop@localhost:drop/`, password `DROP_PASSWORD` from `.env` — works just as
well; the updater only cares that the files end up in `file_container_path`.)
`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
@@ -221,8 +226,7 @@ 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`). Either
drop the files in over the shared volume (SSH drop area), or push them
declares which asset kinds it expects — see `GET /api/builds`). Push them
over HTTP — one request per file, `upload` scope, optional `sha256`
integrity check:
@@ -264,6 +268,125 @@ accepts both:
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 |
@@ -2,7 +2,7 @@ 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 priority: 9, label: "🎟️ App Tokens"
menu parent: "🌀 WarpEngine", priority: 9, label: "🎟️ App Tokens"
config.sort_order = "created_at_desc"
config.batch_actions = false
@@ -39,29 +39,48 @@ ActiveAdmin.register WarpEngine::ApplicationToken, as: "Application Token" do
include_blank: false
else
f.template.concat(f.template.content_tag(:li,
"application_token_owner_class nincs beállítva — token nem hozható létre.",
"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: %(A /build/publish (és a legacy /update) végponthoz az "update", a /build/upload-hoz az "upload" scope kell.)
f.input :unrestricted, hint: "Belső token: az owner-izoláció (enforce_software_ownership) nem vonatkozik rá."
f.input :expires_at, hint: "Üresen hagyva sosem jár le."
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 — csak most látható, másold ki!" do
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 tárolva)" }
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
@@ -73,13 +92,26 @@ ActiveAdmin.register WarpEngine::ApplicationToken, as: "Application Token" do
end
controller do
# A plain token csak közvetlenül a létrehozás után létezik; a session-ön át
# jut el az egyszeri megjelenítésig (a flash nem jó: az AA layout minden
# flash kulcsot üzenetsávként renderel).
# 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
@@ -90,10 +122,12 @@ ActiveAdmin.register WarpEngine::ApplicationToken, as: "Application Token" do
show!
end
# Revoke = soft delete, audit-nyommal.
def destroy
if WarpEngine.woodpecker_configured?
WarpEngine::SecretSyncService.new.deprovision(resource)
end
resource.revoke!
redirect_to collection_path, notice: "Token revoked."
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
@@ -1,7 +1,7 @@
module WarpEngine
# Token-hitelesítés a publikáló (/build/*) endpointokhoz.
# A hitelesítési forrás kizárólagos: :database módban a shared secret nem
# érvényes, :env módban a DB-tokenek nem.
# 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
@@ -9,8 +9,8 @@ module WarpEngine
attr_reader :current_application_token
# A token kizárólag az X-Update-Secret headerből jöhet — URL-ben a secret
# proxy- és access-logokba szivárogna.
# 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?
@@ -23,13 +23,13 @@ module WarpEngine
def env_secret_authorized?(token)
expected = WarpEngine.config.update_secret
# Konfigurálatlan secret esetén az endpoint zárva marad.
# 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, de application_token_owner_class nincs beállítva — minden kérés elutasítva")
Rails.logger.error("[#{self.class.name}] application_token_source=:database but application_token_owner_class is not set — rejecting every request")
return false
end
@@ -41,9 +41,9 @@ module WarpEngine
true
end
# Owner-kényszer: csak :database módban (van token) és bekapcsolt
# enforce_software_ownership mellett szűr. Owner nélküli software a
# backfillig szabad préda — a kényszer bekapcsolása előtt kell backfillelni.
# 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
@@ -56,8 +56,8 @@ module WarpEngine
software.owner_type == token.owner_type && software.owner_id == token.owner_id
end
# Az először publikált (vagy backfill előtti, gazdátlan) software a beküldő
# token ownerét kapja. Unrestricted (belső) token nem foglal ownert.
# 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?
@@ -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
@@ -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
@@ -29,13 +29,13 @@ module WarpEngine
return render json: { error: "Forbidden" }, status: :forbidden
end
input = WarpEngine::UpdateInputDto.new(
input = WarpEngine::PublishInputDto.new(
platform: params[:platform],
name: params[:name],
version: params[:version]
)
WarpEngine::UpdateService.new.update(input)
WarpEngine::PublishService.new.publish(input)
claim_software_ownership(params[:name])
render json: { published: true, name: params[:name], platform: params[:platform], version: params[:version] }
@@ -9,11 +9,11 @@ module WarpEngine
short "Build artifact upload"
end
# A release-fájlnevek kötött konvenciója: <name>-<version>.<ext> vagy
# <name>-<version>-<target>.zip — az updater is ezeket keresi.
# 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 drop area"
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"
@@ -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
@@ -7,8 +7,8 @@ module WarpEngine
UPDATE_SCOPE = "update".freeze
UPLOAD_SCOPE = "upload".freeze
# A generált token csak létrehozáskor, memóriában érhető el — a DB-ben
# kizárólag a SHA256 digest és a nem-titkos prefix tárolódik.
# 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
@@ -30,7 +30,7 @@ module WarpEngine
Digest::SHA256.hexdigest(token)
end
# Az élő (nem törölt, nem lejárt), a kért scope-pal rendelkező token, különben nil.
# 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?
@@ -45,7 +45,7 @@ module WarpEngine
expires_at.present? && expires_at <= Time.current
end
# Visszavonás = soft delete, az audit-nyom megmarad.
# Revocation = soft delete, the audit trail stays.
def revoke!
update_column(:deleted_at, Time.current)
end
@@ -54,7 +54,7 @@ module WarpEngine
update_column(:last_used_at, Time.current)
end
# Admin form: vesszővel elválasztott scope-lista
# Admin form: comma separated scope list
def scopes_string
Array(scopes).join(", ")
end
@@ -67,7 +67,7 @@ module WarpEngine
%w[created_at deleted_at expires_at id last_used_at name owner_id owner_type token_prefix unrestricted updated_at]
end
# A polimorf owner asszociációra a Ransack nem tud szűrni.
# Ransack cannot filter on the polymorphic owner association.
def self.ransackable_associations(auth_object = nil)
[]
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,8 +2,8 @@ module WarpEngine
class Software < ApplicationRecord
self.table_name = "softwares"
# A publikáló token ownere (pl. AdminUser) — 3rd party izolációhoz, ld.
# enforce_software_ownership. nil = belső / backfill előtti software.
# 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
@@ -11,6 +11,7 @@ module WarpEngine
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
@@ -14,7 +14,7 @@ module WarpEngine
field(:license) { |sw| sw.license.to_s }
field :platform
field :status
# Publikus owner-azonosító — az /api/software?owner_id= szűrőhöz.
# 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) }
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,160 @@
require "net/http"
require "json"
require "uri"
module WarpEngine
class WoodpeckerClient
class ApiError < StandardError
attr_reader :status, :body
def initialize(message, status:, body: nil)
super(message)
@status = status
@body = body
end
end
class ConnectionError < StandardError; end
def initialize(base_url: nil, token: nil)
@base_url = (base_url || WarpEngine.config.woodpecker_url).to_s.chomp("/")
@token = token || WarpEngine.config.woodpecker_api_token
end
# --- Repos ---
def list_repos
get("/api/repos")
end
def get_repo(repo_id)
get("/api/repos/#{repo_id}")
end
def activate_repo(repo_id)
post("/api/repos", body: { id: repo_id })
end
def deactivate_repo(repo_id)
delete("/api/repos/#{repo_id}")
end
# --- Secrets ---
def list_secrets(repo_id)
get("/api/repos/#{repo_id}/secrets")
end
def create_secret(repo_id, name:, value:, events: %w[push tag deployment])
post("/api/repos/#{repo_id}/secrets",
body: { name: name, value: value, events: events })
end
def update_secret(repo_id, secret_name, value:)
patch("/api/repos/#{repo_id}/secrets/#{secret_name}",
body: { value: value })
end
def delete_secret(repo_id, secret_name)
delete("/api/repos/#{repo_id}/secrets/#{secret_name}")
end
# --- Pipelines ---
def list_pipelines(repo_id, page: 1, per_page: 25)
get("/api/repos/#{repo_id}/pipelines",
params: { page: page, perPage: per_page })
end
def latest_pipeline(repo_id)
get("/api/repos/#{repo_id}/pipelines/latest")
end
def get_pipeline(repo_id, number)
get("/api/repos/#{repo_id}/pipelines/#{number}")
end
def trigger_pipeline(repo_id, branch: "main")
post("/api/repos/#{repo_id}/pipelines",
body: { branch: branch })
end
private
def get(path, params: {})
uri = build_uri(path, params)
request = Net::HTTP::Get.new(uri)
execute(uri, request)
end
def post(path, body: {})
uri = build_uri(path)
request = Net::HTTP::Post.new(uri)
request.body = body.to_json
request.content_type = "application/json"
execute(uri, request)
end
def patch(path, body: {})
uri = build_uri(path)
request = Net::HTTP::Patch.new(uri)
request.body = body.to_json
request.content_type = "application/json"
execute(uri, request)
end
def delete(path)
uri = build_uri(path)
request = Net::HTTP::Delete.new(uri)
execute(uri, request)
end
def build_uri(path, params = {})
uri = URI.parse("#{@base_url}#{path}")
uri.query = URI.encode_www_form(params) if params.any?
uri
end
def execute(uri, request)
request["Authorization"] = "Bearer #{@token}"
request["Accept"] = "application/json"
response = Net::HTTP.start(uri.hostname, uri.port,
use_ssl: uri.scheme == "https",
open_timeout: 10,
read_timeout: 30) do |http|
http.request(request)
end
handle_response(uri, response)
rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Net::OpenTimeout,
Net::ReadTimeout, SocketError => e
raise ConnectionError, "Cannot reach Woodpecker at #{@base_url}: #{e.message}"
end
def handle_response(uri, response)
case response
when Net::HTTPSuccess, Net::HTTPNoContent
return nil if response.body.blank?
begin
JSON.parse(response.body)
rescue JSON::ParserError
# A wrong path falls through to the Woodpecker SPA, which answers
# 200 with index.html — surface that as an API error, not a parse one.
raise ApiError.new(
"Expected JSON from #{uri.path} but got: #{response.body.truncate(80)}",
status: response.code.to_i, body: response.body
)
end
when Net::HTTPNotFound
raise ApiError.new("Not found: #{uri.path}", status: 404, body: response.body)
else
raise ApiError.new(
"Woodpecker API error #{response.code}: #{response.body&.truncate(200)}",
status: response.code.to_i,
body: response.body
)
end
end
end
end
+6
View File
@@ -6,10 +6,16 @@ WarpEngine::Engine.routes.draw do
get "download", to: "downloads#show"
get "builds", to: "builds#index"
get "softwares/:name/builds", to: "software_builds#show"
get "ci/pipelines", to: "ci#pipelines"
get "ci/pipelines/:id/status", to: "ci#status"
post "ci/pipelines/:id/trigger", to: "ci#trigger"
end
post "build/upload", to: "build/uploads#create"
post "build/publish", to: "build/publish#create"
get "build/config", to: "build/configs#show"
post "build/config", to: "build/configs#create"
get "file/*path", to: "files#show", format: false
end
@@ -3,8 +3,8 @@ class CreateApplicationTokens < ActiveRecord::Migration[8.1]
create_table :application_tokens, id: { type: :bigint, unsigned: true },
charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t|
t.string :name, limit: 128, null: false
# Az owner osztályát a host adja (WarpEngine.config.application_token_owner_class),
# ezért nem lehet FK.
# The owner class comes from the host (WarpEngine.config.application_token_owner_class),
# so no FK.
t.string :owner_type, limit: 128, null: false
t.bigint :owner_id, null: false, unsigned: true
t.string :token_digest, limit: 64, null: false
@@ -1,12 +1,12 @@
class AddBuildOwnership < ActiveRecord::Migration[8.1]
def change
# A publikáló token ownere; nil = belső / backfill előtti software.
# Az owner osztályát a host adja (application_token_owner_class), ezért nem lehet FK.
# Owner of the publishing token; nil = internal / pre-backfill software.
# The owner class comes from the host (application_token_owner_class), so no FK.
add_column :softwares, :owner_type, :string, limit: 128
add_column :softwares, :owner_id, :bigint, unsigned: true
add_index :softwares, [ :owner_type, :owner_id ], name: "idx_softwares_owner"
# unrestricted = belső token: az enforce_software_ownership nem vonatkozik rá.
# unrestricted = internal token: exempt from enforce_software_ownership.
add_column :application_tokens, :unrestricted, :boolean, default: false, null: false
end
end
@@ -0,0 +1,27 @@
class CreateCiRepositories < ActiveRecord::Migration[8.1]
def change
create_table :ci_repositories, id: { type: :bigint, unsigned: true },
charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t|
t.bigint :woodpecker_repo_id, null: false, unsigned: true
t.string :repo_owner, limit: 128, null: false
t.string :repo_name, limit: 128, null: false
t.string :platform, limit: 32, null: false
t.bigint :software_id, unsigned: true
t.boolean :active, default: true, null: false
t.string :last_pipeline_status, limit: 32
t.datetime :last_pipeline_at, precision: 3
t.datetime :deleted_at, precision: 3
t.timestamps precision: 3, null: true
t.index :woodpecker_repo_id, unique: true, name: "idx_ci_repos_wp_id"
t.index :software_id, name: "idx_ci_repos_software"
t.index [ :repo_owner, :repo_name ], unique: true, name: "idx_ci_repos_owner_name"
t.index :deleted_at, name: "idx_ci_repos_deleted"
end
add_foreign_key :ci_repositories, :softwares,
column: :software_id,
name: "fk_ci_repos_software",
on_delete: :nullify
end
end
@@ -0,0 +1,15 @@
class RenameCiRepositoriesToPipelines < ActiveRecord::Migration[8.1]
def change
rename_table :ci_repositories, :pipelines
rename_index :pipelines, "idx_ci_repos_deleted", "idx_pipelines_deleted"
rename_index :pipelines, "idx_ci_repos_owner_name", "idx_pipelines_owner_name"
rename_index :pipelines, "idx_ci_repos_software", "idx_pipelines_software"
rename_index :pipelines, "idx_ci_repos_wp_id", "idx_pipelines_wp_id"
remove_foreign_key :pipelines, :softwares,
column: :software_id, name: "fk_ci_repos_software", on_delete: :nullify
add_foreign_key :pipelines, :softwares,
column: :software_id, name: "fk_pipelines_software", on_delete: :nullify
end
end
@@ -6,12 +6,8 @@ MYSQL_ROOT_PASSWORD=warpengine
# Shared secret for the /build/* endpoints (X-Update-Secret header).
UPDATE_SECRET=example-update-secret
# Password of the "drop" user on the artifact drop area (SSH, port 2222).
DROP_PASSWORD=drop
# Published ports.
APP_PORT=8080
DROPAREA_SSH_PORT=2222
GITEA_SSH_PORT=2223
# --- profile "ci" only -------------------------------------------------------
@@ -22,3 +18,9 @@ WOODPECKER_AGENT_SECRET=example-agent-secret
# http://woodpecker:8000/authorize — required before the ci profile starts.
WOODPECKER_GITEA_CLIENT=
WOODPECKER_GITEA_SECRET=
# Woodpecker management (repo sync, secret provisioning, pipeline control).
# Generate a PAT in Woodpecker: profile → Personal Token.
WOODPECKER_URL=http://woodpecker:8000
WOODPECKER_API_TOKEN=
WOODPECKER_REPO_OWNER=
@@ -3,12 +3,10 @@
# mysql the catalog database
# app a minimal Rails host with the engine mounted from this
# repo checkout (headless: API + updater, no ActiveAdmin)
# droparea SSH server where build pipelines drop artifacts; shares
# the "softwares" volume with the app
# gitea (profile "ci") the git forge
# woodpecker (profile "ci") CI server + agent, wired to gitea
#
# Quickstart (catalog + drop area only):
# Quickstart (catalog only):
# cp .env.example .env
# docker compose up --build
#
@@ -41,6 +39,9 @@ services:
UPDATE_SECRET: ${UPDATE_SECRET:-example-update-secret}
FILE_CONTAINER_PATH: /softwares
IMAGE_CONTAINER_PATH: /images
WOODPECKER_URL: ${WOODPECKER_URL:-}
WOODPECKER_API_TOKEN: ${WOODPECKER_API_TOKEN:-}
WOODPECKER_REPO_OWNER: ${WOODPECKER_REPO_OWNER:-}
depends_on:
mysql:
condition: service_healthy
@@ -51,24 +52,6 @@ services:
- softwares:/softwares
- images:/images
# SSH landing zone for build artifacts. Pipelines (or you, with scp) upload
# into ~/drop here; the app sees the same files under /softwares.
droparea:
image: linuxserver/openssh-server
environment:
PUID: 1
PGID: 1
SUDO_ACCESS: "false"
PASSWORD_ACCESS: "true"
USER_NAME: drop
USER_PASSWORD: ${DROP_PASSWORD:-drop}
ports:
- "${DROPAREA_SSH_PORT:-2222}:2222"
volumes:
# A subdir of the drop user's home (/config), so sshd's own state files
# never end up in the catalog directory.
- softwares:/config/drop
# --- profile "ci": the forge + CI producing releases for the catalog ------
#
# gitea and woodpecker refer to each other by their service names, so your
@@ -113,7 +96,7 @@ services:
WOODPECKER_SERVER: "woodpecker:9000"
WOODPECKER_AGENT_SECRET: ${WOODPECKER_AGENT_SECRET:-example-agent-secret}
# Attach pipeline containers to the stack network so steps can reach
# gitea, droparea and the app by service name.
# gitea and the app by service name.
WOODPECKER_BACKEND_DOCKER_NETWORK: warp-example
volumes:
- /var/run/docker.sock:/var/run/docker.sock
@@ -3,7 +3,7 @@ Rails.application.config.to_prepare do
c.file_container_path = ENV.fetch("FILE_CONTAINER_PATH", "/softwares")
c.image_container_path = ENV.fetch("IMAGE_CONTAINER_PATH", "/images")
# Beállítatlan secret esetén a /build/* endpointok minden kérést elutasítanak.
# With no secret configured the /build/* endpoints reject every request.
c.update_secret = ENV["UPDATE_SECRET"]
end
end
@@ -11,8 +11,8 @@ class CreateWarpEngineTables < ActiveRecord::Migration[8.0]
t.string :site
t.string :status, limit: 20, default: "development"
t.boolean :highlighted, default: false
# A publikáló token ownere (enforce_software_ownership) — nem lehet FK,
# az owner osztályát a host adja.
# Owner of the publishing token (enforce_software_ownership) — no FK,
# the owner class comes from the host.
t.string :owner_type, limit: 128
t.bigint :owner_id
t.datetime :deleted_at, precision: 3
@@ -87,7 +87,7 @@ class CreateWarpEngineTables < ActiveRecord::Migration[8.0]
t.string :token_digest, limit: 64, null: false
t.string :token_prefix, limit: 12, null: false
t.json :scopes
# Belső token: az enforce_software_ownership nem vonatkozik rá.
# Internal token: exempt from enforce_software_ownership.
t.boolean :unrestricted, default: false, null: false
t.datetime :expires_at, precision: 3
t.datetime :last_used_at, precision: 3
@@ -1,32 +1,50 @@
Rails.application.config.to_prepare do
WarpEngine.configure do |c|
# A build-artifactok és képek tárolási helye (defaultok az env-ből:
# FILE_CONTAINER_PATH ill. IMAGE_CONTAINER_PATH).
# Storage locations for build artifacts and images (defaults from ENV:
# FILE_CONTAINER_PATH and IMAGE_CONTAINER_PATH).
# c.file_container_path = "/softwares"
# c.image_container_path = "/images"
# A /build/* endpointok shared secretje (default: ENV["UPDATE_SECRET"]).
# Beállítatlan secret esetén az endpointok minden kérést elutasítanak.
# Shared secret of the /build/* endpoints (default: ENV["UPDATE_SECRET"]).
# With no secret configured the endpoints reject every request.
# c.update_secret = ENV["UPDATE_SECRET"]
# A /build/* hitelesítési forrása — kizárólagos választás:
# :env — a fenti shared secret érvényes (default)
# :database — csak DB-tárolt WarpEngine::ApplicationToken érvényes
# ("update" scope-pal); a shared secret ilyenkor NEM működik.
# A :database módhoz kötelező a tokenek tulajdonos-osztálya is:
# Auth source of the /build/* endpoints — an exclusive choice:
# :env — the shared secret above is accepted (default)
# :database — only DB-stored WarpEngine::ApplicationToken records are
# accepted (with the "update" scope); 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"
# A /build/upload (és az admin file manager) méretplafonja bájtban (default: 500MB).
# Woodpecker configuration extension (/build/config): builder images of
# the served platforms and the CI server's signing key. An empty
# ci_platforms (default) keeps the feature inactive.
# c.ci_platforms = {
# "godot" => { builder: "registry.example/godot-builder:4.6" },
# "tic80" => { builder: "registry.example/tic80-builder:1.0",
# exporter: "registry.example/tic80pro:1.0" }
# }
# c.ci_extension_public_key_url = "https://ci.example.org/api/signature/public-key"
# c.ci_update_server = nil # nil: the request base_url
# Woodpecker CI management — repo sync, secret provisioning, pipeline control.
# All three must be set for the management features to activate.
# c.woodpecker_url = ENV["WOODPECKER_URL"] # e.g. "https://ci.example.org"
# c.woodpecker_api_token = ENV["WOODPECKER_API_TOKEN"] # Woodpecker PAT with admin access
# c.woodpecker_repo_owner = ENV["WOODPECKER_REPO_OWNER"] # forge org/user (e.g. "games")
# Size cap in bytes for /build/upload (and the admin file manager, default: 500MB).
# c.max_upload_size = 500 * 1024 * 1024
# Owner-izoláció: DB-token csak a saját ownerének szoftvereit
# uploadolhatja/publisholhatja (unrestricted token kivétel). Csak azután
# kapcsold be, hogy a meglévő szoftverek ownert kaptak (backfill)!
# Owner isolation: a DB token may only upload/publish its own owner's
# softwares (unrestricted tokens are exempt). Enable only after existing
# softwares got an owner (backfill)!
# c.enforce_software_ownership = true
# Ha a host modelljei is hivatkoznak katalógus-képekre, regisztráld őket,
# hogy az admin Images oldal orphan-detektálása figyelembe vegye:
# If host models also reference catalog images, register them so the
# admin Images page's orphan detection takes them into account:
# c.image_owners = [
# {
# label: "member",
+23
View File
@@ -1,8 +1,15 @@
# Az engine ActiveJob-ra épülő jobot szállít (PipelineSyncJob), ezért a
# framework betöltése a mi dolgunk: a host application.rb-je nem feltétlenül
# require-öli az active_job/railtie-t, és eager loadnál (production) a
# WarpEngine::ApplicationJob különben uninitialized constant-tal elszáll.
require "active_job/railtie"
require "blueprinter"
require "apipie-rails"
require "warp_engine/version"
require "warp_engine/configuration"
require "warp_engine/storage"
module WarpEngine
# A tábláink prefix nélküliek (softwares, releases, ...) — az isolate_namespace
@@ -19,6 +26,22 @@ module WarpEngine
def self.configure
yield(config)
end
def self.woodpecker_configured?
config.woodpecker_url.present? && config.woodpecker_api_token.present?
end
# A host innen tudja, hogy a publikálás ActiveSupport::Notifications-t szór
# ("warp_engine.publish"), és nem kell modell-callbackre kapaszkodnia.
# Régebbi engine-verziókon a metódus nem létezik, ezért a hívó oldalon
# respond_to?-val kérdezendő.
def self.instruments_publish?
true
end
def self.storage
Storage.adapter
end
end
require "warp_engine/engine"

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