Author SHA1 Message Date
mr.zero 4b32252d2b Translate code comments and admin strings to English
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-06 01:25:53 +02:00
mr.zero dc45f2eb35 Serve Woodpecker pipeline configs from the engine (/build/config) 2026-08-06 01:14:22 +02:00
mr.zero c067d303bb Remove the droparea: artifacts arrive over /build/upload
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-05 22:16:34 +02:00
mr.zero 0c981b4590 build endpoints
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-05 20:12:35 +02:00
mr.zero f8ff7c394c Drop the unused UPDATE_SECRET_SOURCE env plumbing 2026-08-05 19:01:44 +02:00
mr.zero 3ade17ce9a enable db secret mode 2026-08-05 18:55:32 +02:00
mr.zero d0ce26c0a3 Rename the update auth switch to application_token_source and drop its ENV default
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-05 18:53:04 +02:00
mr.zero b2c780b697 db mode for update secrets
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-05 18:46:49 +02:00
mr.zero a217bcc14e Add DB-backed application tokens for the update endpoint
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-05 18:30:20 +02:00
mr.zero b69eff7866 Drop the date and new badge from the engine cards
Engines are evergreen products, not dated posts — the meta row, its
styles, the unused isNew wiring and the new-badge translations go.
2026-08-05 07:47:28 +02:00
mr.zero 7737a59850 Compact the top navigation and translate Engines as Motorok
The nav has too many items for the old spacing: smaller type, tighter
gaps, icons only on xl+ screens, and the hamburger now takes over below
lg (the desktop row did not fit between md and lg anymore).

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 19:47:15 +02:00
74 changed files with 3108 additions and 201 deletions
+57
View File
@@ -0,0 +1,57 @@
# 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
# 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.
when:
- event: push
branch: master
path: "libs/ruby/warp_engine/**"
- event: manual
- event: tag
ref: refs/tags/warp_engine-v*
clone:
git:
image: woodpeckerci/plugin-git
settings:
partial: false
depth: 0 # a subtree splithez teljes history kell
steps:
split-mirror:
image: alpine/git
environment:
FORGE_TOKEN:
from_secret: forge_token
commands:
- apk add --no-cache git-subtree
- git subtree split --prefix=libs/ruby/warp_engine HEAD -b warp-engine-split
# a secretbe másolt token végén lehet sortörés — levágjuk
- TOKEN="$$(printf '%s' "$${FORGE_TOKEN}" | tr -d '[:space:]')"
- git push --force "https://ci:$${TOKEN}@git.teletypegames.org/tools/warp_engine.git" warp-engine-split:master
when:
- event: push
branch: master
- event: manual
publish-gem:
image: ruby:3.3-slim
environment:
FORGE_TOKEN:
from_secret: forge_token
commands:
- cd libs/ruby/warp_engine
- gem build warp_engine.gemspec
- mkdir -p ~/.gem
- TOKEN="$$(printf '%s' "$${FORGE_TOKEN}" | tr -d '[:space:]')"
- |
printf -- '---\n:https://git.teletypegames.org/api/packages/tools/rubygems: Bearer %s\n' "$${TOKEN}" > ~/.gem/credentials
- chmod 600 ~/.gem/credentials
- gem push --host https://git.teletypegames.org/api/packages/tools/rubygems warp_engine-*.gem
when:
- event: tag
ref: refs/tags/warp_engine-v*
+1 -1
View File
@@ -17,7 +17,7 @@ The API is split in two layers:
- **WarpEngine** (`libs/ruby/warp_engine`) owns the software catalog: models
(softwares, releases, release assets, images, platform links, download stats),
the CI-callable `/update` endpoint, the public read-only JSON API
the CI-callable `/build/*` publishing endpoints, the public read-only JSON API
(`/api/software*`, `/api/builds*`, `/api/image`, `/api/download`, `/file/*`)
and the catalog ActiveAdmin resources. See its [README](libs/ruby/warp_engine/README.md).
- **The host app** (`apps/api`) owns everything TTG-specific: members, events,
@@ -4,7 +4,7 @@ class Api::WikiController < ApiController
end
api :GET, "/api/wiki/pages", "List wiki pages filtered by tag"
param :tag, String, required: false, desc: "Filter by tag (blog, howto)"
param :tag, String, required: false, desc: "Filter by tag (blog, howto, engine)"
param :limit, :number, required: false, desc: "Limit number of results"
param :body, String, required: false, desc: "Include body content (1 = yes)"
returns code: 200, desc: "Wiki pages response" do
@@ -20,6 +20,7 @@ class Api::WikiController < ApiController
property :locale, String, desc: "Locale code"
property :route, String, desc: "URL slug"
property :tags, Array, of: String, desc: "Tags"
property :repo, String, desc: "Git repository URL (from page metadata, engines)"
property :render, String, desc: "Rendered HTML content"
property :content, String, desc: "Raw markdown content"
end
+3
View File
@@ -43,4 +43,7 @@ class WikiService
rescue StandardError => e
{ "tag" => tag, "count" => 0, "pages" => [], "error" => e.message }
end
# Az RSS feedek ezen a néven hívják.
alias_method :pages, :index
end
+24 -2
View File
@@ -1,7 +1,29 @@
# WarpEngine host-konfiguráció. to_prepare: reload után is újrafut, ezért
# értékadás (nem <<), hogy idempotens legyen.
# WarpEngine host configuration. to_prepare: re-runs after reloads, hence
# assignment (not <<) to stay idempotent.
Rails.application.config.to_prepare do
WarpEngine.configure do |c|
# Owner of the /build/* DB tokens. Switching to :database mode
# (c.application_token_source = :database) must wait until CI uses DB
# tokens — the flip invalidates UPDATE_SECRET immediately.
c.application_token_source = :database
c.application_token_owner_class = "AdminUser"
# Woodpecker configuration extension (/build/config): the served platforms
# and their builder images. An image bump is one line here, rolled out to
# every repo by the deploy. tic80 can be added once the tic80-builder image
# (lua+luacheck+ldoc toolchain) exists in the tic80-tools repo.
c.ci_platforms = {
"godot" => { builder: "git.teletypegames.org/internal/godot-builder:4.6" },
"phaser" => { builder: "git.teletypegames.org/internal/phaser-builder:latest" },
"love" => { builder: "git.teletypegames.org/internal/love-builder:latest" },
"bevy" => { builder: "git.teletypegames.org/internal/bevy-builder:latest" },
"c64" => { builder: "git.teletypegames.org/internal/c64-builder:latest" },
"ebitengine" => { builder: "git.teletypegames.org/internal/ebitengine-builder:latest" }
# "tic80" => { builder: "git.teletypegames.org/internal/tic80-builder:latest",
# exporter: "git.teletypegames.org/internal/tic80pro:latest" }
}
c.ci_extension_public_key_url = "https://ci.teletypegames.org/api/signature/public-key"
c.image_owners = [
{
label: "member",
+1 -1
View File
@@ -14,7 +14,7 @@ Rails.application.routes.draw do
end
# Utolsó sor: a host route-jai nyernek, a katalógus-útvonalakat
# (/api/software*, /api/builds*, /api/image, /api/download, /update, /file/*)
# (/api/software*, /api/builds*, /api/image, /api/download, /build/*, /file/*)
# az engine adja.
mount WarpEngine::Engine => "/"
end
+22 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2026_08_04_000002) do
ActiveRecord::Schema[8.1].define(version: 2026_08_05_000003) do
create_table "admin_users", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "deleted_at", precision: 3
@@ -27,6 +27,24 @@ ActiveRecord::Schema[8.1].define(version: 2026_08_04_000002) do
t.index ["reset_password_token"], name: "index_admin_users_on_reset_password_token", unique: true
end
create_table "application_tokens", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.datetime "created_at", precision: 3
t.datetime "deleted_at", precision: 3
t.datetime "expires_at", precision: 3
t.datetime "last_used_at", precision: 3
t.string "name", limit: 128, null: false
t.bigint "owner_id", null: false, unsigned: true
t.string "owner_type", limit: 128, null: false
t.json "scopes"
t.string "token_digest", limit: 64, null: false
t.string "token_prefix", limit: 12, null: false
t.boolean "unrestricted", default: false, null: false
t.datetime "updated_at", precision: 3
t.index ["deleted_at"], name: "idx_application_tokens_deleted_at"
t.index ["owner_type", "owner_id"], name: "idx_application_tokens_owner"
t.index ["token_digest"], name: "idx_application_tokens_token_digest", unique: true
end
create_table "downloads", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.datetime "created_at", precision: 3
t.datetime "deleted_at", precision: 3
@@ -232,6 +250,8 @@ ActiveRecord::Schema[8.1].define(version: 2026_08_04_000002) do
t.boolean "highlighted", default: false
t.string "license", limit: 128
t.string "name", limit: 128
t.bigint "owner_id", unsigned: true
t.string "owner_type", limit: 128
t.string "platform", limit: 128
t.string "site"
t.string "status", limit: 20, default: "development"
@@ -240,6 +260,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_08_04_000002) do
t.datetime "updated_at", precision: 3
t.index ["deleted_at"], name: "idx_softwares_deleted_at"
t.index ["name"], name: "idx_softwares_name", unique: true
t.index ["owner_type", "owner_id"], name: "idx_softwares_owner"
end
add_foreign_key "admin_users", "members"
+17 -1
View File
@@ -15,6 +15,7 @@ interface RawWikiPage {
updatedAt: string
createdAt: string
locale: string
repo?: string | null
}
async function fetchPages(
@@ -67,6 +68,21 @@ const getBlogPage = async (slug: string): Promise<WikiPageContent | null> => {
}
}
const listEnginePages = async (): Promise<WikiPageWithContent[]> => {
const pages = await fetchPages('engine', { body: true })
return pages.map((p): WikiPageWithContent => ({
id: p.id,
path: p.path,
title: p.title || p.path,
description: p.description ?? '',
content: p.content ?? '',
updatedAt: p.updatedAt,
createdAt: p.createdAt,
locale: p.locale,
repo: p.repo ?? null,
}))
}
const listHowtoPages = async (): Promise<WikiPage[]> => {
const pages = await fetchPages('howto', { limit: 30 })
return pages.map((p): WikiPage => ({
@@ -81,4 +97,4 @@ const listHowtoPages = async (): Promise<WikiPage[]> => {
}
export { WIKI_BASE }
export default { listBlogPages, getBlogPage, listHowtoPages }
export default { listBlogPages, getBlogPage, listHowtoPages, listEnginePages }
+12
View File
@@ -4,6 +4,7 @@ export default {
catalog: 'Catalog',
blog: 'Blog',
howtos: 'How-tos',
engines: 'Engines',
code: 'Code',
team: 'Team',
contact: 'Contact us',
@@ -168,6 +169,17 @@ export default {
title: 'Our Team',
subtitle: 'Meet the brilliant minds behind Teletype Games.',
},
engines: {
badge: 'In-house Tech',
titleLead: 'Our',
titleAccent: 'Engines',
subtitle: 'The engines and frameworks we build, maintain and ship our games and services on.',
errorTitle: 'Failed to connect to Wiki',
noPagesTitle: 'No engines found',
noPagesDesc: "It seems like there aren't any engine pages available on the wiki at the moment.",
explore: 'Explore',
openWiki: 'Open in Wiki',
},
howtos: {
badge: 'Knowledge Base',
title: 'Tech HowTo Center',
+12
View File
@@ -4,6 +4,7 @@ export default {
catalog: 'Katalógus',
blog: 'Blog',
howtos: 'Hogyan csináld',
engines: 'Motorok',
code: 'Kód',
team: 'Csapat',
contact: 'Kapcsolat',
@@ -168,6 +169,17 @@ export default {
title: 'Csapatunk',
subtitle: 'Ismerd meg a Teletype Games mögött álló zseniális elméket.',
},
engines: {
badge: 'Saját technológia',
titleLead: 'Saját',
titleAccent: 'motorjaink',
subtitle: 'Az általunk épített és karbantartott motorok és keretrendszerek, amelyekre a játékaink és szolgáltatásaink épülnek.',
errorTitle: 'Nem sikerült csatlakozni a Wikihez',
noPagesTitle: 'Nem találhatók motorok',
noPagesDesc: 'Úgy tűnik, jelenleg nincsenek motoroldalak a wikin.',
explore: 'Felfedezés',
openWiki: 'Megnyitás a Wikiben',
},
howtos: {
badge: 'Tudásbázis',
title: 'Tech HowTo Központ',
+12 -5
View File
@@ -9,6 +9,7 @@
<RouterLink to="/catalog" class="nav-link"><i class="fa-solid fa-gamepad nav-icon"></i>{{ t('nav.catalog') }}</RouterLink>
<RouterLink to="/blog" class="nav-link"><i class="fa-solid fa-newspaper nav-icon"></i>{{ t('nav.blog') }}</RouterLink>
<RouterLink to="/howtos" class="nav-link"><i class="fa-solid fa-lightbulb nav-icon"></i>{{ t('nav.howtos') }}</RouterLink>
<RouterLink to="/engines" class="nav-link"><i class="fa-solid fa-cubes nav-icon"></i>{{ t('nav.engines') }}</RouterLink>
<RouterLink to="/code" class="nav-link"><i class="fa-solid fa-code nav-icon"></i>{{ t('nav.code') }}</RouterLink>
<RouterLink to="/team" class="nav-link"><i class="fa-solid fa-users nav-icon"></i>{{ t('nav.team') }}</RouterLink>
<RouterLink to="/contact" class="nav-link"><i class="fa-solid fa-envelope nav-icon"></i>{{ t('nav.contact') }}</RouterLink>
@@ -33,6 +34,7 @@
<RouterLink to="/catalog" class="nav-mobile-link"><i class="fa-solid fa-gamepad nav-icon"></i>{{ t('nav.catalog') }}</RouterLink>
<RouterLink to="/blog" class="nav-mobile-link"><i class="fa-solid fa-newspaper nav-icon"></i>{{ t('nav.blog') }}</RouterLink>
<RouterLink to="/howtos" class="nav-mobile-link"><i class="fa-solid fa-lightbulb nav-icon"></i>{{ t('nav.howtos') }}</RouterLink>
<RouterLink to="/engines" class="nav-mobile-link"><i class="fa-solid fa-cubes nav-icon"></i>{{ t('nav.engines') }}</RouterLink>
<RouterLink to="/code" class="nav-mobile-link"><i class="fa-solid fa-code nav-icon"></i>{{ t('nav.code') }}</RouterLink>
<RouterLink to="/team" class="nav-mobile-link"><i class="fa-solid fa-users nav-icon"></i>{{ t('nav.team') }}</RouterLink>
<RouterLink to="/contact" class="nav-mobile-link"><i class="fa-solid fa-envelope nav-icon"></i>{{ t('nav.contact') }}</RouterLink>
@@ -114,13 +116,13 @@ function switchLocale(lang: string) {
@apply text-xl font-bold;
}
.nav-desktop {
@apply hidden md:flex items-center space-x-4;
@apply hidden lg:flex items-center space-x-1;
}
.nav-link {
@apply p-2 hover:text-purple-300 transition-colors duration-200;
@apply px-2.5 py-2 text-sm hover:text-purple-300 transition-colors duration-200 whitespace-nowrap;
}
.nav-link-admin {
@apply p-2 hover:text-yellow-300 transition-colors duration-200;
@apply px-2.5 py-2 text-sm hover:text-yellow-300 transition-colors duration-200 whitespace-nowrap;
}
.locale-switcher {
@apply flex items-center gap-1 text-sm font-bold ml-2 border-l border-gray-600 pl-4;
@@ -131,14 +133,19 @@ function switchLocale(lang: string) {
.locale-separator {
@apply text-gray-600;
}
/* Az ikonok viszik a legtöbb helyet a sok menüpont mellett — desktopon csak
extra széles kijelzőn jelennek meg, a mobil menüben mindig. */
.nav-desktop .nav-icon {
@apply hidden xl:inline-block;
}
.nav-icon {
@apply mr-1.5 text-xs opacity-70;
}
.hamburger-btn {
@apply md:hidden p-2 focus:outline-none focus:ring-2 focus:ring-purple-500 rounded-md;
@apply lg:hidden p-2 focus:outline-none focus:ring-2 focus:ring-purple-500 rounded-md;
}
.nav-mobile {
@apply md:hidden absolute top-full left-0 w-full bg-gray-800 flex flex-col items-center py-4 space-y-2 z-50;
@apply lg:hidden absolute top-full left-0 w-full bg-gray-800 flex flex-col items-center py-4 space-y-2 z-50;
}
.nav-mobile-link {
@apply block p-2 w-full text-center hover:bg-gray-700 transition-colors duration-200;
@@ -6,6 +6,7 @@ export interface WikiPage {
updatedAt: string
createdAt: string
locale: string
repo?: string | null
}
export interface WikiPageWithContent extends WikiPage {
@@ -96,7 +96,7 @@
<span class="latest-release-badge">{{ t('catalogShow.latestStable') }}</span>
</div>
<h2 class="latest-release-version">{{ latestStable.version }}</h2>
<p class="latest-release-date"><i class="fa-solid fa-calendar mr-1"></i>{{ t('catalogShow.released') }} {{ formatDateTime(latestStable.UpdatedAt) }}</p>
<p class="latest-release-date"><i class="fa-solid fa-calendar mr-1"></i>{{ t('catalogShow.released') }} {{ formatDateTime(latestStable.updatedAt) }}</p>
</div>
<a v-if="latestStable.htmlFolderPath" :href="latestStable.htmlFolderPath" target="_blank" class="latest-release-play-btn"><i class="fa-solid fa-play mr-2"></i>{{ t('catalogShow.playNow') }}</a>
@@ -155,7 +155,7 @@
</div>
<a v-if="release.htmlFolderPath" :href="release.htmlFolderPath" target="_blank" class="release-play-link">{{ t('catalogShow.play') }}</a>
</td>
<td class="release-date-col">{{ formatDateTime(release.UpdatedAt) }}</td>
<td class="release-date-col">{{ formatDateTime(release.updatedAt) }}</td>
</tr>
<tr v-if="tableAssets(release).length">
<td colspan="2" class="px-8 pb-4 pt-0">
@@ -185,7 +185,7 @@
<div class="dev-release-version">{{ release.version }}</div>
<a v-if="release.htmlFolderPath" :href="release.htmlFolderPath" target="_blank" class="release-play-link">{{ t('catalogShow.play') }}</a>
</td>
<td class="release-date-col">{{ formatDateTime(release.UpdatedAt) }}</td>
<td class="release-date-col">{{ formatDateTime(release.updatedAt) }}</td>
</tr>
<tr v-if="tableAssets(release).length" class="bg-yellow-50/10">
<td colspan="2" class="px-8 pb-4 pt-0">
@@ -218,7 +218,7 @@
<div class="mobile-release-header">
<div>
<div class="mobile-release-version">{{ release.version }}</div>
<div class="mobile-release-date">{{ formatDateTime(release.UpdatedAt) }}</div>
<div class="mobile-release-date">{{ formatDateTime(release.updatedAt) }}</div>
</div>
<span v-if="release.version.startsWith('dev-')" class="dev-badge">Dev</span>
</div>
@@ -0,0 +1,147 @@
<template>
<div class="engines-container">
<header class="hero-section-slate">
<div class="engines-header-decor">
<div class="hero-decor-blob -top-24 -left-24 h-96 w-96 bg-emerald-600"></div>
<div class="hero-decor-blob -bottom-24 -right-24 h-96 w-96 bg-teal-600"></div>
</div>
<div class="hero-container">
<div class="hero-badge">{{ t('engines.badge') }}</div>
<h1 class="hero-title">
{{ t('engines.titleLead') }} <span class="text-transparent bg-clip-text bg-gradient-to-r from-emerald-400 to-teal-300">{{ t('engines.titleAccent') }}</span>
</h1>
<p class="hero-subtitle mb-10">{{ t('engines.subtitle') }}</p>
</div>
</header>
<main class="engines-main">
<div v-if="error" class="error-banner" role="alert">
<i class="fa-solid fa-triangle-exclamation text-2xl text-yellow-500"></i>
<div>
<h3 class="error-banner-title">{{ t('engines.errorTitle') }}</h3>
<p class="error-banner-desc">{{ error }}</p>
</div>
</div>
<SkeletonCard v-else-if="loading" :count="3" />
<div v-else-if="enginePages.length === 0" class="empty-state">
<div class="empty-state-icon"><i class="fa-solid fa-inbox"></i></div>
<h2 class="empty-state-title">{{ t('engines.noPagesTitle') }}</h2>
<p class="empty-state-desc">{{ t('engines.noPagesDesc') }}</p>
</div>
<div v-else class="engine-list">
<article v-for="(page, index) in enginePages" :key="page.id" class="engine-card group">
<div class="engine-card-index">{{ String(index + 1).padStart(2, '0') }}</div>
<div class="engine-card-body">
<h2 class="engine-card-title">
<a :href="exploreUrl(page)" target="_blank" rel="noopener">{{ page.title }}</a>
</h2>
<p v-if="page.description" class="engine-card-desc">{{ page.description }}</p>
<p v-if="page.content" class="engine-card-preview">{{ getCleanPreview(page.content) }}</p>
<div class="engine-card-actions">
<a :href="exploreUrl(page)" target="_blank" rel="noopener" class="engine-explore-btn">
<i class="fa-solid fa-code-branch text-sm"></i>
{{ t('engines.explore') }}
<i class="fa-solid fa-arrow-right text-sm"></i>
</a>
<a :href="`${WIKI_BASE}/${page.path}`" target="_blank" rel="noopener" class="engine-wiki-link">
<i class="fa-solid fa-book mr-1"></i>{{ t('engines.openWiki') }}
</a>
</div>
</div>
</article>
</div>
</main>
</div>
</template>
<script setup lang="ts">
import { 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 type { WikiPageWithContent } from '../../lib/interfaces/wiki.interface'
import SkeletonCard from '../../components/SkeletonCard.vue'
const { t } = useI18n()
const store = useEnginesStore()
const { pages: enginePages, loading, error } = storeToRefs(store)
const { getCleanPreview } = store
// Explore points at the engine's git repository (from wiki metadata);
// pages without one fall back to their wiki page.
const exploreUrl = (page: WikiPageWithContent): string =>
page.repo || `${WIKI_BASE}/${page.path}`
onMounted(() => store.fetch())
</script>
<style scoped>
.engines-container {
@apply bg-slate-950 min-h-screen pb-24;
}
.engines-header-decor {
@apply absolute inset-0 opacity-30;
}
.engines-main {
@apply max-w-5xl mx-auto px-4 md:px-8 mt-16 relative z-10;
}
.banner-base {
@apply max-w-7xl mx-auto border-l-4 p-6 rounded-r-xl shadow-lg mb-12 flex items-start gap-4;
}
.error-banner { @apply banner-base bg-red-50 border-red-500; }
.error-banner-title { @apply text-red-800 font-bold text-lg; }
.error-banner-desc { @apply text-red-700 mt-1; }
.empty-state {
@apply max-w-7xl mx-auto bg-slate-900 rounded-2xl shadow-xl p-12 text-center border border-slate-800;
}
.empty-state-icon { @apply text-6xl mb-4; }
.empty-state-title { @apply text-2xl font-bold text-white mb-2; }
.empty-state-desc { @apply text-slate-400 max-w-md mx-auto; }
/* Few engines, so every one of them gets a full-width, poster-like card. */
.engine-list {
@apply flex flex-col gap-10;
}
.engine-card {
@apply relative overflow-hidden bg-slate-900 rounded-3xl border border-slate-800 border-l-4 border-l-emerald-500 shadow-2xl transition-all hover:border-l-teal-300 hover:-translate-y-1;
}
.engine-card-index {
@apply absolute -top-6 right-4 text-[9rem] leading-none font-black text-slate-800/60 select-none pointer-events-none transition-colors;
}
.engine-card:hover .engine-card-index {
@apply text-slate-800;
}
.engine-card-body {
@apply relative p-8 md:p-12;
}
.engine-card-title {
@apply text-3xl md:text-5xl font-black text-white mb-4 leading-tight;
}
.engine-card-title a {
@apply hover:text-emerald-400 transition-colors;
}
.engine-card-desc {
@apply text-lg md:text-xl text-emerald-100/90 font-semibold mb-3 leading-relaxed max-w-3xl;
}
.engine-card-preview {
@apply text-base text-slate-400 leading-relaxed mb-8 max-w-3xl;
}
.engine-card-actions {
@apply flex flex-wrap items-center gap-6;
}
.engine-explore-btn {
@apply inline-flex items-center gap-2 bg-emerald-500 text-slate-950 px-6 py-3 rounded-xl font-bold hover:bg-emerald-400 transition-all shadow-lg shadow-emerald-900/40 group-hover:translate-x-1;
}
.engine-wiki-link {
@apply text-slate-400 font-semibold hover:text-white transition-colors;
}
</style>
@@ -0,0 +1,5 @@
import type { RouteRecordRaw } from 'vue-router'
export const enginesRouter: RouteRecordRaw[] = [
{ path: '/engines', name: 'enginesIndex', component: () => import('../page/engines/EnginesIndexPage.vue') },
]
+2
View File
@@ -4,6 +4,7 @@ import { blogRouter } from './blog.router'
import { catalogRouter } from './catalog.router'
import { codeRouter } from './code.router'
import { contactRouter } from './contact.router'
import { enginesRouter } from './engines.router'
import { howtosRouter } from './howtos.router'
import { teamRouter } from './team.router'
import { buildsRouter } from './builds.router'
@@ -16,6 +17,7 @@ export const router = createRouter({
...catalogRouter,
...codeRouter,
...contactRouter,
...enginesRouter,
...howtosRouter,
...teamRouter,
...buildsRouter,
+23
View File
@@ -0,0 +1,23 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import wikiApi from '../api/wiki.api'
import { useLoadable } from '../composables/useLoadable'
import type { WikiPageWithContent } from '../lib/interfaces/wiki.interface'
export const useEnginesStore = defineStore('engines', () => {
const pages = ref<WikiPageWithContent[]>([])
const { loading, error, withCache, invalidate } = useLoadable()
async function fetch() {
await withCache(async () => {
pages.value = await wikiApi.listEnginePages()
})
}
function getCleanPreview(content: string): string {
if (!content) return ''
return content.replace(/[#*`_[\]()>|-]/g, '').replace(/\s+/g, ' ').trim().slice(0, 260) + '...'
}
return { pages, loading, error, fetch, getCleanPreview, invalidate }
})
+1 -20
View File
@@ -178,7 +178,7 @@ services:
- "traefik.http.middlewares.api-cors.headers.accesscontrolallowheaders=Content-Type,Authorization,Accept,X-Requested-With"
- "traefik.http.middlewares.api-cors.headers.accesscontrolmaxage=3600"
- "traefik.http.middlewares.api-cors.headers.addvaryheader=true"
- "traefik.http.routers.api.rule=Host(`${WEBAPP_DOMAIN}`) && (PathPrefix(`/api`) || PathPrefix(`/file`) || PathPrefix(`/update`) || PathPrefix(`/admin`))"
- "traefik.http.routers.api.rule=Host(`${WEBAPP_DOMAIN}`) && (PathPrefix(`/api`) || PathPrefix(`/file`) || PathPrefix(`/build`) || PathPrefix(`/admin`))"
- "traefik.http.routers.api.entrypoints=web"
- "traefik.http.routers.api.priority=10"
- "traefik.http.routers.api.middlewares=api-cors"
@@ -192,25 +192,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=
+293 -46
View File
@@ -1,89 +1,336 @@
# WarpEngine
Mountable Rails engine: a retro software catalog with a CI-pipeline-callable
release updater, a public read-only JSON API, and ActiveAdmin resources that
load into the host application's admin.
A mountable Rails engine that turns any Rails application into a retro
software catalog: catalog models, a CI-pipeline-callable release updater, a
public read-only JSON API, and optional ActiveAdmin resources that plug into
your app's existing admin.
## What it provides
Repository: `https://git.teletypegames.org/tools/warp_engine`
- **Models**: `Software`, `Release`, `ReleaseAsset`, `ExternalLink`,
`PlatformLink`, `Image`, `SoftwareImage`, `Download` (all under
`WarpEngine::`, with unprefixed table names)
- **Updater**: `GET /update?platform=&name=&version=` (auth via the
`X-Update-Secret` header or `?secret=`) — CI copies build artifacts under
`file_container_path` using the `<name>-<version>*` naming convention, then
calls the endpoint; the updater extracts archives, parses metadata, and
upserts the Software/Release/ReleaseAsset/ExternalLink records.
Supported platforms: tic80, ebitengine, love, c64, godot, bevy, phaser.
- **Public API**: `/api/software`, `/api/software/highlighted`, `/api/builds`,
`/api/softwares/:name/builds`, `/api/image/:id`, `/api/download?path=`,
`/file/*path`
- **Admin**: ActiveAdmin resource files (softwares with a 3-level nested form,
releases, external links, platform links, images with orphan management, a
Files file-manager page with picker mode, download stats) — loaded into the
host's single ActiveAdmin instance.
## Features
- **Catalog domain**: `Software`, `Release`, `ReleaseAsset`, `ExternalLink`,
`PlatformLink`, `Image`, `SoftwareImage`, `Download` models with soft-delete
semantics and download statistics.
- **CI-callable updater**: your build pipeline uploads artifacts over HTTP
and calls one endpoint — WarpEngine extracts archives, parses metadata
and upserts the catalog records. Supported platforms out of the
box: TIC-80, Ebitengine, LÖVE, C64, Godot, Bevy, Phaser. Authenticated by
a shared secret or by per-owner database tokens with expiry and scopes
(`ApplicationToken`, managed in the admin).
- **Public JSON API**: catalog listing, highlighted title, per-platform build
matrix, image serving, download tracking, and a static file server for
web-playable builds.
- **Admin (optional)**: if the host runs ActiveAdmin, WarpEngine contributes
ready-made resources — a catalog editor with nested release/asset forms, an
image library with orphan cleanup, a file manager with a picker mode, and
download statistics. Without ActiveAdmin the engine runs headless
(API + updater only).
## Requirements
- Rails >= 8.0
- A relational database (developed and tested against MySQL 8)
- Optional: ActiveAdmin + Devise in the host app for the admin UI
## Example stack (docker compose)
`examples/compose` boots everything the engine's workflow assumes, end to
end: the catalog app itself and — behind a compose profile — a Gitea forge
with Woodpecker CI, so you can watch a pipeline publish a release into the
catalog.
| Service | Role | Where |
| --- | --- | --- |
| `app` | Minimal Rails host with the engine mounted as a path gem (headless: API + updater) | `http://localhost:8080` |
| `mysql` | Catalog database | internal |
| `gitea` | Git forge (profile `ci`) | `http://gitea:3000` |
| `woodpecker` + agent | CI wired to gitea (profile `ci`) | `http://woodpecker:8000` |
### Quickstart — catalog only
```sh
cd examples/compose
cp .env.example .env # defaults work for a throwaway local demo
docker compose up --build
```
The first boot takes a few minutes: the app container bundles, runs
`rails g warp_engine:install` and `rails db:prepare`, then serves on
`http://localhost:8080`:
- `http://localhost:8080/api/software` — the (empty) catalog
- `http://localhost:8080/api/builds` — the platform build matrix
- `http://localhost:8080/api/docs` — apipie API docs
### Publish a release by hand
The updater contract is nothing but a handful of HTTP calls, so you can play
the role of the CI pipeline yourself:
```sh
# 1. Fake a build: metadata, a web build and a windows artifact, named by
# convention (the love platform requires the .html.zip web build)
cat > demo-0.1.0.metadata.json <<'JSON'
{ "name": "demo", "title": "Demo Game", "author": "You", "desc": "Hello", "license": "MIT" }
JSON
echo '<h1>demo</h1>' > index.html && zip demo-0.1.0.html.zip index.html
echo hello > game.bin && zip demo-0.1.0-win-x64.zip game.bin
# 2. Upload them (one request per file)
for f in demo-0.1.0.*; do
curl -fs -H "X-Update-Secret: example-update-secret" \
-F "file=@$f" "http://localhost:8080/build/upload?name=demo&version=0.1.0"
done
# 3. Publish the release
curl -X POST -H "X-Update-Secret: example-update-secret" \
"http://localhost:8080/build/publish?platform=love&name=demo&version=0.1.0"
```
`GET /api/software` now lists *Demo Game* with `html` and `win_x64` assets,
`http://localhost:8080/file/demo-0.1.0/index.html` serves the extracted web
build, and `GET /api/download?path=demo-0.1.0-win-x64.zip` serves the
artifact while logging a download record.
### Full loop — forge + CI (profile `ci`)
gitea and woodpecker address each other by service name, so let your browser
resolve those names too:
```sh
echo "127.0.0.1 gitea woodpecker" | sudo tee -a /etc/hosts
```
1. `docker compose --profile ci up -d gitea`, open `http://gitea:3000`,
finish the install wizard (SQLite is fine) and create your admin user.
2. In gitea: *Settings → Applications → Manage OAuth2 Applications*, create
an app with redirect URI `http://woodpecker:8000/authorize`; copy the
client id/secret into `WOODPECKER_GITEA_CLIENT` / `WOODPECKER_GITEA_SECRET`
in `.env`.
3. `docker compose --profile ci up -d` — then log in at
`http://woodpecker:8000` (OAuth via gitea) and enable your repository.
A pipeline publishes a release exactly like the by-hand steps above — build,
upload, publish:
```yaml
# .woodpecker.yaml in a game repo hosted on the example gitea
steps:
publish:
image: alpine
environment:
UPDATE_SECRET:
from_secret: update_secret
commands:
- apk add --no-cache curl zip
- # ... build your game, produce mygame-1.0.0.metadata.json + artifacts ...
- for f in mygame-1.0.0.*; do curl -fs -H "X-Update-Secret: $UPDATE_SECRET" -F "file=@$f" "http://app:3000/build/upload?name=mygame&version=1.0.0"; done
- curl -fs -X POST -H "X-Update-Secret: $UPDATE_SECRET" "http://app:3000/build/publish?platform=love&name=mygame&version=1.0.0"
```
(The agent attaches pipeline containers to the stack network, so `app`
resolves. For real projects, the per-platform
[`tools/*-tools`](https://git.teletypegames.org) repos ship ready-made
Makefile + pipeline templates implementing this contract.)
Tear the stack down with `docker compose --profile ci down -v`.
## Installation
From the git repository:
```ruby
# Gemfile
gem "warp_engine", path: "../../libs/ruby/warp_engine"
gem "warp_engine", git: "https://git.teletypegames.org/tools/warp_engine.git"
```
Or from the Forgejo rubygems registry (tagged releases):
```ruby
source "https://git.teletypegames.org/api/packages/tools/rubygems" do
gem "warp_engine"
end
```
Then:
```sh
rails g warp_engine:install # initializer + create_warp_engine_tables migration
rails db:migrate
```
```ruby
# config/routes.rb — keep it the last entry so host routes win
# config/routes.rb — keep it the last entry so your own routes win
mount WarpEngine::Engine => "/"
```
## Configuration
```ruby
# config/initializers/warp_engine.rb
Rails.application.config.to_prepare do
WarpEngine.configure do |c|
# Where CI drops build artifacts and where images are stored
c.file_container_path = ENV.fetch("FILE_CONTAINER_PATH", "/softwares")
c.image_container_path = ENV.fetch("IMAGE_CONTAINER_PATH", "/images")
c.update_secret = ENV["UPDATE_SECRET"] # nil => /update rejects everything
# If host models also reference catalog images:
c.image_owners = [
{
label: "member",
image_ids: -> { Member.where.not(image_id: nil).distinct.pluck(:image_id) },
usage_label: ->(image) { "member" if Member.where(image_id: image.id).exists? }
}
]
# Shared secret for the /build/* endpoints.
# nil => the endpoints reject every request.
c.update_secret = ENV["UPDATE_SECRET"]
# Authentication source for /build/* — an exclusive choice:
# :env — the shared secret above is accepted (default)
# :database — only WarpEngine::ApplicationToken records with the
# "update" scope are accepted; the shared secret stops
# working the moment you switch.
# :database mode also requires the owner class every token belongs to:
# c.application_token_source = :database
# c.application_token_owner_class = "AdminUser"
# Size cap for /build/upload and the admin file manager, in bytes (default 500MB).
# c.max_upload_size = 500 * 1024 * 1024
# Owner isolation: a database token may only upload/publish softwares
# owned by its own owner (unrestricted tokens are exempt). Enable only
# after backfilling owners — ownerless softwares are claimable by anyone.
# c.enforce_software_ownership = true
# If your app's own models reference catalog images, register them so the
# admin Images page counts them as "in use":
# c.image_owners = [
# {
# label: "member",
# image_ids: -> { Member.where.not(image_id: nil).distinct.pluck(:image_id) },
# usage_label: ->(image) { "member" if Member.where(image_id: image.id).exists? }
# }
# ]
end
end
```
## Host expectations
## The updater contract
- **ActiveAdmin + Devise live in the host**: authentication, theme, assets and
the `/admin` routes are the host's responsibility; the engine only appends
its resource files to `ActiveAdmin.application.load_paths`.
- **Files picker JS**: the file-picker next to release-asset path inputs relies
on a few lines of JS in the host's `active_admin.js` (an iframe pointing at
`/admin/files?picker=1&field=<dom_id>`) — copy that over to a new host too.
- **apipie**: if the host generates apipie docs, add the engine to the matcher:
`"#{WarpEngine::Engine.root}/app/controllers/**/*.rb"`.
Publishing a release from CI is two steps:
1. **Upload** build artifacts into `file_container_path`, named by convention:
`<name>-<version>.metadata.json`, `<name>-<version>.html.zip`,
`<name>-<version>-win-x64.zip`, `<name>-<version>.tic`, ... (each platform
declares which asset kinds it expects — see `GET /api/builds`). Push them
over HTTP — one request per file, `upload` scope, optional `sha256`
integrity check:
```sh
curl -H "X-Update-Secret: $UPDATE_SECRET" \
-F "file=@mygame-1.2.0.html.zip" \
"https://your-host/build/upload?name=mygame&version=1.2.0"
```
2. **Publish the release**:
```sh
curl -X POST -H "X-Update-Secret: $UPDATE_SECRET" \
"https://your-host/build/publish?platform=tic80&name=mygame&version=1.2.0"
```
WarpEngine extracts the archives, parses the metadata (JSON, or the Lua
comment header for TIC-80), and upserts the `Software`, `ExternalLink`,
`Release` and `ReleaseAsset` records in a single transaction. Previously
deleted records are resurrected on re-ingest.
### Updater authentication
The `X-Update-Secret` header carries one of two credentials, selected by
`application_token_source` — the modes are exclusive, the endpoint never
accepts both:
- **`:env`** (default): the single shared secret from `update_secret`.
- **`:database`**: `WarpEngine::ApplicationToken` records. Each token
belongs to an owner (the class named by `application_token_owner_class`,
e.g. `AdminUser`), carries a free-form scope list — publishing requires
the `"update"` scope, `/build/upload` the `"upload"` scope — and an
optional expiry. Tokens are created in the admin
(*App Tokens*): the plain token is generated server-side and shown exactly
once after creation; only its SHA256 digest is stored. Deleting a token in
the admin revokes it (soft delete), and `last_used_at` records when each
token last authenticated successfully.
When switching to `:database`, create the tokens and move your pipelines to
them first — the flip invalidates the shared secret immediately.
## CI pipeline configs (Woodpecker)
WarpEngine can act as a [Woodpecker configuration extension](https://woodpecker-ci.org/docs/usage/extensions/configuration-extension):
instead of a copy-pasted `.woodpecker.yaml` in every game repo, the repo holds a
one-line marker and the engine serves the full per-platform pipeline
(version → build → upload → publish, calling `/build/upload` + `/build/publish`
with the `application_token` Woodpecker secret):
```yaml
# .woodpecker.yaml in a game repo
platform: godot
```
- `POST /build/config` — the extension endpoint Woodpecker calls on every
pipeline start (httpsig/ed25519-signed request, verified against
`ci_extension_public_key(_url)`). Non-marker configs get a `204` so the
repo's own YAML keeps running — opt-in migration, and putting a full
pipeline back into the repo is the opt-out.
- `GET /build/config?platform=godot` — renders the same pipeline as a preview.
Configuration: `ci_platforms` maps platform names to builder images
(`{ "godot" => { builder: "..." }, "tic80" => { builder: ..., exporter: ... } }`);
an empty map (default) disables the feature. Set the Woodpecker side with
`WOODPECKER_CONFIG_EXTENSION_ENDPOINT=https://your-host/build/config` (or
per-repo in Settings → Extensions). Templates live in
`lib/warp_engine/ci_templates/*.yaml.erb`.
## Public API
| Endpoint | Purpose |
| --- | --- |
| `GET /api/software` | Full catalog with releases, assets, links, download counts; `?owner_id=` filters to one publisher |
| `GET /api/software/highlighted` | The currently highlighted title |
| `GET /api/builds` | Expected asset kinds per platform (build matrix) |
| `GET /api/softwares/:name/builds` | Actual vs. missing build assets per release |
| `GET /api/image/:id` | Serves catalog images |
| `GET /api/download?path=` | Serves an artifact and logs a download record |
| `GET /file/*path` | Serves static build output (web-playable games, docs) |
## Admin integration
The host owns the single ActiveAdmin instance — authentication (Devise),
theme, assets and the `/admin` routes. WarpEngine only appends its resource
files to `ActiveAdmin.application.load_paths`. Two things to copy into a new
host:
- the small file-picker JS for release-asset path inputs (an iframe pointing
at `/admin/files?picker=1&field=<dom_id>`) in your `active_admin.js`;
- if you generate apipie docs, add
`"#{WarpEngine::Engine.root}/app/controllers/**/*.rb"` to your
`api_controllers_matcher`.
## Behavioral notes
- Every model is soft-deleted (`default_scope { where(deleted_at: nil) }`);
the updater "resurrects" re-submitted, previously deleted records via
`.unscoped`.
- The JSON shape is intentionally bug-compatible with the former Go backend
(Go zero-time timestamps, camelCase keys, legacy flat path fields).
- Every model is soft-deleted (`default_scope { where(deleted_at: nil) }`).
- The JSON shape is stable and intentionally bug-compatible with the project's
former Go backend (Go zero-time timestamps, camelCase keys, legacy flat
path fields).
- Model extension points: `ActiveSupport.on_load(:warp_engine_<model>)` hooks.
## Tests
The engine ships an RSpec suite running against a bundled dummy app:
```sh
bundle install
bundle exec rake app:db:prepare RAILS_ENV=test # warp_engine_test DB for the dummy app
bundle exec rake app:db:prepare RAILS_ENV=test
bundle exec rspec
```
## Development
This repository is a **read-only split mirror** — development happens in the
[`tools/teletypegames`](https://git.teletypegames.org/tools/teletypegames)
monorepo under `libs/ruby/warp_engine`, and CI republishes the mirror on every
change. Please do not open pull requests against the mirror.
@@ -0,0 +1,99 @@
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"
config.sort_order = "created_at_desc"
config.batch_actions = false
scope :all, default: true
scope("Active") { |scope| scope.where("expires_at IS NULL OR expires_at > ?", Time.current) }
scope("Expired") { |scope| scope.where("expires_at <= ?", Time.current) }
index do
id_column
column :name
column("Token") { |t| code "#{t.token_prefix}", style: "font-family:monospace;" }
column("Owner") { |t| t.owner.try(:email) || t.owner.try(:name) || "#{t.owner_type} ##{t.owner_id}" }
column("Scopes") { |t| t.scopes_string }
column :unrestricted
column :expires_at
column :last_used_at
column :created_at
actions
end
filter :name_cont, label: "Name"
filter :token_prefix_cont, label: "Token prefix"
filter :expires_at
filter :last_used_at
form do |f|
owner_class = WarpEngine.config.application_token_owner_class&.safe_constantize
f.inputs do
if f.object.new_record?
if owner_class
f.input :owner_id, as: :select, label: owner_class.name,
collection: owner_class.all.map { |o| [ o.try(:email) || o.try(:name) || "##{o.id}", o.id ] },
include_blank: false
else
f.template.concat(f.template.content_tag(:li,
"application_token_owner_class is not configured — tokens cannot be created.",
class: "flash flash_error"))
end
end
f.input :name
f.input :scopes_string, label: "Scopes (comma separated)",
hint: %(The "update" scope is required for /build/publish, the "upload" scope for /build/upload.)
f.input :unrestricted, hint: "Internal token: exempt from owner isolation (enforce_software_ownership)."
f.input :expires_at, hint: "Leave empty for a token that never expires."
end
f.actions
end
show do
if (plain = controller.instance_variable_get(:@plain_token))
panel "⚠️ Token — shown only once, copy it now!" do
pre plain, style: "font-family:monospace;font-size:14px;padding:8px;background:#fff3cd;user-select:all;"
end
end
attributes_table do
row :id
row :name
row("Token") { |t| code "#{t.token_prefix}… (SHA256 digest stored)" }
row("Owner") { |t| "#{t.owner_type} ##{t.owner_id}#{t.owner.try(:email) || t.owner.try(:name)}" }
row("Scopes") { |t| t.scopes_string }
row :unrestricted
row :expires_at
row :last_used_at
row :created_at
row :updated_at
end
end
controller do
# The plain token only exists right after creation; it travels via the
# session to its one-time display (flash is unsuitable: the AA layout
# renders every flash key as a message bar).
def create
create! do |success, _failure|
success.html do
session[:warp_engine_plain_token] = resource.plain_token
redirect_to resource_path(resource) and return
end
end
end
def show
@plain_token = session.delete(:warp_engine_plain_token)
show!
end
# Revoke = soft delete, audit-nyommal.
def destroy
resource.revoke!
redirect_to collection_path, notice: "Token revoked."
end
end
end
@@ -0,0 +1,71 @@
module WarpEngine
# Token authentication for the publishing (/build/*) endpoints.
# The auth source is exclusive: in :database mode the shared secret is not
# accepted, in :env mode DB tokens are not.
module UpdateAuthentication
extend ActiveSupport::Concern
private
attr_reader :current_application_token
# The token is accepted from the X-Update-Secret header only — in the URL
# it would leak into proxy and access logs.
def update_authorized?(required_scope:)
token = request.headers["X-Update-Secret"].presence
return false if token.blank?
case WarpEngine.config.application_token_source
when :database then database_token_authorized?(token, required_scope)
else env_secret_authorized?(token)
end
end
def env_secret_authorized?(token)
expected = WarpEngine.config.update_secret
# With no secret configured the endpoint stays closed.
expected.present? && ActiveSupport::SecurityUtils.secure_compare(token, expected)
end
def database_token_authorized?(token, required_scope)
if WarpEngine.config.application_token_owner_class.blank?
Rails.logger.error("[#{self.class.name}] application_token_source=:database but application_token_owner_class is not set — rejecting every request")
return false
end
record = WarpEngine::ApplicationToken.authenticate(token, required_scope: required_scope)
return false if record.nil?
record.touch_last_used!
@current_application_token = record
true
end
# Ownership enforcement applies only in :database mode (there is a token)
# with enforce_software_ownership on. An ownerless software is up for grabs
# until the backfill — backfill before enabling the enforcement.
def software_ownership_authorized?(name)
return true unless WarpEngine.config.enforce_software_ownership
token = current_application_token
return true if token.nil? || token.unrestricted?
software = WarpEngine::Software.find_by(name: name)
return true if software.nil? || software.owner_id.nil?
software.owner_type == token.owner_type && software.owner_id == token.owner_id
end
# A first-published (or pre-backfill, ownerless) software gets the
# submitting token's owner. Unrestricted (internal) tokens claim nothing.
def claim_software_ownership(name)
token = current_application_token
return if token.nil? || token.unrestricted?
software = WarpEngine::Software.find_by(name: name)
return if software.nil? || software.owner_id.present?
software.update_columns(owner_type: token.owner_type, owner_id: token.owner_id)
end
end
end
@@ -29,6 +29,7 @@ module WarpEngine
end
api :GET, "/api/software", "List all software entries with releases"
param :owner_id, :number, required: false, desc: "Filter to the softwares of one owner (publisher)"
returns code: 200, desc: "Wrapper object with softwares array" do
property :softwares, Array, desc: "Array of software entries" do
property :ID, Integer, desc: "Software ID"
@@ -55,7 +56,7 @@ module WarpEngine
end
end
def index
render json: WarpEngine::SoftwareService.new.index
render json: WarpEngine::SoftwareService.new.index(owner_id: params[:owner_id])
end
end
end
@@ -0,0 +1,83 @@
module WarpEngine
module Build
# Woodpecker configuration-extension endpoint: on every pipeline start the
# CI server POSTs the repo's marker file and receives the platform's full
# pipeline YAML. GET renders the same thing as a preview.
class ConfigsController < ApiController
resource_description do
short "Woodpecker CI pipeline configs"
end
api :GET, "/build/config", "Preview the generated pipeline config for a platform"
param :platform, String, required: true, desc: "Platform (a configured ci_platforms key, e.g. tic80)"
param :name, String, required: false, desc: "Software name substituted into the pipeline (default: example)"
returns code: 200, desc: "Pipeline YAML (text/yaml)"
error code: 404, desc: "Unknown platform"
def show
yaml = render_config(platform: params[:platform], name: params[:name].presence || "example")
return render json: { error: "Unknown platform" }, status: :not_found if yaml.nil?
render plain: yaml, content_type: "text/yaml"
end
api :POST, "/build/config", "Woodpecker configuration extension endpoint"
description <<~DESC
Called by the Woodpecker server on every pipeline start (httpsig-signed request).
If the repo's .woodpecker.yaml is a marker (has a `platform:` key), responds with
the generated pipeline; otherwise responds 204 so the repo's own config runs.
DESC
returns code: 200, desc: %(JSON: {"configs": [{"name": ..., "data": "<pipeline YAML>"}]})
returns code: 204, desc: "Not a marker config — keep the repo's own configuration"
error code: 403, desc: "Missing or invalid request signature"
error code: 422, desc: "Marker requests an unknown platform"
def create
unless WarpEngine::CiSignatureVerifier.new(request).valid?
return render json: { error: "Invalid signature" }, status: :forbidden
end
marker = find_marker
return head :no_content if marker.nil?
platform = marker["platform"].to_s
name = marker["name"].presence || repo_name
yaml = render_config(platform: platform, name: name)
if yaml.nil?
return render json: { error: "Unknown platform: #{platform}" }, status: :unprocessable_entity
end
render json: { configs: [ { name: platform, data: yaml } ] }
end
private
def render_config(platform:, name:)
WarpEngine::CiConfigService.new.render(
platform: platform,
name: name,
update_server: WarpEngine.config.ci_update_server.presence || request.base_url
)
end
# The first submitted config that parses as a marker (Hash with a `platform`
# key). The docs call the key "configuration", the example-config-service
# uses "configs" — accept both.
def find_marker
configs = params[:configuration].presence || params[:configs].presence || []
configs.each do |config|
data = config[:data].to_s
parsed = begin
YAML.safe_load(data)
rescue Psych::Exception
nil
end
return parsed if parsed.is_a?(Hash) && parsed.key?("platform")
end
nil
end
def repo_name
params.dig(:repo, :name).to_s
end
end
end
end
@@ -0,0 +1,45 @@
module WarpEngine
module Build
class PublishController < ApiController
include UpdateAuthentication
resource_description do
short "Build release publishing"
end
api :POST, "/build/publish", "Register an uploaded build as a release"
header "X-Update-Secret", "Shared secret or application token (update scope)", required: true
param :name, String, required: true, desc: "Software name"
param :platform, String, required: true, desc: "Platform (tic80, love, ebitengine, c64, godot, bevy, phaser)"
param :version, String, required: true, desc: "Version string"
returns code: 200, desc: "JSON with the published name/platform/version"
error code: 401, desc: "Invalid secret"
error code: 403, desc: "Token does not own this software"
error code: 400, desc: "Missing or invalid arguments"
def create
unless update_authorized?(required_scope: WarpEngine::ApplicationToken::UPDATE_SCOPE)
return render json: { error: "Unauthorized" }, status: :unauthorized
end
%i[name platform version].each do |key|
return render json: { error: "#{key.to_s.capitalize} not provided" }, status: :bad_request if params[key].blank?
end
unless software_ownership_authorized?(params[:name])
return render json: { error: "Forbidden" }, status: :forbidden
end
input = WarpEngine::UpdateInputDto.new(
platform: params[:platform],
name: params[:name],
version: params[:version]
)
WarpEngine::UpdateService.new.update(input)
claim_software_ownership(params[:name])
render json: { published: true, name: params[:name], platform: params[:platform], version: params[:version] }
end
end
end
end
@@ -0,0 +1,65 @@
require "digest"
module WarpEngine
module Build
class UploadsController < ApiController
include UpdateAuthentication
resource_description do
short "Build artifact upload"
end
# Release file naming convention: <name>-<version>.<ext> or
# <name>-<version>-<target>.zip — the updater looks for these too.
NAME_FORMAT = /\A[A-Za-z0-9._-]+\z/
api :POST, "/build/upload", "Upload a build artifact into the artifact directory"
header "X-Update-Secret", "Shared secret or application token (upload scope)", required: true
param :name, String, required: true, desc: "Software name (filename must be prefixed with <name>-<version>)"
param :version, String, required: true, desc: "Version string"
param :file, File, required: true, desc: "Artifact file (multipart)"
param :sha256, String, required: false, desc: "Expected SHA256 hex digest; on mismatch the upload is rejected"
returns code: 200, desc: "JSON with stored file name, size and sha256"
error code: 401, desc: "Invalid secret"
error code: 403, desc: "Token does not own this software"
error code: 400, desc: "Missing or invalid arguments"
error code: 413, desc: "File larger than max_upload_size"
error code: 422, desc: "SHA256 mismatch"
def create
unless update_authorized?(required_scope: WarpEngine::ApplicationToken::UPLOAD_SCOPE)
return render json: { error: "Unauthorized" }, status: :unauthorized
end
name = params[:name].to_s
version = params[:version].to_s
file = params[:file]
return render json: { error: "Invalid name" }, status: :bad_request unless name.match?(NAME_FORMAT)
return render json: { error: "Invalid version" }, status: :bad_request unless version.match?(NAME_FORMAT)
return render json: { error: "File not provided" }, status: :bad_request unless file.respond_to?(:original_filename)
unless software_ownership_authorized?(name)
return render json: { error: "Forbidden" }, status: :forbidden
end
filename = File.basename(file.original_filename.to_s)
unless filename.start_with?("#{name}-#{version}.", "#{name}-#{version}-")
return render json: { error: "Filename must be prefixed with #{name}-#{version}" }, status: :bad_request
end
max = WarpEngine.config.max_upload_size
if file.size > max
return render json: { error: "File too large (max #{max / (1024 * 1024)}MB)" }, status: :payload_too_large
end
digest = Digest::SHA256.file(file.tempfile.path).hexdigest
if params[:sha256].present? && !ActiveSupport::SecurityUtils.secure_compare(params[:sha256].downcase, digest)
return render json: { error: "SHA256 mismatch" }, status: :unprocessable_entity
end
stored = WarpEngine::FileManagerService.new.upload("", file)
render json: { file: stored, size: file.size, sha256: digest }
end
end
end
end
@@ -1,49 +0,0 @@
module WarpEngine
class UpdateController < ApiController
resource_description do
short "Software updater"
formats [ "text" ]
end
rescue_from ArgumentError do |e|
render plain: e.message, status: :bad_request
end
rescue_from StandardError do |e|
Rails.logger.error("[UpdateController] #{e.class}: #{e.message}\n#{e.backtrace.first(5).join("\n")}")
render plain: "Internal server error", status: :internal_server_error
end
api :GET, "/update", "Update software version in database"
param :secret, String, required: true, desc: "Authorization secret"
param :platform, String, required: false, desc: "Platform (tic80, love, ebitengine, c64, godot, bevy, phaser)"
param :name, String, required: false, desc: "Software name"
param :version, String, required: true, desc: "Version string"
returns code: 200, desc: "Plain text 'Updated'"
error code: 401, desc: "Invalid secret"
error code: 400, desc: "Version not provided or invalid arguments"
error code: 500, desc: "Internal server error"
def update
return render plain: "Unauthorized", status: :unauthorized unless authorized?
return render plain: "Version not provided", status: :bad_request if params[:version].blank?
input = WarpEngine::UpdateInputDto.new(
platform: params[:platform],
name: params[:name],
version: params[:version]
)
WarpEngine::UpdateService.new.update(input)
render plain: "Updated"
end
private
def authorized?
secret = request.headers["X-Update-Secret"].presence || params[:secret]
expected = WarpEngine.config.update_secret
# Konfigurálatlan secret esetén az endpoint zárva marad.
expected.present? && secret == expected
end
end
end
@@ -0,0 +1,100 @@
require "digest"
module WarpEngine
class ApplicationToken < ApplicationRecord
self.table_name = "application_tokens"
UPDATE_SCOPE = "update".freeze
UPLOAD_SCOPE = "upload".freeze
# The generated token is only available in memory at creation time — the DB
# stores nothing but the SHA256 digest and the non-secret prefix.
attr_reader :plain_token
belongs_to :owner, polymorphic: true
default_scope { where(deleted_at: nil) }
scope :active, -> { where("expires_at IS NULL OR expires_at > ?", Time.current) }
before_validation :assign_owner_type, on: :create
before_validation :generate_token, on: :create
after_initialize { self.scopes = [] if new_record? && scopes.nil? }
validates :name, presence: true
validates :token_digest, presence: true, uniqueness: true
validates :token_prefix, presence: true
validates :scopes, presence: true
validate :owner_type_matches_configuration
def self.digest(token)
Digest::SHA256.hexdigest(token)
end
# The live (not deleted, not expired) token carrying the required scope, else nil.
def self.authenticate(token, required_scope: nil)
return nil if token.blank?
record = active.find_by(token_digest: digest(token))
return nil if record.nil?
return nil if required_scope.present? && !Array(record.scopes).include?(required_scope)
record
end
def expired?
expires_at.present? && expires_at <= Time.current
end
# Revocation = soft delete, the audit trail stays.
def revoke!
update_column(:deleted_at, Time.current)
end
def touch_last_used!
update_column(:last_used_at, Time.current)
end
# Admin form: comma separated scope list
def scopes_string
Array(scopes).join(", ")
end
def scopes_string=(value)
self.scopes = value.to_s.split(",").map(&:strip).reject(&:blank?).uniq
end
def self.ransackable_attributes(auth_object = nil)
%w[created_at deleted_at expires_at id last_used_at name owner_id owner_type token_prefix unrestricted updated_at]
end
# Ransack cannot filter on the polymorphic owner association.
def self.ransackable_associations(auth_object = nil)
[]
end
private
def assign_owner_type
self.owner_type = WarpEngine.config.application_token_owner_class if owner_type.blank?
end
def generate_token
return if token_digest.present?
@plain_token = SecureRandom.hex(24)
self.token_prefix = @plain_token.first(8)
self.token_digest = self.class.digest(@plain_token)
end
def owner_type_matches_configuration
expected = WarpEngine.config.application_token_owner_class
if expected.blank?
errors.add(:base, "application_token_owner_class is not configured")
elsif owner_type != expected
errors.add(:owner_type, "must be #{expected}")
end
end
ActiveSupport.run_load_hooks(:warp_engine_application_token, self)
end
end
@@ -2,6 +2,10 @@ module WarpEngine
class Software < ApplicationRecord
self.table_name = "softwares"
# Owner of the publishing token (e.g. AdminUser) — for 3rd-party isolation,
# see enforce_software_ownership. nil = internal / pre-backfill software.
belongs_to :owner, polymorphic: true, optional: true
has_many :software_images, foreign_key: :software_id, dependent: :destroy
has_many :images, through: :software_images
has_many :releases, foreign_key: :software_id
@@ -19,7 +23,7 @@ module WarpEngine
default_scope { where(deleted_at: nil) }
def self.ransackable_attributes(auth_object = nil)
%w[author created_at desc highlighted id license name platform site status story title updated_at]
%w[author created_at desc highlighted id license name owner_id owner_type platform site status story title updated_at]
end
def self.ransackable_associations(auth_object = nil)
@@ -14,6 +14,8 @@ module WarpEngine
field(:license) { |sw| sw.license.to_s }
field :platform
field :status
# Public owner id — for the /api/software?owner_id= filter.
field(:ownerId) { |sw| sw.owner_id }
field(:highlighted) { |sw| sw.highlighted ? true : false }
field(:externalLinks) { |sw| ExternalLinkSerializer.render_as_hash(sw.external_links) }
field(:platformLinks) { |sw| PlatformLinkSerializer.render_as_hash(WarpEngine::PlatformLink.for_platform(sw.platform)) }
@@ -0,0 +1,40 @@
require "erb"
module WarpEngine
# Renders the /build/config platform templates: the pipeline logic lives in
# lib/warp_engine/ci_templates/<platform>.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}.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("lib", "warp_engine", "ci_templates")
end
end
end
@@ -0,0 +1,90 @@
require "openssl"
require "base64"
require "net/http"
module WarpEngine
# Verifies the httpsig signature of Woodpecker configuration-extension
# requests (draft-cavage http-signatures, ed25519). The server sends the
# signed header list in the Signature header — typically "(request-target) date".
class CiSignatureVerifier
SIGNATURE_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
params = signature_params
return false if params.nil? || params["signature"].blank?
signing_string = build_signing_string(params.fetch("headers", "date"))
return false if signing_string.nil?
key = OpenSSL::PKey.read(pem)
key.verify(nil, Base64.decode64(params["signature"]), signing_string)
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
# Parameters of the Signature header (or the "Authorization: Signature ..." form).
def signature_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(SIGNATURE_PARAM).to_h
end
def build_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
@@ -22,10 +22,9 @@ module WarpEngine
end
end
MAX_UPLOAD_SIZE = 100 * 1024 * 1024 # 100MB
def upload(relative_dir, uploaded_file)
raise ArgumentError, "File too large (max 100MB)" if uploaded_file.size > MAX_UPLOAD_SIZE
max = WarpEngine.config.max_upload_size
raise ArgumentError, "File too large (max #{max / (1024 * 1024)}MB)" if uploaded_file.size > max
dir = safe_path!(relative_dir)
raise ArgumentError, "Not a directory" unless dir.directory?
@@ -2,8 +2,9 @@ module WarpEngine
class SoftwareService
include SoftwareResponseBuilder
def index
def index(owner_id: nil)
softwares = WarpEngine::Software.includes(releases: [ :release_assets ]).includes(:external_links, :software_images).all
softwares = softwares.where(owner_id: owner_id) if owner_id.present?
counts = download_counts_for(softwares.flat_map { |sw| sw.releases.map(&:id) })
{ softwares: softwares.map { |sw| build_response(sw, sw.releases.to_a, counts) } }
end
+5 -1
View File
@@ -8,6 +8,10 @@ WarpEngine::Engine.routes.draw do
get "softwares/:name/builds", to: "software_builds#show"
end
get "update", to: "update#update"
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
@@ -0,0 +1,22 @@
class CreateApplicationTokens < ActiveRecord::Migration[8.1]
def change
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
# 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
t.string :token_prefix, limit: 12, null: false
t.json :scopes
t.datetime :expires_at, precision: 3
t.datetime :last_used_at, precision: 3
t.datetime :deleted_at, precision: 3
t.timestamps precision: 3, null: true
t.index :token_digest, name: "idx_application_tokens_token_digest", unique: true
t.index [ :owner_type, :owner_id ], name: "idx_application_tokens_owner"
t.index :deleted_at, name: "idx_application_tokens_deleted_at"
end
end
end
@@ -0,0 +1,12 @@
class AddBuildOwnership < ActiveRecord::Migration[8.1]
def change
# 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 = internal token: exempt from enforce_software_ownership.
add_column :application_tokens, :unrestricted, :boolean, default: false, null: false
end
end
@@ -0,0 +1,20 @@
# Copy to .env and adjust. Every value except the OAuth pair has a working
# default for a throwaway local demo, so the quickstart runs without edits.
MYSQL_ROOT_PASSWORD=warpengine
# Shared secret for the /build/* endpoints (X-Update-Secret header).
UPDATE_SECRET=example-update-secret
# Published ports.
APP_PORT=8080
GITEA_SSH_PORT=2223
# --- profile "ci" only -------------------------------------------------------
WOODPECKER_AGENT_SECRET=example-agent-secret
# OAuth2 application created in gitea (Settings -> Applications), redirect URI
# http://woodpecker:8000/authorize — required before the ci profile starts.
WOODPECKER_GITEA_CLIENT=
WOODPECKER_GITEA_SECRET=
@@ -0,0 +1,111 @@
# Example stack: everything WarpEngine needs to come alive, end to end.
#
# mysql the catalog database
# app a minimal Rails host with the engine mounted from this
# repo checkout (headless: API + updater, no ActiveAdmin)
# gitea (profile "ci") the git forge
# woodpecker (profile "ci") CI server + agent, wired to gitea
#
# Quickstart (catalog only):
# cp .env.example .env
# docker compose up --build
#
# Full loop with forge + CI:
# docker compose --profile ci up --build
#
# See ../../README.md ("Example stack") or the WarpEngine wiki page for the
# full walkthrough, including the one-time gitea/woodpecker OAuth wiring.
services:
mysql:
image: mysql:8
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
timeout: 20s
retries: 10
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-warpengine}
MYSQL_DATABASE: warp_engine_example
volumes:
- mysql-data:/var/lib/mysql
app:
build: ./host_app
ports:
- "${APP_PORT:-8080}:3000"
environment:
RAILS_ENV: development
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-warpengine}
UPDATE_SECRET: ${UPDATE_SECRET:-example-update-secret}
FILE_CONTAINER_PATH: /softwares
IMAGE_CONTAINER_PATH: /images
depends_on:
mysql:
condition: service_healthy
volumes:
- ./host_app:/app
- ../..:/warp_engine # the engine itself, consumed as a path gem
- bundle:/usr/local/bundle
- softwares:/softwares
- images:/images
# --- profile "ci": the forge + CI producing releases for the catalog ------
#
# gitea and woodpecker refer to each other by their service names, so your
# browser needs to resolve those names too:
# echo "127.0.0.1 gitea woodpecker" | sudo tee -a /etc/hosts
# gitea: http://gitea:3000 woodpecker: http://woodpecker:8000
gitea:
image: gitea/gitea:1.27.0
profiles: ["ci"]
environment:
DISABLE_REGISTRATION: "true"
ROOT_URL: "http://gitea:3000"
ports:
- "3000:3000"
- "${GITEA_SSH_PORT:-2223}:22"
volumes:
- gitea-data:/data
woodpecker:
image: woodpeckerci/woodpecker-server:v3.16.0
profiles: ["ci"]
environment:
WOODPECKER_HOST: "http://woodpecker:8000"
WOODPECKER_OPEN: "true"
WOODPECKER_GITEA: "true"
WOODPECKER_GITEA_URL: "http://gitea:3000"
# Create an OAuth2 app in gitea first — see the README walkthrough.
WOODPECKER_GITEA_CLIENT: ${WOODPECKER_GITEA_CLIENT:-}
WOODPECKER_GITEA_SECRET: ${WOODPECKER_GITEA_SECRET:-}
WOODPECKER_SERVER_ADDR: ":8000"
WOODPECKER_AGENT_SECRET: ${WOODPECKER_AGENT_SECRET:-example-agent-secret}
ports:
- "8000:8000"
volumes:
- woodpecker-data:/var/lib/woodpecker
woodpecker-agent:
image: woodpeckerci/woodpecker-agent:v3.16.0
profiles: ["ci"]
environment:
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 and the app by service name.
WOODPECKER_BACKEND_DOCKER_NETWORK: warp-example
volumes:
- /var/run/docker.sock:/var/run/docker.sock
networks:
default:
name: warp-example
volumes:
mysql-data:
bundle:
softwares:
images:
gitea-data:
woodpecker-data:
@@ -0,0 +1,6 @@
# Generated on first boot by the entrypoint (install generator + db:prepare).
db/migrate/
db/schema.rb
log/
tmp/
Gemfile.lock
@@ -0,0 +1,20 @@
FROM ruby:3.3-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
default-libmysqlclient-dev \
git \
tzdata \
libyaml-dev \
pkg-config \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# The app source, the engine checkout (/warp_engine) and the bundle are all
# mounted at runtime by the compose file; the entrypoint bundles on first boot.
ENTRYPOINT ["./bin/docker-entrypoint"]
EXPOSE 3000
CMD ["bin/rails", "server", "-b", "0.0.0.0", "-p", "3000"]
@@ -0,0 +1,10 @@
source "https://rubygems.org"
gem "rails", "~> 8.0.0"
gem "mysql2", "~> 0.5"
gem "puma"
# In this example stack the engine comes straight from the repo checkout the
# compose file mounts at /warp_engine. A real host would use the git source or
# the Forgejo rubygems registry instead — see the engine README.
gem "warp_engine", path: ENV.fetch("WARP_ENGINE_PATH", "/warp_engine")
@@ -0,0 +1,3 @@
require_relative "config/application"
Rails.application.load_tasks
@@ -0,0 +1,16 @@
#!/bin/sh
set -e
bundle check || bundle install
# First boot: run the install generator the way a real host would. It creates
# the create_warp_engine_tables migration; --skip leaves our initializer alone.
if ! ls db/migrate/*create_warp_engine_tables* >/dev/null 2>&1; then
bin/rails generate warp_engine:install --skip
fi
bin/rails db:prepare
rm -f tmp/pids/server.pid
exec "$@"
@@ -0,0 +1,4 @@
#!/usr/bin/env ruby
APP_PATH = File.expand_path("../config/application", __dir__)
require_relative "../config/boot"
require "rails/commands"
@@ -0,0 +1,4 @@
require_relative "config/environment"
run Rails.application
Rails.application.load_server
@@ -0,0 +1,23 @@
require_relative "boot"
require "rails"
require "active_model/railtie"
require "active_record/railtie"
require "action_controller/railtie"
require "action_view/railtie"
require "action_dispatch/railtie"
Bundler.require(*Rails.groups)
require "warp_engine"
module HostApp
class Application < Rails::Application
config.load_defaults 8.0
config.time_zone = "UTC"
config.active_record.default_timezone = :utc
# Demo stack: reachable as localhost, gitea-network hostnames, etc.
config.hosts.clear
end
end
@@ -0,0 +1,3 @@
ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
require "bundler/setup"
@@ -0,0 +1,8 @@
development:
adapter: mysql2
encoding: utf8mb4
username: root
password: <%= ENV.fetch("MYSQL_ROOT_PASSWORD", "warpengine") %>
host: mysql
port: 3306
database: warp_engine_example
@@ -0,0 +1,3 @@
require_relative "application"
Rails.application.initialize!
@@ -0,0 +1,10 @@
Rails.application.configure do
config.enable_reloading = true
config.eager_load = false
config.consider_all_requests_local = true
config.active_record.migration_error = :page_load
config.active_record.verbose_query_logs = true
config.logger = ActiveSupport::Logger.new($stdout)
end
@@ -0,0 +1,11 @@
Apipie.configure do |config|
config.app_name = "WarpEngine Example Host"
config.api_base_url = ""
config.doc_base_url = "/api/docs"
config.api_controllers_matcher = [
"#{WarpEngine::Engine.root}/app/controllers/**/*.rb"
]
config.validate = false
config.translate = false
config.default_version = "1.0"
end
@@ -0,0 +1,9 @@
Rails.application.config.to_prepare do
WarpEngine.configure do |c|
c.file_container_path = ENV.fetch("FILE_CONTAINER_PATH", "/softwares")
c.image_container_path = ENV.fetch("IMAGE_CONTAINER_PATH", "/images")
# With no secret configured the /build/* endpoints reject every request.
c.update_secret = ENV["UPDATE_SECRET"]
end
end
@@ -0,0 +1,6 @@
Rails.application.routes.draw do
apipie
# Keep the engine mount the last entry so the host's own routes win.
mount WarpEngine::Engine => "/"
end
@@ -11,9 +11,14 @@ class CreateWarpEngineTables < ActiveRecord::Migration[8.0]
t.string :site
t.string :status, limit: 20, default: "development"
t.boolean :highlighted, default: false
# 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
t.timestamps precision: 3, null: true
t.index :name, unique: true
t.index [ :owner_type, :owner_id ]
t.index :deleted_at
end
@@ -75,6 +80,24 @@ class CreateWarpEngineTables < ActiveRecord::Migration[8.0]
t.index [ :software_id, :position ]
end
create_table :application_tokens do |t|
t.string :name, limit: 128, null: false
t.string :owner_type, limit: 128, null: false
t.bigint :owner_id, null: false
t.string :token_digest, limit: 64, null: false
t.string :token_prefix, limit: 12, null: false
t.json :scopes
# 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
t.datetime :deleted_at, precision: 3
t.timestamps precision: 3, null: true
t.index :token_digest, unique: true
t.index [ :owner_type, :owner_id ]
t.index :deleted_at
end
create_table :downloads do |t|
t.string :file_path, null: false
t.references :release, foreign_key: { on_delete: :nullify }, index: false
@@ -1,16 +1,44 @@
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 /update endpoint shared secretje (default: ENV["UPDATE_SECRET"]).
# Beállítatlan secret esetén az endpoint minden kérést elutasít.
# 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"]
# Ha a host modelljei is hivatkoznak katalógus-képekre, regisztráld őket,
# hogy az admin Images oldal orphan-detektálása figyelembe vegye:
# 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"
# 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
# Size cap in bytes for /build/upload (and the admin file manager, default: 500MB).
# c.max_upload_size = 500 * 1024 * 1024
# 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
# 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",
@@ -0,0 +1,103 @@
# 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"
- 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; 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,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,95 @@
# 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"
PKG_DIR="<%= name %>-$VERSION-$B_TARGET"
echo "==> Building $PKG_DIR"
rm -rf "$PKG_DIR" "$PKG_DIR.zip"
mkdir -p "$PKG_DIR"
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 three:
binary_build "windows" "386" "0" ".exe" "win-x86"
binary_build "windows" "amd64" "0" ".exe" "win-x64"
binary_build "linux" "amd64" "1" "" "linux-x64"
- 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,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,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,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 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,189 @@
# 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: <%= builder %>
commands:
- |
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: <%= builder %>
commands:
- |
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: <%= builder %>
commands:
- |
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: <%= exporter %>
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: <%= exporter %>
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"
@@ -1,19 +1,51 @@
module WarpEngine
class Configuration
# Owner kontraktus az image_owners elemeire:
# Owner contract for image_owners elements:
# label: String
# image_ids: -> { Array<Integer> } az owner által használt image id-k
# usage_label: ->(image) { String vagy nil } — megjelenítendő címke, ha használja
# image_ids: -> { Array<Integer> } — image ids used by the owner
# usage_label: ->(image) { String or nil } label to display when in use
# application_token_source: auth source of the /build/* endpoints, exclusive.
# :env — the shared secret (update_secret) is accepted, DB tokens are not
# :database — only WarpEngine::ApplicationToken is accepted, the shared secret is not
# application_token_owner_class: class name of the mandatory token owner
# (e.g. "AdminUser"); nil makes :database mode reject every request.
# max_upload_size: file size cap in bytes for /build/upload (and the admin file manager).
# enforce_software_ownership: when true, a DB token may only upload/publish
# its own owner's softwares (unrestricted tokens are exempt).
# Enable ONLY after the backfill: any token can claim an ownerless software.
# ci_platforms: platforms served by /build/config:
# { "godot" => { builder: "<image>" }, "tic80" => { builder: ..., exporter: ... } }
# Empty map = the feature is inactive (POST → 204, GET → 404).
# ci_extension_public_key(_url): the Woodpecker httpsig ed25519 public key as
# PEM, or a URL to fetch it from (e.g. https://ci.../api/signature/public-key).
# With neither set, POST /build/config rejects every request.
# ci_update_server: server URL written into the upload/publish steps; nil → the request's base_url.
attr_accessor :file_container_path,
:image_container_path,
:update_secret,
:application_token_source,
:application_token_owner_class,
:max_upload_size,
:enforce_software_ownership,
:ci_platforms,
:ci_extension_public_key,
:ci_extension_public_key_url,
:ci_update_server,
:image_owners
def initialize
@file_container_path = ENV.fetch("FILE_CONTAINER_PATH", "/softwares")
@image_container_path = ENV.fetch("IMAGE_CONTAINER_PATH", "/images")
@update_secret = ENV["UPDATE_SECRET"]
@image_owners = []
@file_container_path = ENV.fetch("FILE_CONTAINER_PATH", "/softwares")
@image_container_path = ENV.fetch("IMAGE_CONTAINER_PATH", "/images")
@update_secret = ENV["UPDATE_SECRET"]
@application_token_source = :env
@application_token_owner_class = nil
@max_upload_size = 500 * 1024 * 1024
@enforce_software_ownership = false
@ci_platforms = {}
@ci_extension_public_key = nil
@ci_extension_public_key_url = nil
@ci_update_server = nil
@image_owners = []
end
end
end
@@ -0,0 +1,3 @@
# Test-only: plays the ApplicationToken owner role in the dummy app.
class TestOwner < ActiveRecord::Base
end
@@ -0,0 +1,10 @@
# Test-only: plays the ApplicationToken owner role (AdminUser in the host).
class CreateTestOwners < ActiveRecord::Migration[8.1]
def change
create_table :test_owners, id: { type: :bigint, unsigned: true },
charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t|
t.string :name, limit: 128
t.timestamps precision: 3, null: true
end
end
end
+40 -2
View File
@@ -1,5 +1,34 @@
# A katalógus-táblák a host schema.rb-vel megegyező definícióval.
ActiveRecord::Schema[8.1].define(version: 1) do
# This file is auto-generated from the current state of the database. Instead
# of editing this file, please use the migrations feature of Active Record to
# incrementally modify your database, and then regenerate this schema definition.
#
# This file is the source Rails uses to define your schema when running `bin/rails
# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to
# be faster and is potentially less error prone than running all of your
# migrations from scratch. Old migrations may fail to apply correctly if those
# migrations use external dependencies or application code.
#
# 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
create_table "application_tokens", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.datetime "created_at", precision: 3
t.datetime "deleted_at", precision: 3
t.datetime "expires_at", precision: 3
t.datetime "last_used_at", precision: 3
t.string "name", limit: 128, null: false
t.bigint "owner_id", null: false, unsigned: true
t.string "owner_type", limit: 128, null: false
t.json "scopes"
t.string "token_digest", limit: 64, null: false
t.string "token_prefix", limit: 12, null: false
t.boolean "unrestricted", default: false, null: false
t.datetime "updated_at", precision: 3
t.index ["deleted_at"], name: "idx_application_tokens_deleted_at"
t.index ["owner_type", "owner_id"], name: "idx_application_tokens_owner"
t.index ["token_digest"], name: "idx_application_tokens_token_digest", unique: true
end
create_table "downloads", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.datetime "created_at", precision: 3
t.datetime "deleted_at", precision: 3
@@ -89,6 +118,8 @@ ActiveRecord::Schema[8.1].define(version: 1) do
t.boolean "highlighted", default: false
t.string "license", limit: 128
t.string "name", limit: 128
t.bigint "owner_id", unsigned: true
t.string "owner_type", limit: 128
t.string "platform", limit: 128
t.string "site"
t.string "status", limit: 20, default: "development"
@@ -97,6 +128,13 @@ ActiveRecord::Schema[8.1].define(version: 1) do
t.datetime "updated_at", precision: 3
t.index ["deleted_at"], name: "idx_softwares_deleted_at"
t.index ["name"], name: "idx_softwares_name", unique: true
t.index ["owner_type", "owner_id"], name: "idx_softwares_owner"
end
create_table "test_owners", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.datetime "created_at", precision: 3
t.string "name", limit: 128
t.datetime "updated_at", precision: 3
end
add_foreign_key "downloads", "releases", name: "fk_downloads_release", on_delete: :nullify
@@ -0,0 +1,21 @@
# TestOwner exists only in the dummy app — host-side usage must override the
# owner (e.g. owner: create(:admin_user)).
FactoryBot.define do
factory :test_owner, class: "TestOwner" do
name { "test owner" }
end
factory :application_token, class: "WarpEngine::ApplicationToken" do
name { "CI token" }
scopes { [ "update" ] }
association :owner, factory: :test_owner
trait :expired do
expires_at { 1.hour.ago }
end
trait :unrestricted do
unrestricted { true }
end
end
end
@@ -0,0 +1,124 @@
require "rails_helper"
RSpec.describe WarpEngine::ApplicationToken, type: :model do
before do
allow(WarpEngine.config).to receive(:application_token_owner_class).and_return("TestOwner")
end
describe "token generation" do
it "generates a plain token on create and stores only its digest and prefix" do
token = create(:application_token)
expect(token.plain_token).to match(/\A\h{48}\z/)
expect(token.token_prefix).to eq(token.plain_token.first(8))
expect(token.token_digest).to eq(Digest::SHA256.hexdigest(token.plain_token))
end
it "does not expose the plain token on a reloaded record" do
token = create(:application_token)
expect(described_class.find(token.id).plain_token).to be_nil
end
end
describe "validations" do
it "requires an owner" do
token = build(:application_token, owner: nil)
expect(token).not_to be_valid
expect(token.errors[:owner]).to be_present
end
it "requires a name" do
expect(build(:application_token, name: nil)).not_to be_valid
end
it "requires at least one scope" do
expect(build(:application_token, scopes: [])).not_to be_valid
end
it "fills owner_type from the configuration" do
token = create(:application_token)
expect(token.owner_type).to eq("TestOwner")
end
it "rejects an owner_type differing from the configuration" do
token = build(:application_token, owner_type: "WarpEngine::Software")
expect(token).not_to be_valid
expect(token.errors[:owner_type]).to be_present
end
it "rejects creation when no owner class is configured" do
allow(WarpEngine.config).to receive(:application_token_owner_class).and_return(nil)
token = build(:application_token, owner_type: "TestOwner")
expect(token).not_to be_valid
expect(token.errors[:base]).to be_present
end
end
describe "#scopes_string" do
it "round-trips a comma separated list" do
token = build(:application_token)
token.scopes_string = "update, deploy,update ,"
expect(token.scopes).to eq(%w[update deploy])
expect(token.scopes_string).to eq("update, deploy")
end
end
describe ".authenticate" do
it "returns the token for a valid plain token and scope" do
token = create(:application_token)
expect(described_class.authenticate(token.plain_token, required_scope: "update")).to eq(token)
end
it "returns nil for a blank or unknown token" do
create(:application_token)
expect(described_class.authenticate(nil)).to be_nil
expect(described_class.authenticate("")).to be_nil
expect(described_class.authenticate("nem-letezo")).to be_nil
end
it "returns nil when the required scope is missing" do
token = create(:application_token, scopes: [ "deploy" ])
expect(described_class.authenticate(token.plain_token, required_scope: "update")).to be_nil
end
it "returns nil for an expired token" do
token = create(:application_token, :expired)
expect(described_class.authenticate(token.plain_token, required_scope: "update")).to be_nil
end
it "returns nil for a revoked token" do
token = create(:application_token)
token.revoke!
expect(described_class.authenticate(token.plain_token, required_scope: "update")).to be_nil
end
end
describe "#revoke!" do
it "soft deletes the token" do
token = create(:application_token)
token.revoke!
expect(described_class.find_by(id: token.id)).to be_nil
expect(described_class.unscoped.find(token.id).deleted_at).to be_present
end
end
describe "#touch_last_used!" do
it "stamps last_used_at" do
token = create(:application_token)
expect { token.touch_last_used! }.to change { token.reload.last_used_at }.from(nil)
end
end
end
@@ -0,0 +1,176 @@
require "rails_helper"
RSpec.describe "Build configs endpoint", type: :request do
let(:signing_key) { OpenSSL::PKey.generate_key("ed25519") }
let(:ci_platforms) do
{
"godot" => { builder: "registry.example/godot-builder:4.6" },
"tic80" => { builder: "registry.example/tic80-builder:1.0",
exporter: "registry.example/tic80pro:1.0" }
}
end
before do
allow(WarpEngine.config).to receive(:ci_platforms).and_return(ci_platforms)
allow(WarpEngine.config).to receive(:ci_extension_public_key).and_return(signing_key.public_to_pem)
end
def signed_headers(method: "post", path: "/build/config")
date = Time.now.httpdate
signing_string = "(request-target): #{method} #{path}\ndate: #{date}"
signature = Base64.strict_encode64(signing_key.sign(nil, signing_string))
{
"Date" => date,
"Signature" => %(keyId="woodpecker-ci-plugins",algorithm="ed25519",headers="(request-target) date",signature="#{signature}"),
"Content-Type" => "application/json"
}
end
def extension_payload(marker_yaml, repo_name: "mygame")
{
repo: { name: repo_name },
pipeline: { branch: "master" },
configuration: [ { name: ".woodpecker.yaml", data: marker_yaml } ]
}.to_json
end
describe "GET /build/config" do
it "renders the pipeline for a configured platform" do
get "/build/config", params: { platform: "godot", name: "mygame" }
expect(response).to have_http_status(:ok)
pipeline = YAML.safe_load(response.body)
expect(pipeline["steps"]).to be_present
expect(response.body).to include("registry.example/godot-builder:4.6")
expect(response.body).to include("mygame")
end
it "returns 404 for an unknown platform" do
get "/build/config", params: { platform: "nope" }
expect(response).to have_http_status(:not_found)
end
it "returns 404 when the feature is not configured" do
allow(WarpEngine.config).to receive(:ci_platforms).and_return({})
get "/build/config", params: { platform: "godot" }
expect(response).to have_http_status(:not_found)
end
it "rejects path traversal in the platform param" do
get "/build/config", params: { platform: "../secrets" }
expect(response).to have_http_status(:not_found)
end
end
describe "POST /build/config" do
it "returns the rendered pipeline for a marker config" do
post "/build/config", params: extension_payload("platform: godot\n"),
headers: signed_headers
expect(response).to have_http_status(:ok)
configs = response.parsed_body["configs"]
expect(configs.length).to eq(1)
expect(configs.first["name"]).to eq("godot")
pipeline = YAML.safe_load(configs.first["data"])
expect(pipeline["steps"].map { |s| s["name"] }).to include("version", "publish")
expect(configs.first["data"]).to include("mygame")
end
it "uses the marker's name override instead of the repo name" do
post "/build/config", params: extension_payload("platform: godot\nname: othername\n"),
headers: signed_headers
expect(response.parsed_body["configs"].first["data"]).to include("othername")
expect(response.parsed_body["configs"].first["data"]).not_to include("mygame")
end
it "accepts the configs key used by older Woodpecker payloads" do
payload = { repo: { name: "mygame" },
configs: [ { name: ".woodpecker.yaml", data: "platform: godot\n" } ] }.to_json
post "/build/config", params: payload, headers: signed_headers
expect(response).to have_http_status(:ok)
end
it "returns 204 for a non-marker config" do
full_pipeline = "steps:\n - name: build\n image: alpine\n"
post "/build/config", params: extension_payload(full_pipeline),
headers: signed_headers
expect(response).to have_http_status(:no_content)
end
it "returns 204 when no configuration is sent" do
post "/build/config", params: { repo: { name: "mygame" } }.to_json,
headers: signed_headers
expect(response).to have_http_status(:no_content)
end
it "returns 422 for a marker with an unknown platform" do
post "/build/config", params: extension_payload("platform: amiga\n"),
headers: signed_headers
expect(response).to have_http_status(:unprocessable_entity)
end
it "rejects a request with an invalid signature" do
headers = signed_headers
other_key = OpenSSL::PKey.generate_key("ed25519")
allow(WarpEngine.config).to receive(:ci_extension_public_key).and_return(other_key.public_to_pem)
post "/build/config", params: extension_payload("platform: godot\n"), headers: headers
expect(response).to have_http_status(:forbidden)
end
it "rejects a request without a signature header" do
post "/build/config", params: extension_payload("platform: godot\n"),
headers: { "Content-Type" => "application/json" }
expect(response).to have_http_status(:forbidden)
end
it "rejects every request when no public key is configured" do
allow(WarpEngine.config).to receive(:ci_extension_public_key).and_return(nil)
allow(WarpEngine.config).to receive(:ci_extension_public_key_url).and_return(nil)
post "/build/config", params: extension_payload("platform: godot\n"),
headers: signed_headers
expect(response).to have_http_status(:forbidden)
end
end
describe "shipped templates" do
it "renders every template to valid YAML with non-empty steps" do
templates = Dir[WarpEngine::Engine.root.join("lib/warp_engine/ci_templates/*.yaml.erb")]
expect(templates).not_to be_empty
templates.each do |path|
platform = File.basename(path, ".yaml.erb")
allow(WarpEngine.config).to receive(:ci_platforms).and_return(
platform => { builder: "registry.example/builder:1", exporter: "registry.example/exporter:1" }
)
yaml = WarpEngine::CiConfigService.new.render(
platform: platform, name: "example", update_server: "https://games.example"
)
expect(yaml).to be_present, "#{platform}: no template rendered"
pipeline = YAML.safe_load(yaml)
expect(pipeline["steps"]).to be_present, "#{platform}: no steps"
pipeline["steps"].each do |step|
expect(step["image"]).to be_present, "#{platform}/#{step['name']}: missing image"
expect(step["commands"]).to be_present, "#{platform}/#{step['name']}: missing commands"
end
end
end
end
end
@@ -0,0 +1,122 @@
require "rails_helper"
RSpec.describe "POST /build/publish", type: :request do
before do
allow(WarpEngine.config).to receive(:update_secret).and_return("s3cret")
end
def stub_updater
updater = instance_double(WarpEngine::SoftwareUpdater::Tic80Service)
allow(WarpEngine::SoftwareUpdater::Tic80Service).to receive(:new).and_return(updater)
allow(updater).to receive(:update)
updater
end
def publish(headers: { "X-Update-Secret" => "s3cret" }, params: {})
post "/build/publish", headers: headers,
params: { name: "game", platform: "tic80", version: "1.0" }.merge(params)
end
it "rejects requests without a secret" do
publish(headers: {})
expect(response).to have_http_status(:unauthorized)
end
it "does not accept the secret as a query param" do
post "/build/publish", params: { secret: "s3cret", name: "game", platform: "tic80", version: "1.0" }
expect(response).to have_http_status(:unauthorized)
end
it "runs the updater and returns the published release" do
updater = stub_updater
expect(updater).to receive(:update).with("game", "1.0")
publish
expect(response).to have_http_status(:ok)
expect(JSON.parse(response.body)).to include("published" => true, "name" => "game",
"platform" => "tic80", "version" => "1.0")
end
it "requires name, platform and version" do
%i[name platform version].each do |key|
publish(params: { key => "" })
expect(response).to have_http_status(:bad_request)
end
end
it "rejects an unsupported platform" do
publish(params: { platform: "amiga" })
expect(response).to have_http_status(:bad_request)
end
context "with application_token_source :database" do
before do
allow(WarpEngine.config).to receive(:application_token_source).and_return(:database)
allow(WarpEngine.config).to receive(:application_token_owner_class).and_return("TestOwner")
stub_updater
end
it "accepts a token with the update scope" do
token = create(:application_token)
publish(headers: { "X-Update-Secret" => token.plain_token })
expect(response).to have_http_status(:ok)
end
it "rejects the ENV shared secret" do
publish
expect(response).to have_http_status(:unauthorized)
end
context "with enforce_software_ownership" do
before { allow(WarpEngine.config).to receive(:enforce_software_ownership).and_return(true) }
let(:token) { create(:application_token) }
it "rejects publishing another owner's software" do
create(:software, name: "game", owner: create(:test_owner))
publish(headers: { "X-Update-Secret" => token.plain_token })
expect(response).to have_http_status(:forbidden)
end
it "claims an ownerless software for the token owner" do
software = create(:software, name: "game")
publish(headers: { "X-Update-Secret" => token.plain_token })
expect(response).to have_http_status(:ok)
expect(software.reload.owner).to eq(token.owner)
end
it "does not claim ownership with an unrestricted token" do
software = create(:software, name: "game")
internal = create(:application_token, :unrestricted)
publish(headers: { "X-Update-Secret" => internal.plain_token })
expect(response).to have_http_status(:ok)
expect(software.reload.owner_id).to be_nil
end
it "keeps the existing owner on republish" do
owner = create(:test_owner)
software = create(:software, name: "game", owner: owner)
token = create(:application_token, owner: owner)
publish(headers: { "X-Update-Secret" => token.plain_token })
expect(response).to have_http_status(:ok)
expect(software.reload.owner).to eq(owner)
end
end
end
end
@@ -0,0 +1,161 @@
require "rails_helper"
require "tmpdir"
require "digest"
RSpec.describe "POST /build/upload", type: :request do
let(:tmpdir) { Dir.mktmpdir }
before do
allow(WarpEngine.config).to receive(:update_secret).and_return("s3cret")
allow(WarpEngine.config).to receive(:file_container_path).and_return(tmpdir)
end
after { FileUtils.rm_rf(tmpdir) }
def artifact(filename, content: "zipdata")
path = File.join(Dir.mktmpdir, filename)
File.write(path, content)
Rack::Test::UploadedFile.new(path, "application/zip")
end
def upload(file:, name: "game", version: "1.0", headers: { "X-Update-Secret" => "s3cret" }, extra: {})
post "/build/upload", headers: headers,
params: { name: name, version: version, file: file }.merge(extra)
end
it "rejects requests without a secret" do
upload(file: artifact("game-1.0.html.zip"), headers: {})
expect(response).to have_http_status(:unauthorized)
end
it "does not accept the secret as a query param" do
post "/build/upload", params: { secret: "s3cret", name: "game", version: "1.0",
file: artifact("game-1.0.html.zip") }
expect(response).to have_http_status(:unauthorized)
end
it "stores a valid artifact and returns its digest" do
file = artifact("game-1.0.html.zip", content: "zipdata")
upload(file: file)
expect(response).to have_http_status(:ok)
body = JSON.parse(response.body)
expect(body["file"]).to eq("game-1.0.html.zip")
expect(body["sha256"]).to eq(Digest::SHA256.hexdigest("zipdata"))
expect(File.read(File.join(tmpdir, "game-1.0.html.zip"))).to eq("zipdata")
end
it "accepts binary target artifacts with the <name>-<version>- prefix" do
upload(file: artifact("game-1.0-win-x64.zip"))
expect(response).to have_http_status(:ok)
end
it "rejects a filename outside the <name>-<version> convention" do
upload(file: artifact("other-2.0.html.zip"))
expect(response).to have_http_status(:bad_request)
expect(File.exist?(File.join(tmpdir, "other-2.0.html.zip"))).to be(false)
end
it "requires name and version" do
post "/build/upload", headers: { "X-Update-Secret" => "s3cret" },
params: { file: artifact("game-1.0.html.zip") }
expect(response).to have_http_status(:bad_request)
end
it "requires a file" do
post "/build/upload", headers: { "X-Update-Secret" => "s3cret" },
params: { name: "game", version: "1.0" }
expect(response).to have_http_status(:bad_request)
end
it "rejects a file over max_upload_size" do
allow(WarpEngine.config).to receive(:max_upload_size).and_return(3)
upload(file: artifact("game-1.0.html.zip", content: "toolarge"))
expect(response).to have_http_status(:payload_too_large)
end
it "verifies a provided sha256 and rejects a mismatch" do
upload(file: artifact("game-1.0.html.zip"), extra: { sha256: "0" * 64 })
expect(response).to have_http_status(:unprocessable_entity)
expect(File.exist?(File.join(tmpdir, "game-1.0.html.zip"))).to be(false)
end
it "accepts a matching sha256" do
upload(file: artifact("game-1.0.html.zip", content: "zipdata"),
extra: { sha256: Digest::SHA256.hexdigest("zipdata") })
expect(response).to have_http_status(:ok)
end
context "with application_token_source :database" do
before do
allow(WarpEngine.config).to receive(:application_token_source).and_return(:database)
allow(WarpEngine.config).to receive(:application_token_owner_class).and_return("TestOwner")
end
it "accepts a token with the upload scope" do
token = create(:application_token, scopes: [ "update", "upload" ])
upload(file: artifact("game-1.0.html.zip"), headers: { "X-Update-Secret" => token.plain_token })
expect(response).to have_http_status(:ok)
expect(token.reload.last_used_at).to be_present
end
it "rejects a token without the upload scope" do
token = create(:application_token, scopes: [ "update" ])
upload(file: artifact("game-1.0.html.zip"), headers: { "X-Update-Secret" => token.plain_token })
expect(response).to have_http_status(:unauthorized)
end
context "with enforce_software_ownership" do
before { allow(WarpEngine.config).to receive(:enforce_software_ownership).and_return(true) }
let(:token) { create(:application_token, scopes: [ "upload" ]) }
it "allows uploading to the token owner's software" do
create(:software, name: "game", owner: token.owner)
upload(file: artifact("game-1.0.html.zip"), headers: { "X-Update-Secret" => token.plain_token })
expect(response).to have_http_status(:ok)
end
it "rejects uploading to another owner's software" do
create(:software, name: "game", owner: create(:test_owner))
upload(file: artifact("game-1.0.html.zip"), headers: { "X-Update-Secret" => token.plain_token })
expect(response).to have_http_status(:forbidden)
end
it "allows an unrestricted token regardless of owner" do
create(:software, name: "game", owner: create(:test_owner))
internal = create(:application_token, :unrestricted, scopes: [ "upload" ])
upload(file: artifact("game-1.0.html.zip"), headers: { "X-Update-Secret" => internal.plain_token })
expect(response).to have_http_status(:ok)
end
it "allows uploading to an ownerless software" do
create(:software, name: "game")
upload(file: artifact("game-1.0.html.zip"), headers: { "X-Update-Secret" => token.plain_token })
expect(response).to have_http_status(:ok)
end
end
end
end
@@ -30,6 +30,21 @@ RSpec.describe "GET /api/software", type: :request do
expect(counts[other.id]).to eq(1)
end
it "filters by owner_id and exposes ownerId" do
owner = create(:test_owner)
mine = create(:software, name: "mine", owner: owner)
create(:software, name: "other", owner: create(:test_owner))
create(:software, name: "ownerless")
get "/api/software", params: { owner_id: owner.id }
json = JSON.parse(response.body)
expect(json["softwares"].length).to eq(1)
expect(json["softwares"].first["software"]["name"]).to eq("mine")
expect(json["softwares"].first["software"]["ownerId"]).to eq(owner.id)
expect(mine.reload.owner).to eq(owner)
end
it "excludes soft-deleted software" do
create(:software, deleted_at: Time.current)
@@ -1,46 +0,0 @@
require "rails_helper"
RSpec.describe "GET /update", type: :request do
before do
allow(WarpEngine.config).to receive(:update_secret).and_return("s3cret")
end
it "rejects requests without a secret" do
get "/update", params: { platform: "tic80", name: "game", version: "1.0" }
expect(response).to have_http_status(:unauthorized)
end
it "rejects requests with a wrong secret" do
get "/update", params: { secret: "wrong", platform: "tic80", name: "game", version: "1.0" }
expect(response).to have_http_status(:unauthorized)
end
it "rejects every request when no secret is configured" do
allow(WarpEngine.config).to receive(:update_secret).and_return(nil)
get "/update", params: { secret: "", platform: "tic80", name: "game", version: "1.0" }
expect(response).to have_http_status(:unauthorized)
end
it "requires a version" do
get "/update", headers: { "X-Update-Secret" => "s3cret" }, params: { platform: "tic80", name: "game" }
expect(response).to have_http_status(:bad_request)
expect(response.body).to eq("Version not provided")
end
it "runs the updater with a valid secret" do
updater = instance_double(WarpEngine::SoftwareUpdater::Tic80Service)
allow(WarpEngine::SoftwareUpdater::Tic80Service).to receive(:new).and_return(updater)
expect(updater).to receive(:update).with("game", "1.0")
get "/update", headers: { "X-Update-Secret" => "s3cret" },
params: { platform: "tic80", name: "game", version: "1.0" }
expect(response).to have_http_status(:ok)
expect(response.body).to eq("Updated")
end
end