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
33 changed files with 1338 additions and 139 deletions
+21 -5
View File
@@ -1,13 +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|
# A /build/* DB-tokenjeinek tulajdonosa. A :database módra váltás
# (c.application_token_source = :database) csak azután jöhet, hogy a CI már
# DB-tokent használ — az átkapcsolás azonnal érvényteleníti az UPDATE_SECRET-et.
# Owner of the /build/* DB tokens. Switching to :database mode
# (c.application_token_source = :database) must wait until CI uses DB
# tokens — the flip invalidates UPDATE_SECRET immediately.
c.application_token_source = :database
c.application_token_owner_class = "AdminUser"
# Woodpecker configuration extension (/build/config): the served platforms
# and their builder images. An image bump is one line here, rolled out to
# every repo by the deploy. 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",
-19
View File
@@ -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=
+35 -14
View File
@@ -12,9 +12,9 @@ Repository: `https://git.teletypegames.org/tools/warp_engine`
- **Catalog domain**: `Software`, `Release`, `ReleaseAsset`, `ExternalLink`,
`PlatformLink`, `Image`, `SoftwareImage`, `Download` models with soft-delete
semantics and download statistics.
- **CI-callable updater**: your build pipeline drops artifacts into a
directory and calls one endpoint — WarpEngine extracts archives, parses
metadata and upserts the catalog records. Supported platforms out of the
- **CI-callable updater**: your build pipeline uploads artifacts over HTTP
and calls one endpoint — WarpEngine extracts archives, parses metadata
and upserts the catalog records. Supported platforms out of the
box: TIC-80, Ebitengine, LÖVE, C64, Godot, Bevy, Phaser. Authenticated by
a shared secret or by per-owner database tokens with expiry and scopes
(`ApplicationToken`, managed in the admin).
@@ -36,19 +36,18 @@ Repository: `https://git.teletypegames.org/tools/warp_engine`
## Example stack (docker compose)
`examples/compose` boots everything the engine's workflow assumes, end to
end: the catalog app itself, the SSH drop area the updater contract feeds
from and — behind a compose profile — a Gitea forge with Woodpecker CI, so
you can watch a pipeline publish a release into the catalog.
end: the catalog app itself and — behind a compose profile — a Gitea forge
with Woodpecker CI, so you can watch a pipeline publish a release into the
catalog.
| Service | Role | Where |
| --- | --- | --- |
| `app` | Minimal Rails host with the engine mounted as a path gem (headless: API + updater) | `http://localhost:8080` |
| `mysql` | Catalog database | internal |
| `droparea` | SSH server where pipelines drop build artifacts; shares the `softwares` volume with `app` | `ssh drop@localhost -p 2222` |
| `gitea` | Git forge (profile `ci`) | `http://gitea:3000` |
| `woodpecker` + agent | CI wired to gitea (profile `ci`) | `http://woodpecker:8000` |
### Quickstart — catalog + drop area
### Quickstart — catalog only
```sh
cd examples/compose
@@ -89,10 +88,6 @@ curl -X POST -H "X-Update-Secret: example-update-secret" \
"http://localhost:8080/build/publish?platform=love&name=demo&version=0.1.0"
```
(Dropping the files in over the SSH drop area — `scp -P 2222 demo-0.1.0.*
drop@localhost:drop/`, password `DROP_PASSWORD` from `.env` — works just as
well; the updater only cares that the files end up in `file_container_path`.)
`GET /api/software` now lists *Demo Game* with `html` and `win_x64` assets,
`http://localhost:8080/file/demo-0.1.0/index.html` serves the extracted web
build, and `GET /api/download?path=demo-0.1.0-win-x64.zip` serves the
@@ -221,8 +216,7 @@ Publishing a release from CI is two steps:
1. **Upload** build artifacts into `file_container_path`, named by convention:
`<name>-<version>.metadata.json`, `<name>-<version>.html.zip`,
`<name>-<version>-win-x64.zip`, `<name>-<version>.tic`, ... (each platform
declares which asset kinds it expects — see `GET /api/builds`). Either
drop the files in over the shared volume (SSH drop area), or push them
declares which asset kinds it expects — see `GET /api/builds`). Push them
over HTTP — one request per file, `upload` scope, optional `sha256`
integrity check:
@@ -264,6 +258,33 @@ accepts both:
When switching to `:database`, create the tokens and move your pipelines to
them first — the flip invalidates the shared secret immediately.
## CI pipeline configs (Woodpecker)
WarpEngine can act as a [Woodpecker configuration extension](https://woodpecker-ci.org/docs/usage/extensions/configuration-extension):
instead of a copy-pasted `.woodpecker.yaml` in every game repo, the repo holds a
one-line marker and the engine serves the full per-platform pipeline
(version → build → upload → publish, calling `/build/upload` + `/build/publish`
with the `application_token` Woodpecker secret):
```yaml
# .woodpecker.yaml in a game repo
platform: godot
```
- `POST /build/config` — the extension endpoint Woodpecker calls on every
pipeline start (httpsig/ed25519-signed request, verified against
`ci_extension_public_key(_url)`). Non-marker configs get a `204` so the
repo's own YAML keeps running — opt-in migration, and putting a full
pipeline back into the repo is the opt-out.
- `GET /build/config?platform=godot` — renders the same pipeline as a preview.
Configuration: `ci_platforms` maps platform names to builder images
(`{ "godot" => { builder: "..." }, "tic80" => { builder: ..., exporter: ... } }`);
an empty map (default) disables the feature. Set the Woodpecker side with
`WOODPECKER_CONFIG_EXTENSION_ENDPOINT=https://your-host/build/config` (or
per-repo in Settings → Extensions). Templates live in
`lib/warp_engine/ci_templates/*.yaml.erb`.
## Public API
| Endpoint | Purpose |
@@ -39,29 +39,29 @@ ActiveAdmin.register WarpEngine::ApplicationToken, as: "Application Token" do
include_blank: false
else
f.template.concat(f.template.content_tag(:li,
"application_token_owner_class nincs beállítva — token nem hozható létre.",
"application_token_owner_class is not configured — tokens cannot be created.",
class: "flash flash_error"))
end
end
f.input :name
f.input :scopes_string, label: "Scopes (comma separated)",
hint: %(A /build/publish (és a legacy /update) végponthoz az "update", a /build/upload-hoz az "upload" scope kell.)
f.input :unrestricted, hint: "Belső token: az owner-izoláció (enforce_software_ownership) nem vonatkozik rá."
f.input :expires_at, hint: "Üresen hagyva sosem jár le."
hint: %(The "update" scope is required for /build/publish, the "upload" scope for /build/upload.)
f.input :unrestricted, hint: "Internal token: exempt from owner isolation (enforce_software_ownership)."
f.input :expires_at, hint: "Leave empty for a token that never expires."
end
f.actions
end
show do
if (plain = controller.instance_variable_get(:@plain_token))
panel "⚠️ Token — csak most látható, másold ki!" do
panel "⚠️ Token — shown only once, copy it now!" do
pre plain, style: "font-family:monospace;font-size:14px;padding:8px;background:#fff3cd;user-select:all;"
end
end
attributes_table do
row :id
row :name
row("Token") { |t| code "#{t.token_prefix}… (SHA256 digest tárolva)" }
row("Token") { |t| code "#{t.token_prefix}… (SHA256 digest stored)" }
row("Owner") { |t| "#{t.owner_type} ##{t.owner_id}#{t.owner.try(:email) || t.owner.try(:name)}" }
row("Scopes") { |t| t.scopes_string }
row :unrestricted
@@ -73,9 +73,9 @@ ActiveAdmin.register WarpEngine::ApplicationToken, as: "Application Token" do
end
controller do
# A plain token csak közvetlenül a létrehozás után létezik; a session-ön át
# jut el az egyszeri megjelenítésig (a flash nem jó: az AA layout minden
# flash kulcsot üzenetsávként renderel).
# The plain token only exists right after creation; it travels via the
# session to its one-time display (flash is unsuitable: the AA layout
# renders every flash key as a message bar).
def create
create! do |success, _failure|
success.html do
@@ -1,7 +1,7 @@
module WarpEngine
# Token-hitelesítés a publikáló (/build/*) endpointokhoz.
# A hitelesítési forrás kizárólagos: :database módban a shared secret nem
# érvényes, :env módban a DB-tokenek nem.
# Token authentication for the publishing (/build/*) endpoints.
# The auth source is exclusive: in :database mode the shared secret is not
# accepted, in :env mode DB tokens are not.
module UpdateAuthentication
extend ActiveSupport::Concern
@@ -9,8 +9,8 @@ module WarpEngine
attr_reader :current_application_token
# A token kizárólag az X-Update-Secret headerből jöhet — URL-ben a secret
# proxy- és access-logokba szivárogna.
# The token is accepted from the X-Update-Secret header only — in the URL
# it would leak into proxy and access logs.
def update_authorized?(required_scope:)
token = request.headers["X-Update-Secret"].presence
return false if token.blank?
@@ -23,13 +23,13 @@ module WarpEngine
def env_secret_authorized?(token)
expected = WarpEngine.config.update_secret
# Konfigurálatlan secret esetén az endpoint zárva marad.
# With no secret configured the endpoint stays closed.
expected.present? && ActiveSupport::SecurityUtils.secure_compare(token, expected)
end
def database_token_authorized?(token, required_scope)
if WarpEngine.config.application_token_owner_class.blank?
Rails.logger.error("[#{self.class.name}] application_token_source=:database, de application_token_owner_class nincs beállítva — minden kérés elutasítva")
Rails.logger.error("[#{self.class.name}] application_token_source=:database but application_token_owner_class is not set — rejecting every request")
return false
end
@@ -41,9 +41,9 @@ module WarpEngine
true
end
# Owner-kényszer: csak :database módban (van token) és bekapcsolt
# enforce_software_ownership mellett szűr. Owner nélküli software a
# backfillig szabad préda — a kényszer bekapcsolása előtt kell backfillelni.
# Ownership enforcement applies only in :database mode (there is a token)
# with enforce_software_ownership on. An ownerless software is up for grabs
# until the backfill — backfill before enabling the enforcement.
def software_ownership_authorized?(name)
return true unless WarpEngine.config.enforce_software_ownership
@@ -56,8 +56,8 @@ module WarpEngine
software.owner_type == token.owner_type && software.owner_id == token.owner_id
end
# Az először publikált (vagy backfill előtti, gazdátlan) software a beküldő
# token ownerét kapja. Unrestricted (belső) token nem foglal ownert.
# A first-published (or pre-backfill, ownerless) software gets the
# submitting token's owner. Unrestricted (internal) tokens claim nothing.
def claim_software_ownership(name)
token = current_application_token
return if token.nil? || token.unrestricted?
@@ -0,0 +1,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
@@ -9,11 +9,11 @@ module WarpEngine
short "Build artifact upload"
end
# A release-fájlnevek kötött konvenciója: <name>-<version>.<ext> vagy
# <name>-<version>-<target>.zip — az updater is ezeket keresi.
# Release file naming convention: <name>-<version>.<ext> or
# <name>-<version>-<target>.zip — the updater looks for these too.
NAME_FORMAT = /\A[A-Za-z0-9._-]+\z/
api :POST, "/build/upload", "Upload a build artifact into the drop area"
api :POST, "/build/upload", "Upload a build artifact into the artifact directory"
header "X-Update-Secret", "Shared secret or application token (upload scope)", required: true
param :name, String, required: true, desc: "Software name (filename must be prefixed with <name>-<version>)"
param :version, String, required: true, desc: "Version string"
@@ -7,8 +7,8 @@ module WarpEngine
UPDATE_SCOPE = "update".freeze
UPLOAD_SCOPE = "upload".freeze
# A generált token csak létrehozáskor, memóriában érhető el — a DB-ben
# kizárólag a SHA256 digest és a nem-titkos prefix tárolódik.
# The generated token is only available in memory at creation time — the DB
# stores nothing but the SHA256 digest and the non-secret prefix.
attr_reader :plain_token
belongs_to :owner, polymorphic: true
@@ -30,7 +30,7 @@ module WarpEngine
Digest::SHA256.hexdigest(token)
end
# Az élő (nem törölt, nem lejárt), a kért scope-pal rendelkező token, különben nil.
# The live (not deleted, not expired) token carrying the required scope, else nil.
def self.authenticate(token, required_scope: nil)
return nil if token.blank?
@@ -45,7 +45,7 @@ module WarpEngine
expires_at.present? && expires_at <= Time.current
end
# Visszavonás = soft delete, az audit-nyom megmarad.
# Revocation = soft delete, the audit trail stays.
def revoke!
update_column(:deleted_at, Time.current)
end
@@ -54,7 +54,7 @@ module WarpEngine
update_column(:last_used_at, Time.current)
end
# Admin form: vesszővel elválasztott scope-lista
# Admin form: comma separated scope list
def scopes_string
Array(scopes).join(", ")
end
@@ -67,7 +67,7 @@ module WarpEngine
%w[created_at deleted_at expires_at id last_used_at name owner_id owner_type token_prefix unrestricted updated_at]
end
# A polimorf owner asszociációra a Ransack nem tud szűrni.
# Ransack cannot filter on the polymorphic owner association.
def self.ransackable_associations(auth_object = nil)
[]
end
@@ -2,8 +2,8 @@ module WarpEngine
class Software < ApplicationRecord
self.table_name = "softwares"
# A publikáló token ownere (pl. AdminUser) — 3rd party izolációhoz, ld.
# enforce_software_ownership. nil = belső / backfill előtti software.
# Owner of the publishing token (e.g. AdminUser) — for 3rd-party isolation,
# see enforce_software_ownership. nil = internal / pre-backfill software.
belongs_to :owner, polymorphic: true, optional: true
has_many :software_images, foreign_key: :software_id, dependent: :destroy
@@ -14,7 +14,7 @@ module WarpEngine
field(:license) { |sw| sw.license.to_s }
field :platform
field :status
# Publikus owner-azonosító — az /api/software?owner_id= szűrőhöz.
# Public owner id — for the /api/software?owner_id= filter.
field(:ownerId) { |sw| sw.owner_id }
field(:highlighted) { |sw| sw.highlighted ? true : false }
field(:externalLinks) { |sw| ExternalLinkSerializer.render_as_hash(sw.external_links) }
@@ -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
+2
View File
@@ -10,6 +10,8 @@ WarpEngine::Engine.routes.draw do
post "build/upload", to: "build/uploads#create"
post "build/publish", to: "build/publish#create"
get "build/config", to: "build/configs#show"
post "build/config", to: "build/configs#create"
get "file/*path", to: "files#show", format: false
end
@@ -3,8 +3,8 @@ class CreateApplicationTokens < ActiveRecord::Migration[8.1]
create_table :application_tokens, id: { type: :bigint, unsigned: true },
charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t|
t.string :name, limit: 128, null: false
# Az owner osztályát a host adja (WarpEngine.config.application_token_owner_class),
# ezért nem lehet FK.
# The owner class comes from the host (WarpEngine.config.application_token_owner_class),
# so no FK.
t.string :owner_type, limit: 128, null: false
t.bigint :owner_id, null: false, unsigned: true
t.string :token_digest, limit: 64, null: false
@@ -1,12 +1,12 @@
class AddBuildOwnership < ActiveRecord::Migration[8.1]
def change
# A publikáló token ownere; nil = belső / backfill előtti software.
# Az owner osztályát a host adja (application_token_owner_class), ezért nem lehet FK.
# Owner of the publishing token; nil = internal / pre-backfill software.
# The owner class comes from the host (application_token_owner_class), so no FK.
add_column :softwares, :owner_type, :string, limit: 128
add_column :softwares, :owner_id, :bigint, unsigned: true
add_index :softwares, [ :owner_type, :owner_id ], name: "idx_softwares_owner"
# unrestricted = belső token: az enforce_software_ownership nem vonatkozik rá.
# unrestricted = internal token: exempt from enforce_software_ownership.
add_column :application_tokens, :unrestricted, :boolean, default: false, null: false
end
end
@@ -6,12 +6,8 @@ MYSQL_ROOT_PASSWORD=warpengine
# Shared secret for the /build/* endpoints (X-Update-Secret header).
UPDATE_SECRET=example-update-secret
# Password of the "drop" user on the artifact drop area (SSH, port 2222).
DROP_PASSWORD=drop
# Published ports.
APP_PORT=8080
DROPAREA_SSH_PORT=2222
GITEA_SSH_PORT=2223
# --- profile "ci" only -------------------------------------------------------
@@ -3,12 +3,10 @@
# mysql the catalog database
# app a minimal Rails host with the engine mounted from this
# repo checkout (headless: API + updater, no ActiveAdmin)
# droparea SSH server where build pipelines drop artifacts; shares
# the "softwares" volume with the app
# gitea (profile "ci") the git forge
# woodpecker (profile "ci") CI server + agent, wired to gitea
#
# Quickstart (catalog + drop area only):
# Quickstart (catalog only):
# cp .env.example .env
# docker compose up --build
#
@@ -51,24 +49,6 @@ services:
- softwares:/softwares
- images:/images
# SSH landing zone for build artifacts. Pipelines (or you, with scp) upload
# into ~/drop here; the app sees the same files under /softwares.
droparea:
image: linuxserver/openssh-server
environment:
PUID: 1
PGID: 1
SUDO_ACCESS: "false"
PASSWORD_ACCESS: "true"
USER_NAME: drop
USER_PASSWORD: ${DROP_PASSWORD:-drop}
ports:
- "${DROPAREA_SSH_PORT:-2222}:2222"
volumes:
# A subdir of the drop user's home (/config), so sshd's own state files
# never end up in the catalog directory.
- softwares:/config/drop
# --- profile "ci": the forge + CI producing releases for the catalog ------
#
# gitea and woodpecker refer to each other by their service names, so your
@@ -113,7 +93,7 @@ services:
WOODPECKER_SERVER: "woodpecker:9000"
WOODPECKER_AGENT_SECRET: ${WOODPECKER_AGENT_SECRET:-example-agent-secret}
# Attach pipeline containers to the stack network so steps can reach
# gitea, droparea and the app by service name.
# gitea and the app by service name.
WOODPECKER_BACKEND_DOCKER_NETWORK: warp-example
volumes:
- /var/run/docker.sock:/var/run/docker.sock
@@ -3,7 +3,7 @@ Rails.application.config.to_prepare do
c.file_container_path = ENV.fetch("FILE_CONTAINER_PATH", "/softwares")
c.image_container_path = ENV.fetch("IMAGE_CONTAINER_PATH", "/images")
# Beállítatlan secret esetén a /build/* endpointok minden kérést elutasítanak.
# With no secret configured the /build/* endpoints reject every request.
c.update_secret = ENV["UPDATE_SECRET"]
end
end
@@ -11,8 +11,8 @@ class CreateWarpEngineTables < ActiveRecord::Migration[8.0]
t.string :site
t.string :status, limit: 20, default: "development"
t.boolean :highlighted, default: false
# A publikáló token ownere (enforce_software_ownership) — nem lehet FK,
# az owner osztályát a host adja.
# Owner of the publishing token (enforce_software_ownership) — no FK,
# the owner class comes from the host.
t.string :owner_type, limit: 128
t.bigint :owner_id
t.datetime :deleted_at, precision: 3
@@ -87,7 +87,7 @@ class CreateWarpEngineTables < ActiveRecord::Migration[8.0]
t.string :token_digest, limit: 64, null: false
t.string :token_prefix, limit: 12, null: false
t.json :scopes
# Belső token: az enforce_software_ownership nem vonatkozik rá.
# Internal token: exempt from enforce_software_ownership.
t.boolean :unrestricted, default: false, null: false
t.datetime :expires_at, precision: 3
t.datetime :last_used_at, precision: 3
@@ -1,32 +1,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 /build/* endpointok shared secretje (default: ENV["UPDATE_SECRET"]).
# Beállítatlan secret esetén az endpointok minden kérést elutasítanak.
# Shared secret of the /build/* endpoints (default: ENV["UPDATE_SECRET"]).
# With no secret configured the endpoints reject every request.
# c.update_secret = ENV["UPDATE_SECRET"]
# A /build/* hitelesítési forrása — kizárólagos választás:
# :env — a fenti shared secret érvényes (default)
# :database — csak DB-tárolt WarpEngine::ApplicationToken érvényes
# ("update" scope-pal); a shared secret ilyenkor NEM működik.
# A :database módhoz kötelező a tokenek tulajdonos-osztálya is:
# Auth source of the /build/* endpoints — an exclusive choice:
# :env — the shared secret above is accepted (default)
# :database — only DB-stored WarpEngine::ApplicationToken records are
# accepted (with the "update" scope); the shared secret
# stops working the moment you switch.
# :database mode also requires the owner class every token belongs to:
# c.application_token_source = :database
# c.application_token_owner_class = "AdminUser"
# A /build/upload (és az admin file manager) méretplafonja bájtban (default: 500MB).
# Woodpecker configuration extension (/build/config): builder images of
# the served platforms and the CI server's signing key. An empty
# ci_platforms (default) keeps the feature inactive.
# c.ci_platforms = {
# "godot" => { builder: "registry.example/godot-builder:4.6" },
# "tic80" => { builder: "registry.example/tic80-builder:1.0",
# exporter: "registry.example/tic80pro:1.0" }
# }
# c.ci_extension_public_key_url = "https://ci.example.org/api/signature/public-key"
# c.ci_update_server = nil # nil: the request base_url
# Size cap in bytes for /build/upload (and the admin file manager, default: 500MB).
# c.max_upload_size = 500 * 1024 * 1024
# Owner-izoláció: DB-token csak a saját ownerének szoftvereit
# uploadolhatja/publisholhatja (unrestricted token kivétel). Csak azután
# kapcsold be, hogy a meglévő szoftverek ownert kaptak (backfill)!
# Owner isolation: a DB token may only upload/publish its own owner's
# softwares (unrestricted tokens are exempt). Enable only after existing
# softwares got an owner (backfill)!
# c.enforce_software_ownership = true
# Ha a host modelljei is hivatkoznak katalógus-képekre, regisztráld őket,
# hogy az admin Images oldal orphan-detektálása figyelembe vegye:
# If host models also reference catalog images, register them so the
# admin Images page's orphan detection takes them into account:
# c.image_owners = [
# {
# label: "member",
@@ -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,18 +1,25 @@
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
# application_token_source: a /build/* endpointok hitelesítési forrása, kizárólagos.
# :env — a shared secret (update_secret) érvényes, a DB-tokenek nem
# :database — csak WarpEngine::ApplicationToken érvényes, a shared secret nem
# application_token_owner_class: a tokenek kötelező tulajdonosának osztályneve
# (pl. "AdminUser"); nil esetén a :database mód minden kérést elutasít.
# max_upload_size: a /build/upload (és az admin file manager) fájlméret-plafonja bájtban.
# enforce_software_ownership: ha true, egy DB-token csak a saját ownerének
# szoftvereit uploadolhatja/publisholhatja (unrestricted token kivétel).
# Bekapcsolás CSAK backfill után: gazdátlan software-t bármely token elvihet.
# 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,
@@ -20,6 +27,10 @@ module WarpEngine
: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
@@ -30,6 +41,10 @@ module WarpEngine
@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
@@ -1,3 +1,3 @@
# Csak a dummy app tesztjeihez: az ApplicationToken owner szerepét tölti be.
# Test-only: plays the ApplicationToken owner role in the dummy app.
class TestOwner < ActiveRecord::Base
end
@@ -1,4 +1,4 @@
# Csak a tesztekhez: az ApplicationToken owner-e (a hostban ez pl. AdminUser).
# 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 },
@@ -1,5 +1,5 @@
# A TestOwner csak a dummy appban létezik — host-oldali használatnál az owner-t
# felül kell írni (pl. owner: create(:admin_user)).
# 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" }
@@ -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