Author SHA1 Message Date
mr.zero 9ffbc7a2ca wiki multilang support
ci/woodpecker/push/deploy Pipeline was successful
2026-08-28 20:52:49 +02:00
mr.zero 653894a876 wp version upgrade
ci/woodpecker/push/deploy Pipeline was successful
2026-08-26 00:14:51 +02:00
mr.zero 97d71a7c99 advanced pipeline fix
ci/woodpecker/push/deploy Pipeline was successful
2026-08-26 00:08:09 +02:00
mr.zero a58e91520e advanced pipeline
ci/woodpecker/push/deploy Pipeline was successful
ci/woodpecker/push/warp_engine Pipeline was successful
2026-08-25 23:50:11 +02:00
mr.zero 259de781bb ignored user agents
ci/woodpecker/push/woodpecker Pipeline failed
2026-08-25 15:09:34 +02:00
mr.zero 6f2a39fe1b X-Forwarded-For fix 3 2026-08-25 15:03:02 +02:00
mr.zero fbdfbe954e X-Forwarded-For fix 2 2026-08-25 15:00:38 +02:00
mr.zero b51bf0d094 X-Forwarded-For fix 2026-08-25 14:57:08 +02:00
mr.zero fc1076f8fc refact
ci/woodpecker/push/woodpecker Pipeline failed
2026-08-25 14:49:23 +02:00
mr.zero 3e70717e8f gitlab
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-24 13:46:15 +02:00
mr.zero c0252928c6 batocera info update 2026-08-23 21:59:57 +02:00
mr.zeroandClaude Opus 5 3a524d5003 The file manager spec gets a directory nobody else can be in
Twice in a long session the suite came back with one failure in this file, and
neither run could be reproduced afterwards — not by seed, not by repetition.
The one thing this spec has that the other 39 examples do not is a fixed path
under `tmp/`, which anything else on the machine can also be inside.

`Dir.mktmpdir` instead, so two runs cannot see each other's folders at all. If
a failure survives this, it is about the code and not about the directory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 09:50:56 +02:00
116 changed files with 2707 additions and 303 deletions
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env ruby
require "rbconfig"
root = IO.popen([ "git", "rev-parse", "--show-toplevel" ], &:read).strip
check = File.join(root, "script", "warp_engine_version_check.rb")
exit 0 unless File.exist?(check)
exec(RbConfig.ruby, check, "--staged")
+125
View File
@@ -0,0 +1,125 @@
# Éles deploy: master push -> rubocop -> host teszt -> pull + függőségek + restart.
#
# A woodpecker-agent ugyanabban a stackben fut, mint az api és a frontend, ezért
# a deploy nem SSH-zik: a hoszt docker socketjét használja. A stack könyvtára a
# konténerben UGYANARRA az útvonalra van mountolva, mint a hoszton — különben a
# compose relatív bind mountjai (./data, ./apps) máshova oldódnának fel.
#
# Feltételek:
# - Woodpecker: a repónál engedélyezni kell a "trusted: volumes" jelölést
# (Repo settings -> Trusted, admin joggal), különben a mountok tiltottak.
# - Woodpecker secret: forge_token — Forgejo token a services/teletypegames
# olvasásához (ugyanaz, amit a warp_engine workflow használ).
# - A szerveren /srv/stacks/teletype-games egy master-en álló git checkout,
# benne az api és frontend konténerek futnak (container_name: api, frontend).
when:
- event: push
branch: master
- event: manual
services:
- name: mysql
image: mysql:8
environment:
MYSQL_ALLOW_EMPTY_PASSWORD: "yes"
MYSQL_DATABASE: softwares # a zeitwerk:check production modban bootol; a softwares_test-et a db:test:prepare csinalja
steps:
rubocop:
image: ruby:3.3
environment:
BUNDLE_PATH: /cache/bundle
volumes:
# A gemek a szerveren maradnak futások között; a ruby-verzió a kulcs, mert a
# natív kiterjesztések (mysql2) egy ABI-hoz fordulnak.
- /srv/ci-cache/bundle/api-ruby3.3:/cache/bundle
commands:
- apt-get update && apt-get install -y --no-install-recommends default-libmysqlclient-dev
- cd apps/api
# A lock a párhuzamos futásokat választja szét: két pipeline nem ír egyszerre
# ugyanabba a bundle könyvtárba.
- flock -w 900 /cache/bundle/.install.lock bundle install --jobs 4
- bundle exec rubocop
test-host:
image: ruby:3.3
environment:
RAILS_ENV: test
DB_HOST: mysql
DB_USER: root
DB_NAME: softwares
FILE_CONTAINER_PATH: /tmp/softwares
IMAGE_CONTAINER_PATH: /tmp/images
UPDATE_SECRET: ci-test
SECRET_KEY_BASE: ci-test
BUNDLE_PATH: /cache/bundle
volumes:
- /srv/ci-cache/bundle/api-ruby3.3:/cache/bundle
commands:
- apt-get update && apt-get install -y --no-install-recommends default-libmysqlclient-dev default-mysql-client
- mkdir -p /tmp/softwares /tmp/images
- cd apps/api
- flock -w 900 /cache/bundle/.install.lock bundle install --jobs 4
- |
echo "==> Varakozas a mysql-re"
for i in $(seq 1 60); do
if mysqladmin ping -h mysql --silent 2>/dev/null; then break; fi
sleep 2
done
- bundle exec rails db:test:prepare
- bundle exec rspec
# Production modu eager load: a sema-betoltott teszt adatbazisra mutatunk,
# hogy a boot ne egy ures DB-n haljon el.
- RAILS_ENV=production DB_NAME=softwares_test bundle exec rails zeitwerk:check
pull:
image: alpine/git
environment:
FORGE_TOKEN:
from_secret: forge_token
volumes:
- /srv/stacks/teletype-games:/srv/stacks/teletype-games
commands:
- git config --global --add safe.directory /srv/stacks/teletype-games
- cd /srv/stacks/teletype-games
# A szerver checkoutja detached HEAD-en is állhat: akkor a pull "sikeres",
# de a futó kód nem mozdul. Inkább bukjunk el itt.
- |
BRANCH="$(git symbolic-ref --short -q HEAD || true)"
if [ "$BRANCH" != "master" ]; then
echo "A checkout nem master-en all (HEAD: $${BRANCH:-detached}), a deploy megall."
exit 1
fi
- TOKEN="$$(printf '%s' "$${FORGE_TOKEN}" | tr -d '[:space:]')"
- git fetch "https://ci:$${TOKEN}@git.teletypegames.org/services/teletypegames.git" master
- git merge --ff-only FETCH_HEAD
- |
HEAD_SHA="$(git rev-parse HEAD)"
echo "==> A szerver most itt all: $HEAD_SHA"
if [ "$HEAD_SHA" != "$CI_COMMIT_SHA" ]; then
echo "Megjegyzes: ez nem a pipeline commitja ($CI_COMMIT_SHA) — kozben ujabb push jott."
fi
when:
- event: [ push, manual ]
branch: master
restart:
image: docker:cli
volumes:
- /var/run/docker.sock:/var/run/docker.sock
commands:
# Csak az api és a frontend indul újra. A stack többi szolgáltatását
# (traefik, gitea, woodpecker, mysql) nem bántjuk: a woodpecker-server
# restartja épp ezt a pipeline-t vágná el.
- docker exec api bundle install
# A migracio a restart elott fut: a motor migraciois utvonala a hoszt
# db:migrate-jaban van, tehat ez a warp_engine migraciot is elvegzi.
- docker exec api bundle exec rails db:migrate
- docker restart api
- docker exec frontend npm install
- docker restart frontend
- docker ps --filter name=api --filter name=frontend --format '{{.Names}} {{.Status}}'
when:
- event: [ push, manual ]
branch: master
@@ -1,7 +1,8 @@
# Read-only split mirror: a libs/ruby/warp_engine alkönyvtárat kitükrözi a
# engines/warp_engine repóba (fejlesztés itt, a monorepóban történik; a tükör
# csak publikálásra való). Tag-elt release (warp_engine-v*) esetén a gem a
# Forgejo rubygems registry-be is felmegy.
# WarpEngine gem: verzió -> rubocop -> teszt -> tükrözés -> gem kiadás.
#
# A fejlesztés ebben a monorepóban történik; a engines/warp_engine repó csak
# publikálásra való read-only tükör, ezért CI-t oda nincs értelme tenni: a
# subtree split minden alkalommal felülírja.
#
# Szükséges Woodpecker secret: forge_token — Forgejo access token
# repository:write (engines/warp_engine) és package:write joggal.
@@ -29,18 +30,51 @@ services:
MYSQL_DATABASE: warp_engine_test
steps:
# A motor tesztje kapuzza a tobbit: ha bukik, se a tukrozes, se a gem
# kiadasa nem fut le. A mirror repoba nincs ertelme CI-t tenni, mert azt a
# subtree split minden alkalommal feluliria.
# Első kapu: a motor nem módosulhat verzióemelés nélkül. Ugyanaz a szkript
# fut a .githooks/pre-commit hookban is, csak --staged módban.
version-bumped:
image: ruby:3.3
commands:
- ruby script/warp_engine_version_check.rb --range "$CI_PREV_COMMIT_SHA" "$CI_COMMIT_SHA"
when:
- event: [ push, manual ]
# Tag esetén a verzió már nem emelkedhet: a tagnek a VERSION-t kell hirdetnie.
tag-matches-version:
image: ruby:3.3
commands:
- ruby script/warp_engine_version_check.rb --tag "$CI_COMMIT_REF"
when:
- event: tag
rubocop:
image: ruby:3.2
environment:
BUNDLE_PATH: /cache/bundle
volumes:
# A gemek a szerveren maradnak futások között. Külön könyvtár ruby-verziónként:
# a natív kiterjesztések (mysql2) egy ABI-hoz fordulnak.
- /srv/ci-cache/bundle/warp_engine-ruby3.2:/cache/bundle
commands:
- apt-get update && apt-get install -y --no-install-recommends default-libmysqlclient-dev
- cd libs/ruby/warp_engine
# A lock a párhuzamos futásokat választja szét: két pipeline nem ír egyszerre
# ugyanabba a bundle könyvtárba.
- flock -w 900 /cache/bundle/.install.lock bundle install --jobs 4
- bundle exec rubocop
test-engine:
image: ruby:3.2
environment:
RAILS_ENV: test
DB_HOST: mysql
BUNDLE_PATH: /cache/bundle
volumes:
- /srv/ci-cache/bundle/warp_engine-ruby3.2:/cache/bundle
commands:
- apt-get update && apt-get install -y --no-install-recommends default-libmysqlclient-dev
- apt-get update && apt-get install -y --no-install-recommends default-libmysqlclient-dev default-mysql-client
- cd libs/ruby/warp_engine
- bundle install --jobs 4
- flock -w 900 /cache/bundle/.install.lock bundle install --jobs 4
- |
echo "==> Varakozas a mysql-re"
for i in $(seq 1 60); do
+129 -3
View File
@@ -27,6 +27,34 @@ The API is split in two layers:
Devise/ActiveAdmin authentication, theming and assets. It consumes WarpEngine
as a path gem and mounts it at `/`.
## Languages
The site is bilingual (English and Hungarian) and so is the wiki behind it.
`Locales` (`lib/locales.rb`) is the one list of supported codes; anything else
falls back to `en`.
The wiki serves English unprefixed and Hungarian under `/hu`, so
`Wiki::Pages` builds the language into the request path rather than into a query
parameter:
```
GET /api/wiki/pages?tag=howto&lang=hu -> https://wiki.teletypegames.org/hu/custom/pages.json?tag=howto
GET /api/wiki/pages?tag=howto -> https://wiki.teletypegames.org/custom/pages.json?tag=howto
```
Each page in the response carries a `locale` saying which language its body is
**actually** written in. That differs from the requested `lang` for the wiki
pages that exist only in Hungarian: they are served as-is rather than 404ing, and
a client can label them.
The RSS feeds take the same `lang` parameter (`/api/rss/blog?lang=hu`), which
sets the channel language and picks the wiki language and the feed's own strings
from `config/locales/`.
The frontend passes the interface language on every wiki call and rebuilds its
outbound wiki links with `wikiUrl()`, so switching EN/HU re-reads the catalog and
points the links at the matching wiki pages.
## The store registry
`GET /api/stores` lists the stores a client can install from. The desktop
@@ -106,11 +134,14 @@ JSON contract baselines for `/api/software` and `/api/builds` live in
### Backend (RuboCop)
```bash
docker compose run --rm --no-deps api bundle exec rubocop # check
docker compose run --rm --no-deps api bundle exec rubocop -A # autofix
docker compose run --rm --no-deps api bundle exec rubocop # host app
docker compose run --rm --no-deps api bundle exec rubocop -A # host app, autofix
docker exec -w /libs/ruby/warp_engine api \
env BUNDLE_GEMFILE=/app/Gemfile bundle exec rubocop # engine
```
Config: `apps/api/.rubocop.yml` (rubocop-rails-omakase preset)
Config: `apps/api/.rubocop.yml` and `libs/ruby/warp_engine/.rubocop.yml`
(both rubocop-rails-omakase). Both are green, and CI keeps them that way.
### Frontend (ESLint)
@@ -120,3 +151,98 @@ docker compose run --rm --no-deps frontend npm run lint:fix # autofix
```
Config: `apps/frontend/eslint.config.js` (ESLint 9 flat config, Vue + TypeScript)
## Pipelines
Woodpecker reads every file in `.woodpecker/` as its own workflow, each with its
own trigger.
### `.woodpecker/warp_engine.yaml` — the gem
Runs when a push to master touches `libs/ruby/warp_engine/**`, on a manual run,
and on a `warp_engine-v*` tag:
| Step | What it guards |
|---|---|
| `version-bumped` | the engine changed but `WarpEngine::VERSION` did not — fails first, before anything else runs |
| `tag-matches-version` | tag events only: `warp_engine-v0.9.1` must find `VERSION = "0.9.1"` |
| `rubocop` | `libs/ruby/warp_engine` against its own `.rubocop.yml` |
| `test-engine` | the engine suite (dummy app, `warp_engine_test` DB) |
| `split-mirror` | pushes the subtree split to `engines/warp_engine` (master pushes only) |
| `publish-gem` | `gem push` to the Forgejo registry (tags only) |
Nothing is mirrored or published until the version check, RuboCop and the suite
have all passed. The mirror repository deliberately has no CI of its own: the
subtree split overwrites it on every run.
### `.woodpecker/deploy.yaml` — the deploy
Runs on every push to master (and manually): `rubocop``test-host` (host
suite + a production-mode `zeitwerk:check`) → `pull``restart`.
The `pull` and `restart` steps reach the server through the host Docker socket
rather than SSH — the `woodpecker-agent` runs in the same stack as `api` and
`frontend`. Two things this depends on:
- The stack directory is bind-mounted **at the same path** it has on the host
(`/srv/stacks/teletype-games`), so the compose file's relative bind mounts
(`./data`, `./apps`) still resolve where they did.
- Woodpecker only allows step volumes on a **trusted** repository: enable
*Trusted → Volumes* in the repo settings (admin only), or the deploy steps
are rejected.
`pull` fails if the server checkout is not on `master` (a detached HEAD would
make the pull look successful while the running code never moves). `restart`
installs dependencies, runs `db:migrate` and only then restarts — and it
touches only `api` and `frontend`: restarting `woodpecker-server` would cut off
the very pipeline doing the deploy.
`db:migrate` in the host app covers the engine too — WarpEngine appends its own
`db/migrate` to the host's migration paths. A failing migration stops the
deploy before the restart, so the old code keeps running.
### Gem cache
Both workflows install gems into a host directory that survives runs:
| Workflow | Host path | `BUNDLE_PATH` |
|---|---|---|
| `warp_engine.yaml` | `/srv/ci-cache/bundle/warp_engine-ruby3.2` | `/cache/bundle` |
| `deploy.yaml` | `/srv/ci-cache/bundle/api-ruby3.3` | `/cache/bundle` |
Cold install is ~35 s, warm ~6 s (113 MB of gems). The directory is keyed by
Ruby version because native extensions (mysql2) are built against one ABI; the
`flock` around `bundle install` keeps two concurrent pipelines from writing the
same directory at once. Docker creates the directories on first run — to drop
the cache, delete them.
This needs the same *Trusted → Volumes* flag the deploy does.
### Why a MySQL service and not a stub
Two thirds of the engine suite (22 of 36 spec files, all 11 request specs)
create rows and assert on what comes back, and both `schema.rb` files are
MySQL-shaped (`charset: utf8mb4`, `collation: utf8mb4_0900_ai_ci`, unsigned
keys). A null adapter answers every query with nothing, and SQLite would test a
database we do not run. The service container costs a start, not a download —
the agent's Docker daemon already has the image.
Secret used by both workflows: `forge_token` — a Forgejo token with
repository read/write and package:write.
## Version bump hook
The engine's version rule is enforced twice, by the same script:
```bash
git config core.hooksPath .githooks # once per clone
```
`.githooks/pre-commit` runs `script/warp_engine_version_check.rb --staged`: a
commit that touches `libs/ruby/warp_engine/**` must also raise
`WarpEngine::VERSION` above the one in `HEAD`. The pipeline runs the same script
in `--range` mode over the pushed commits, so nothing slips through a
`--no-verify`.
Escape hatch when a bump genuinely does not belong:
`SKIP_WARP_ENGINE_VERSION_CHECK=1 git commit ...`
+1 -1
View File
@@ -1,7 +1,7 @@
PATH
remote: ../libs/ruby/warp_engine
specs:
warp_engine (0.8.0)
warp_engine (0.9.1)
apipie-rails
blueprinter
rails (>= 8.0)
-1
View File
@@ -19,7 +19,6 @@ ActiveAdmin.register Store do
end
column :updated_at
actions defaults: true do |store|
link_to store.active? ? "Hide" : "List",
toggle_admin_store_path(store),
method: :put
@@ -5,23 +5,26 @@ class Api::RssController < ApiController
end
api :GET, "/api/rss/blog", "Blog RSS feed"
param :lang, String, required: false, desc: "Feed language (en, hu). Unknown values fall back to en"
returns code: 200, desc: "RSS XML feed of blog posts"
def blog
xml = Rss::BlogFeed.new.xml
xml = Rss::BlogFeed.new(lang: params[:lang]).xml
render xml: xml, content_type: "application/rss+xml"
end
api :GET, "/api/rss/releases", "Software releases RSS feed"
param :lang, String, required: false, desc: "Feed language (en, hu). Unknown values fall back to en"
returns code: 200, desc: "RSS XML feed of software releases"
def releases
xml = Rss::ReleasesFeed.new.xml
xml = Rss::ReleasesFeed.new(lang: params[:lang]).xml
render xml: xml, content_type: "application/rss+xml"
end
api :GET, "/api/rss/howtos", "HowTos RSS feed"
param :lang, String, required: false, desc: "Feed language (en, hu). Unknown values fall back to en"
returns code: 200, desc: "RSS XML feed of tech howtos"
def howtos
xml = Rss::HowtosFeed.new.xml
xml = Rss::HowtosFeed.new(lang: params[:lang]).xml
render xml: xml, content_type: "application/rss+xml"
end
end
@@ -15,7 +15,6 @@ class Api::StoresController < ApiController
returns code: 200, desc: "Array of stores" do
property :name, String, desc: "Display name of the store"
property :catalogUrl, String, desc: "Base URL of the WarpEngine catalog it serves"
end
def index
render json: StoreService.new.index
@@ -7,8 +7,10 @@ class Api::WikiController < ApiController
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)"
param :lang, String, required: false, desc: "Content language (en, hu). Unknown values fall back to en"
returns code: 200, desc: "Wiki pages response" do
property :tag, String, desc: "Applied tag filter"
property :lang, String, desc: "Requested content language"
property :count, Integer, desc: "Number of pages returned"
property :pages, Array, desc: "Array of wiki pages" do
property :id, String, desc: "Page ID"
@@ -17,7 +19,7 @@ class Api::WikiController < ApiController
property :description, String, desc: "Short description"
property :createdAt, String, desc: "Created date (ISO 8601)"
property :updatedAt, String, desc: "Updated date (ISO 8601)"
property :locale, String, desc: "Locale code"
property :locale, String, desc: "Language the page body is actually written in (differs from lang when the page has no translation)"
property :route, String, desc: "URL slug"
property :tags, Array, of: String, desc: "Tags"
property :repo, String, desc: "Git repository URL (from page metadata, engines)"
@@ -31,7 +33,8 @@ class Api::WikiController < ApiController
render json: Wiki::Pages.new.fetch(
tag: params[:tag],
limit: params[:limit],
body: params[:body]
body: params[:body],
lang: params[:lang]
)
end
end
+3 -3
View File
@@ -2,12 +2,12 @@ module Rss
class BlogFeed < Feed
private
def title = "Teletype Games Blog"
def title = translate("rss.blog.title")
def link = "#{site_url}/blog"
def description = "Latest blog posts from Teletype Games"
def description = translate("rss.blog.description")
def items
Wiki::Pages.new.all(tag: "blog", limit: 30).map do |page|
Wiki::Pages.new.all(tag: "blog", limit: 30, lang: lang).map do |page|
{
title: page["title"],
link: "#{site_url}/blog/#{page['route']}",
+10 -2
View File
@@ -2,12 +2,16 @@ require "rss"
module Rss
class Feed
def initialize(lang: nil)
@lang = Locales.resolve(lang)
end
def xml
RSS::Maker.make("2.0") do |maker|
maker.channel.title = title
maker.channel.link = link
maker.channel.description = description
maker.channel.language = "hu"
maker.channel.language = lang
items.each do |item|
maker.items.new_item do |rss_item|
@@ -22,9 +26,13 @@ module Rss
private
attr_reader :lang
def site_url = Rails.configuration.x.site_url
def wiki_url = Rails.configuration.x.wiki_url
def wiki_url = "#{Rails.configuration.x.wiki_url}#{Wiki::Pages.path_prefix(lang)}"
def translate(key, **options) = I18n.t(key, locale: lang, **options)
def parse_time(value)
Time.parse(value.to_s)
+3 -3
View File
@@ -2,12 +2,12 @@ module Rss
class HowtosFeed < Feed
private
def title = "Teletype Games HowTos"
def title = translate("rss.howtos.title")
def link = "#{site_url}/howtos"
def description = "Latest tech howtos from Teletype Games"
def description = translate("rss.howtos.description")
def items
Wiki::Pages.new.all(tag: "howto", limit: 30).map do |page|
Wiki::Pages.new.all(tag: "howto", limit: 30, lang: lang).map do |page|
{
title: page["title"],
link: "#{wiki_url}/#{page['path']}",
+3 -3
View File
@@ -2,9 +2,9 @@ module Rss
class ReleasesFeed < Feed
private
def title = "Teletype Games Releases"
def title = translate("rss.releases.title")
def link = "#{site_url}/catalog"
def description = "Latest game releases from Teletype Games"
def description = translate("rss.releases.description")
def items
WarpEngine::Release.includes(:software).order(created_at: :desc).limit(50).filter_map do |release|
@@ -14,7 +14,7 @@ module Rss
{
title: "#{software.title} v#{release.version}",
link: "#{site_url}/catalog/#{software.name}",
description: "#{software.title} #{release.version} released #{software.desc}",
description: translate("rss.releases.item_description", title: software.title, version: release.version, desc: software.desc),
published_at: release.created_at.to_time
}
end
-1
View File
@@ -1,5 +1,4 @@
class StoreService
def index
StoreSerializer.render_as_hash(Store.active.ordered)
end
+14 -8
View File
@@ -3,12 +3,18 @@ require "json"
module Wiki
class Pages
def fetch(tag:, limit: nil, body: nil)
def self.path_prefix(language)
language == Locales::DEFAULT ? "" : "/#{language}"
end
def fetch(tag:, limit: nil, body: nil, lang: nil)
language = Locales.resolve(lang)
query = { tag: tag }
query[:limit] = limit if limit.present?
query[:body] = body if body.present?
uri = URI.parse("#{Rails.configuration.x.wiki_url}/custom/pages.json")
uri = URI.parse("#{Rails.configuration.x.wiki_url}#{self.class.path_prefix(language)}/custom/pages.json")
uri.query = URI.encode_www_form(query)
response = Net::HTTP.start(
@@ -17,21 +23,21 @@ module Wiki
open_timeout: 5, read_timeout: 10
) { |http| http.get(uri.request_uri) }
return empty(tag, "grav responded #{response.code}") unless response.is_a?(Net::HTTPSuccess)
return empty(tag, language, "grav responded #{response.code}") unless response.is_a?(Net::HTTPSuccess)
JSON.parse(response.body)
rescue StandardError => e
empty(tag, e.message)
empty(tag, language, e.message)
end
def all(tag:, limit: nil)
fetch(tag: tag, limit: limit).fetch("pages", [])
def all(tag:, limit: nil, lang: nil)
fetch(tag: tag, limit: limit, lang: lang).fetch("pages", [])
end
private
def empty(tag, error)
{ "tag" => tag, "count" => 0, "pages" => [], "error" => error }
def empty(tag, language, error)
{ "tag" => tag, "lang" => language, "count" => 0, "pages" => [], "error" => error }
end
end
end
+8
View File
@@ -24,6 +24,14 @@ module Api
config.action_controller.forgery_protection_origin_check = false
# nginx → Traefik → Rails proxy chain: trust Docker + loopback ranges
# so request.remote_ip reads the real client IP from X-Forwarded-For
config.action_dispatch.trusted_proxies = ActionDispatch::RemoteIp::TRUSTED_PROXIES + [
IPAddr.new("172.16.0.0/12"),
IPAddr.new("10.0.0.0/8"),
IPAddr.new("192.168.0.0/16")
]
config.autoload_lib(ignore: %w[assets tasks])
config.x.site_url = ENV.fetch("SITE_URL", "https://teletypegames.org")
@@ -8,6 +8,8 @@ Rails.application.config.to_prepare do
c.site_url = Rails.configuration.x.site_url
c.ignored_user_agents = %w[warpstore/*]
c.ci_adapter = WarpEngine::CI::Woodpecker::Adapter.new(
url: ENV["WOODPECKER_URL"],
api_token: ENV["WOODPECKER_API_TOKEN"],
+11
View File
@@ -5,3 +5,14 @@ en:
date:
formats:
long: "%Y-%m-%d"
rss:
blog:
title: "Teletype Games Blog"
description: "Latest blog posts from Teletype Games"
howtos:
title: "Teletype Games HowTos"
description: "Latest tech howtos from Teletype Games"
releases:
title: "Teletype Games Releases"
description: "Latest game releases from Teletype Games"
item_description: "%{title} %{version} released %{desc}"
+18
View File
@@ -0,0 +1,18 @@
hu:
time:
formats:
long: "%Y-%m-%d %H:%M"
date:
formats:
long: "%Y-%m-%d"
rss:
blog:
title: "Teletype Games blog"
description: "A Teletype Games legfrissebb blogbejegyzései"
howtos:
title: "Teletype Games útmutatók"
description: "A Teletype Games legfrissebb technikai útmutatói"
releases:
title: "Teletype Games kiadások"
description: "A Teletype Games legfrissebb játékkiadásai"
item_description: "Megjelent a %{title} %{version} %{desc}"
+9
View File
@@ -0,0 +1,9 @@
module Locales
SUPPORTED = %w[en hu].freeze
DEFAULT = "en"
def self.resolve(value)
normalized = value.to_s.strip.downcase
SUPPORTED.include?(normalized) ? normalized : DEFAULT
end
end
@@ -10,7 +10,7 @@ RSpec.describe Api::StoresController, type: :request do
expect(response).to have_http_status(:ok)
json = JSON.parse(response.body)
expect(json.map { |s| s["name"] }).to eq(["Apex Games", "Zed Games"])
expect(json.map { |s| s["name"] }).to eq([ "Apex Games", "Zed Games" ])
end
it "answers with the fields a client needs, camelCased" do
+20
View File
@@ -0,0 +1,20 @@
require "rails_helper"
RSpec.describe Locales do
describe ".resolve" do
it "accepts the supported languages" do
expect(Locales.resolve("en")).to eq("en")
expect(Locales.resolve("hu")).to eq("hu")
end
it "normalises case and whitespace" do
expect(Locales.resolve(" HU ")).to eq("hu")
end
it "falls back to the default for anything else" do
expect(Locales.resolve("de")).to eq(Locales::DEFAULT)
expect(Locales.resolve(nil)).to eq(Locales::DEFAULT)
expect(Locales.resolve("")).to eq(Locales::DEFAULT)
end
end
end
+1 -1
View File
@@ -9,7 +9,7 @@ RSpec.describe Event, type: :model do
future = create(:event, date: 1.week.from_now)
create(:event, date: 1.week.ago)
expect(Event.upcoming).to eq([future])
expect(Event.upcoming).to eq([ future ])
end
end
end
+1 -1
View File
@@ -15,7 +15,7 @@ RSpec.describe Store, type: :model do
later = create(:store, name: "Zed Games")
first = create(:store, name: "Apex Games")
expect(Store.ordered).to eq([first, later])
expect(Store.ordered).to eq([ first, later ])
end
end
+2 -2
View File
@@ -1,14 +1,14 @@
require "rails_helper"
require "warden/test/helpers"
require "tmpdir"
RSpec.describe "Admin file manager", type: :request do
include Warden::Test::Helpers
let(:admin) { AdminUser.create!(email: "files-spec@example.org", password: "password123") }
let(:container) { Rails.root.join("tmp/files-spec").to_s }
let(:container) { Dir.mktmpdir("files-spec") }
before do
FileUtils.rm_rf(container)
FileUtils.mkdir_p(File.join(container, "mygame-1.0"))
File.write(File.join(container, "mygame-1.0.zip"), "zipdata")
allow(WarpEngine.config).to receive(:file_container_path).and_return(container)
+31
View File
@@ -0,0 +1,31 @@
require "rails_helper"
RSpec.describe "Api::Wiki", type: :request do
let(:service) { instance_double(Wiki::Pages) }
before do
host! "teletypegames.org"
allow(Wiki::Pages).to receive(:new).and_return(service)
end
it "passes the requested language through to the wiki service" do
expect(service).to receive(:fetch)
.with(tag: "howto", limit: nil, body: nil, lang: "hu")
.and_return({ "tag" => "howto", "lang" => "hu", "count" => 0, "pages" => [] })
get "/api/wiki/pages", params: { tag: "howto", lang: "hu" }
expect(response).to have_http_status(:ok)
expect(response.parsed_body["lang"]).to eq("hu")
end
it "leaves the fallback to the service when no language is asked for" do
expect(service).to receive(:fetch)
.with(tag: "howto", limit: nil, body: nil, lang: nil)
.and_return({ "tag" => "howto", "lang" => "en", "count" => 0, "pages" => [] })
get "/api/wiki/pages", params: { tag: "howto" }
expect(response.parsed_body["lang"]).to eq("en")
end
end
+46
View File
@@ -0,0 +1,46 @@
require "rails_helper"
RSpec.describe Wiki::Pages do
let(:requested_paths) { [] }
before do
http = instance_double(Net::HTTP)
response = Net::HTTPOK.new("1.1", "200", "OK")
allow(response).to receive(:body).and_return({ tag: "howto", lang: "en", count: 0, pages: [] }.to_json)
allow(http).to receive(:get) do |path|
requested_paths << path
response
end
allow(Net::HTTP).to receive(:start) { |*_args, **_opts, &block| block.call(http) }
end
describe "#fetch" do
it "requests the default language without a path prefix" do
described_class.new.fetch(tag: "howto")
expect(requested_paths.first).to start_with("/custom/pages.json")
end
it "prefixes the path with the requested language" do
described_class.new.fetch(tag: "howto", lang: "hu")
expect(requested_paths.first).to start_with("/hu/custom/pages.json")
end
it "falls back to the default language for an unsupported one" do
described_class.new.fetch(tag: "howto", lang: "de")
expect(requested_paths.first).to start_with("/custom/pages.json")
end
it "reports the resolved language when grav is unreachable" do
allow(Net::HTTP).to receive(:start).and_raise(Errno::ECONNREFUSED)
result = described_class.new.fetch(tag: "howto", lang: "hu")
expect(result["lang"]).to eq("hu")
expect(result["pages"]).to eq([])
expect(result["error"]).to be_present
end
end
end
@@ -0,0 +1,35 @@
import { describe, it, expect, afterEach } from 'vitest'
import { i18n } from '../../i18n'
import { WIKI_BASE, wikiUrl } from '../wiki.api'
afterEach(() => {
i18n.global.locale.value = 'en'
})
describe('wikiUrl', () => {
it('leaves English unprefixed', () => {
expect(wikiUrl('development/godot', 'en')).toBe(`${WIKI_BASE}/development/godot`)
})
it('prefixes Hungarian with /hu', () => {
expect(wikiUrl('development/godot', 'hu')).toBe(`${WIKI_BASE}/hu/development/godot`)
})
it('accepts a route that already starts with a slash', () => {
expect(wikiUrl('/development/godot', 'hu')).toBe(`${WIKI_BASE}/hu/development/godot`)
})
it('returns the wiki root when no page is given', () => {
expect(wikiUrl('', 'hu')).toBe(`${WIKI_BASE}/hu`)
expect(wikiUrl()).toBe(WIKI_BASE)
})
it('falls back to English for an unsupported language', () => {
expect(wikiUrl('development/godot', 'de')).toBe(`${WIKI_BASE}/development/godot`)
})
it('follows the active interface language when none is passed', () => {
i18n.global.locale.value = 'hu'
expect(wikiUrl('development/godot')).toBe(`${WIKI_BASE}/hu/development/godot`)
})
})
+29 -11
View File
@@ -1,9 +1,27 @@
import type { WikiPage, WikiPageWithContent, WikiPageContent } from '../lib/interfaces/wiki.interface'
import { CONFIG } from '../lib/config'
import { i18n } from '../i18n'
const WIKI_BASE = CONFIG.wikiBase
const WIKI_LOCALES = ['en', 'hu'] as const
const DEFAULT_WIKI_LOCALE = 'en'
function resolveLocale(locale?: string): string {
const value = (locale ?? String(i18n.global.locale.value)).toLowerCase()
return (WIKI_LOCALES as readonly string[]).includes(value) ? value : DEFAULT_WIKI_LOCALE
}
// Grav serves English unprefixed and Hungarian under /hu, so a page link is the
// base plus the prefix plus the language-neutral route.
function wikiUrl(path = '', locale?: string): string {
const lang = resolveLocale(locale)
const prefix = lang === DEFAULT_WIKI_LOCALE ? '' : `/${lang}`
const route = path ? `/${path.replace(/^\/+/, '')}` : ''
return `${WIKI_BASE}${prefix}${route}`
}
interface RawWikiPage {
id: number
path: string
@@ -22,9 +40,9 @@ const HIGHLIGHTED_TAG = 'highlighted'
async function fetchPages(
tag: string,
opts: { body?: boolean; limit?: number } = {},
opts: { body?: boolean; limit?: number; locale?: string } = {},
): Promise<RawWikiPage[]> {
const params = new URLSearchParams({ tag })
const params = new URLSearchParams({ tag, lang: resolveLocale(opts.locale) })
if (opts.body) params.set('body', '1')
if (opts.limit) params.set('limit', String(opts.limit))
@@ -34,8 +52,8 @@ async function fetchPages(
return json?.pages ?? []
}
const listBlogPages = async (): Promise<WikiPageWithContent[]> => {
const pages = await fetchPages('blog', { body: true })
const listBlogPages = async (locale?: string): Promise<WikiPageWithContent[]> => {
const pages = await fetchPages('blog', { body: true, locale })
return pages.map((p): WikiPageWithContent => ({
id: p.id,
path: p.path,
@@ -48,8 +66,8 @@ const listBlogPages = async (): Promise<WikiPageWithContent[]> => {
}))
}
const getBlogPage = async (slug: string): Promise<WikiPageContent | null> => {
const pages = await fetchPages('blog', { body: true })
const getBlogPage = async (slug: string, locale?: string): Promise<WikiPageContent | null> => {
const pages = await fetchPages('blog', { body: true, locale })
const matched = pages.find((p) => {
const pageSlug = p.path.startsWith('blog/') ? p.path.replace('blog/', '') : p.path
@@ -70,8 +88,8 @@ const getBlogPage = async (slug: string): Promise<WikiPageContent | null> => {
}
}
const listEnginePages = async (): Promise<WikiPageWithContent[]> => {
const pages = await fetchPages('engine', { body: true })
const listEnginePages = async (locale?: string): Promise<WikiPageWithContent[]> => {
const pages = await fetchPages('engine', { body: true, locale })
return pages
.filter((p) => (p.tags ?? []).includes(HIGHLIGHTED_TAG))
.map((p): WikiPageWithContent => ({
@@ -87,8 +105,8 @@ const listEnginePages = async (): Promise<WikiPageWithContent[]> => {
}))
}
const listHowtoPages = async (): Promise<WikiPage[]> => {
const pages = await fetchPages('howto', { limit: 30 })
const listHowtoPages = async (locale?: string): Promise<WikiPage[]> => {
const pages = await fetchPages('howto', { limit: 30, locale })
return pages.map((p): WikiPage => ({
id: p.id,
path: p.path,
@@ -100,5 +118,5 @@ const listHowtoPages = async (): Promise<WikiPage[]> => {
}))
}
export { WIKI_BASE }
export { WIKI_BASE, WIKI_LOCALES, DEFAULT_WIKI_LOCALE, wikiUrl }
export default { listBlogPages, getBlogPage, listHowtoPages, listEnginePages }
@@ -0,0 +1,13 @@
import { watch } from 'vue'
import { i18n } from '../i18n'
// Wiki content is fetched per language, so anything cached from the wiki has to
// be dropped and re-read when the visitor switches EN/HU.
export function useLocaleReload(reload: () => void | Promise<void>) {
watch(
() => i18n.global.locale.value,
() => {
void reload()
},
)
}
+2 -2
View File
@@ -53,7 +53,7 @@
</nav>
<div class="footer-meta">
<a :href="WIKI_BASE" target="_blank" rel="noopener noreferrer" class="footer-link">{{ t('footer.wiki') }}</a>
<a :href="wikiUrl()" target="_blank" rel="noopener noreferrer" class="footer-link">{{ t('footer.wiki') }}</a>
<a :href="GIT_BASE" target="_blank" rel="noopener noreferrer" class="footer-link">{{ t('footer.git') }}</a>
<span class="footer-rss">
<Rss v-bind="ICON" aria-hidden="true" />
@@ -89,7 +89,7 @@ import { Menu, Moon, Rss, Sun, X } from 'lucide-vue-next'
import { useUiStore } from '../stores/ui.store'
import { getCookie } from '../lib/cookie'
import { ICON, ICON_CONTROL } from '../components/icons'
import { WIKI_BASE } from '../api/wiki.api'
import { wikiUrl } from '../api/wiki.api'
const GIT_BASE = 'https://git.teletypegames.org'
@@ -48,7 +48,7 @@
<a :href="exploreUrl(page)" target="_blank" rel="noopener" class="btn-accent">
{{ t('engines.explore') }}
</a>
<a :href="`${WIKI_BASE}/${page.path}`" target="_blank" rel="noopener" class="btn-ghost">
<a :href="wikiUrl(page.path)" target="_blank" rel="noopener" class="btn-ghost">
{{ t('engines.openWiki') }}
</a>
</div>
@@ -63,7 +63,7 @@
import { computed, onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import { WIKI_BASE } from '../../api/wiki.api'
import { wikiUrl } from '../../api/wiki.api'
import { useEnginesStore, getEngineDigest } from '../../stores/engines.store'
import type { WikiPageWithContent } from '../../lib/interfaces/wiki.interface'
import SkeletonCard from '../../components/SkeletonCard.vue'
@@ -79,7 +79,7 @@ const cards = computed(() =>
)
const exploreUrl = (page: WikiPageWithContent): string =>
page.repo || `${WIKI_BASE}/${page.path}`
page.repo || wikiUrl(page.path)
onMounted(() => store.fetch())
</script>
@@ -6,7 +6,7 @@
<h1 class="hero-title">{{ t('howtos.title') }}</h1>
<p class="hero-subtitle">{{ t('howtos.subtitle') }}</p>
<div class="hero-actions">
<a :href="WIKI_BASE" target="_blank" rel="noopener noreferrer" class="btn-accent">
<a :href="wikiUrl()" target="_blank" rel="noopener noreferrer" class="btn-accent">
{{ t('howtos.knowledgeBase') }}
</a>
</div>
@@ -30,7 +30,7 @@
<a
v-for="page in recentPages"
:key="page.id"
:href="`${WIKI_BASE}/${page.path}`"
:href="wikiUrl(page.path)"
target="_blank"
rel="noopener noreferrer"
class="howto-card"
@@ -50,7 +50,7 @@
</div>
<div v-if="!error && recentPages.length > 0" class="focus-row">
<a :href="WIKI_BASE" target="_blank" rel="noopener noreferrer" class="link text-sm">
<a :href="wikiUrl()" target="_blank" rel="noopener noreferrer" class="link text-sm">
{{ t('howtos.browseWiki') }}
</a>
</div>
@@ -63,7 +63,7 @@ import { onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import { formatDateTime } from '../../lib/dateFormat'
import { WIKI_BASE } from '../../api/wiki.api'
import { wikiUrl } from '../../api/wiki.api'
import { useHowtosStore } from '../../stores/howtos.store'
import SkeletonCard from '../../components/SkeletonCard.vue'
@@ -30,7 +30,7 @@
<a :href="client.releasesUrl" target="_blank" rel="noopener noreferrer" class="btn-accent">
{{ t('stores.clientDownload') }}
</a>
<a :href="client.wikiUrl" target="_blank" rel="noopener noreferrer" class="btn-ghost">
<a :href="wikiUrl(client.wikiPath)" target="_blank" rel="noopener noreferrer" class="btn-ghost">
{{ t('stores.docs') }}
</a>
<a :href="client.repoUrl" target="_blank" rel="noopener noreferrer" class="btn-ghost">
@@ -82,7 +82,7 @@
</div>
<div class="st-links st-links-after">
<a :href="current.wikiUrl" target="_blank" rel="noopener noreferrer" class="btn-ghost">
<a :href="wikiUrl(current.wikiPath)" target="_blank" rel="noopener noreferrer" class="btn-ghost">
{{ t('stores.docs') }}
</a>
<a :href="current.repoUrl" target="_blank" rel="noopener noreferrer" class="btn-ghost">
@@ -97,8 +97,9 @@
<section class="st-section">
<h2 class="st-section-title">{{ t('stores.platformsTitle') }}</h2>
<ul class="st-platforms">
<li v-for="p in platforms" :key="p.platform" class="st-platform">
<li v-for="p in current.platforms" :key="p.platform" class="st-platform">
<span class="st-platform-name">{{ p.label }}</span>
<span v-if="p.note" class="st-platform-note mono">{{ p.note }}</span>
<code class="st-platform-ext mono">{{ p.ext }}</code>
</li>
</ul>
@@ -115,7 +116,7 @@
import { computed, ref } from 'vue'
import { RouterLink, useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { CONFIG } from '../../lib/config'
import { wikiUrl } from '../../api/wiki.api'
import CommandBlock from './CommandBlock.vue'
import { BrandIcon } from '../../components/icons'
import clientScreenshot from '../../assets/warpengine-client.webp'
@@ -129,7 +130,7 @@ const FORGE = 'https://git.teletypegames.org/stores'
const CLIENT = {
repoUrl: `${FORGE}/warp-engine-client`,
releasesUrl: `${FORGE}/warp-engine-client/releases`,
wikiUrl: `${CONFIG.wikiBase}/stores/warp-engine-client`,
wikiPath: 'stores/warp-engine-client',
}
type DeviceId = 'batocera' | 'retroarch'
@@ -137,12 +138,26 @@ type DeviceId = 'batocera' | 'retroarch'
const BATOCERA_CLI = '/userdata/system/batocera-store/ttg-store'
const RETROARCH_CLI = '~/.local/bin/ttg-retroarch-store'
type StorePlatform = { platform: string; label: string; ext: string; note?: string }
const CARTRIDGE_PLATFORMS: StorePlatform[] = [
{ platform: 'c64', label: 'Commodore 64 (VICE)', ext: '.prg' },
{ platform: 'tic80', label: 'TIC-80', ext: '.tic' },
]
const devices = [
{
id: 'batocera' as DeviceId,
projectUrl: 'https://batocera.org',
repoUrl: `${FORGE}/ttg-batocera-store`,
wikiUrl: `${CONFIG.wikiBase}/stores/ttg-batocera-store`,
wikiPath: 'stores/ttg-batocera-store',
platforms: [
...CARTRIDGE_PLATFORMS,
{ platform: 'ebitengine', label: 'Ebitengine', ext: '.zip', note: 'x86_64 · ARM64' },
{ platform: 'bevy', label: 'Bevy', ext: '.zip', note: 'x86_64 · ARM64' },
{ platform: 'godot', label: 'Godot', ext: '.zip', note: 'x86_64' },
{ platform: 'love', label: 'LÖVE', ext: '.zip', note: 'x86_64' },
] as StorePlatform[],
installCmd: `curl -fsSL ${FORGE}/ttg-batocera-store/raw/branch/master/install.sh | sh`,
afterInstallCmd: 'batocera-es-swissknife --restart',
@@ -157,7 +172,8 @@ const devices = [
id: 'retroarch' as DeviceId,
projectUrl: 'https://www.retroarch.com',
repoUrl: `${FORGE}/ttg-retroarch-store`,
wikiUrl: `${CONFIG.wikiBase}/stores/ttg-retroarch-store`,
wikiPath: 'stores/ttg-retroarch-store',
platforms: CARTRIDGE_PLATFORMS,
installCmd: `curl -fsSL ${FORGE}/ttg-retroarch-store/raw/branch/master/install.sh | sh`,
afterInstallCmd: '',
uninstallCmd: `curl -fsSL ${FORGE}/ttg-retroarch-store/raw/branch/master/uninstall.sh | sh`,
@@ -196,10 +212,6 @@ function selectView(id: ViewId) {
void router.replace({ query: { ...route.query, view: id } })
}
const platforms = [
{ platform: 'c64', label: 'Commodore 64 (VICE)', ext: '.prg' },
{ platform: 'tic80', label: 'TIC-80', ext: '.tic' },
]
</script>
<style scoped>
@@ -277,6 +289,9 @@ const platforms = [
.st-platform-name {
@apply flex-grow text-sm text-fg;
}
.st-platform-note {
@apply text-xs text-fg-subtle;
}
.st-platform-ext {
@apply rounded border border-line bg-surface px-2 py-0.5 text-xs text-accent;
}
@@ -74,3 +74,31 @@ describe('getEngineDigest', () => {
expect(getEngineDigest('')).toEqual({ intro: '', highlightsTitle: '', highlights: [] })
})
})
const SAMPLE_MARKDOWN_HU = `
> A példamotor egy **minta** keretrendszer, ami markdownból landing-kártyát csinál.
# Amit kapsz
- **Első funkció**: rövid magyarázattal.
- Sima felsorolás, félkövér nyitás nélkül.
# Későbbi szakasz
- **Nem kerül be**: egy lista egy későbbi cím alatt.
`
describe('getEngineDigest, Hungarian page', () => {
it('recognises the Hungarian highlights heading', () => {
const digest = getEngineDigest(SAMPLE_MARKDOWN_HU)
expect(digest.highlightsTitle).toBe('Amit kapsz')
expect(digest.highlights).toHaveLength(2)
expect(digest.highlights[0]).toEqual({ title: 'Első funkció', text: 'rövid magyarázattal.' })
})
it('still stops at the next heading', () => {
expect(getEngineDigest(SAMPLE_MARKDOWN_HU).highlights.map((h) => h.title)).not.toContain(
'Nem kerül be',
)
})
})
+10
View File
@@ -3,6 +3,7 @@ import { ref } from 'vue'
import wikiApi from '../api/wiki.api'
import { isNew } from '../lib/softwareUtils'
import { useLoadable } from '../composables/useLoadable'
import { useLocaleReload } from '../composables/useLocaleReload'
import type { WikiPageWithContent, WikiPageContent } from '../lib/interfaces/wiki.interface'
export const useBlogStore = defineStore('blog', () => {
@@ -18,7 +19,10 @@ export const useBlogStore = defineStore('blog', () => {
})
}
const currentSlug = ref<string | null>(null)
async function fetchPage(slug: string) {
currentSlug.value = slug
currentPage.value = null
await withPageCache(async () => {
const result = await wikiApi.getBlogPage(slug)
@@ -37,5 +41,11 @@ export const useBlogStore = defineStore('blog', () => {
return content.replace(/[#*`_[\]()]/g, '').trim().slice(0, 300) + '...'
}
useLocaleReload(async () => {
invalidate()
await fetch()
if (currentSlug.value) await fetchPage(currentSlug.value)
})
return { pages, loading, error, currentPage, pageLoading, pageError, fetch, fetchPage, isNew, getPermalink, getCleanPreview, invalidate }
})
+8 -2
View File
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
import { ref } from 'vue'
import wikiApi from '../api/wiki.api'
import { useLoadable } from '../composables/useLoadable'
import { useLocaleReload } from '../composables/useLocaleReload'
import type { WikiPageWithContent } from '../lib/interfaces/wiki.interface'
export interface EngineHighlight {
@@ -15,7 +16,7 @@ export interface EngineDigest {
highlights: EngineHighlight[]
}
const HIGHLIGHTS_HEADING = 'what you get'
const HIGHLIGHTS_HEADINGS = ['what you get', 'amit kapsz']
const MAX_HIGHLIGHTS = 6
function stripInline(md: string): string {
@@ -39,7 +40,7 @@ export function getEngineDigest(content: string): EngineDigest {
if (heading) {
if (inHighlights) break
const title = stripInline(heading[1])
if (title.toLowerCase() !== HIGHLIGHTS_HEADING) break
if (!HIGHLIGHTS_HEADINGS.includes(title.toLowerCase())) break
inHighlights = true
digest.highlightsTitle = title
continue
@@ -81,5 +82,10 @@ export const useEnginesStore = defineStore('engines', () => {
return content.replace(/[#*`_[\]()>|-]/g, '').replace(/\s+/g, ' ').trim().slice(0, 260) + '...'
}
useLocaleReload(async () => {
invalidate()
await fetch()
})
return { pages, loading, error, fetch, getCleanPreview, getEngineDigest, invalidate }
})
+6
View File
@@ -3,6 +3,7 @@ import { ref } from 'vue'
import wikiApi from '../api/wiki.api'
import { isNew } from '../lib/softwareUtils'
import { useLoadable } from '../composables/useLoadable'
import { useLocaleReload } from '../composables/useLocaleReload'
import type { WikiPage } from '../lib/interfaces/wiki.interface'
export const useHowtosStore = defineStore('howtos', () => {
@@ -15,5 +16,10 @@ export const useHowtosStore = defineStore('howtos', () => {
})
}
useLocaleReload(async () => {
invalidate()
await fetch()
})
return { pages, loading, error, fetch, isNew, invalidate }
})
+3 -2
View File
@@ -7,6 +7,7 @@ services:
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80"
- "--entrypoints.web.forwardedHeaders.trustedIPs=127.0.0.1/32,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
ports:
- "${TRAEFIK_WEB_PORT}:80"
- "${TRAEFIK_API_PORT}:8080"
@@ -43,7 +44,7 @@ services:
- interstack
woodpecker-server:
image: woodpeckerci/woodpecker-server:v3.17.0
image: woodpeckerci/woodpecker-server:v3.18.0
container_name: woodpecker-server
environment:
WOODPECKER_HOST: "https://${WOODPECKER_DOMAIN}"
@@ -71,7 +72,7 @@ services:
- interstack
woodpecker-agent:
image: woodpeckerci/woodpecker-agent:v3.17.0
image: woodpeckerci/woodpecker-agent:v3.18.0
container_name: woodpecker-agent
environment:
WOODPECKER_SERVER: "woodpecker-server:9000"
+13
View File
@@ -0,0 +1,13 @@
inherit_gem:
rubocop-rails-omakase: rubocop.yml
AllCops:
NewCops: enable
TargetRubyVersion: 3.2
Exclude:
- "bin/**/*"
- "db/**/*"
- "config/**/*"
- "examples/**/*"
- "spec/dummy/**/*"
- "vendor/**/*"
+1
View File
@@ -10,4 +10,5 @@ group :development, :test do
gem "shoulda-matchers", "~> 6.0"
gem "webmock", "~> 3.0"
gem "debug", platforms: %i[mri windows]
gem "rubocop-rails-omakase", require: false
end
+11 -1
View File
@@ -761,6 +761,16 @@ bundle exec rspec
## Development
This repository is a **read-only split mirror** — development happens in the
[`tools/teletypegames`](https://git.teletypegames.org/tools/teletypegames)
[`services/teletypegames`](https://git.teletypegames.org/services/teletypegames)
monorepo under `libs/ruby/warp_engine`, and CI republishes the mirror on every
change. Please do not open pull requests against the mirror.
Two rules the monorepo's pipeline enforces before anything is mirrored or
published:
- **Every commit that touches the engine raises `WarpEngine::VERSION`.** A
pre-commit hook checks it locally, the pipeline's first step checks it again
over the pushed commits, and a `warp_engine-v*` tag must name the version the
tree actually holds.
- **RuboCop is green** (`.rubocop.yml`, rubocop-rails-omakase), and the suite
passes — in that order, before the gem is built.
@@ -96,6 +96,10 @@ ActiveAdmin.register WarpEngine::ApplicationToken, as: "Application Token" do
end
controller do
def scoped_collection
super.kept
end
def create
create! do |success, _failure|
success.html do
+1 -1
View File
@@ -100,7 +100,7 @@ ActiveAdmin.register WarpEngine::Download, as: "Download" do
controller do
def scoped_collection
super.includes(release: :software)
super.kept.includes(release: :software)
end
end
end
@@ -5,6 +5,12 @@ ActiveAdmin.register WarpEngine::ExternalLink, as: "External Link" do
belongs_to :software
controller do
def scoped_collection
super.kept
end
end
index do
selectable_column
id_column
+2 -2
View File
@@ -47,7 +47,7 @@ ActiveAdmin.register WarpEngine::Pipeline, as: "Pipeline" do
f.inputs do
f.input :platform, as: :select, collection: WarpEngine::PlatformLink::SUPPORTED_PLATFORMS
f.input :software_id, as: :select,
collection: WarpEngine::Software.order(:title).map { |s| [ s.title, s.id ] },
collection: WarpEngine::Software.kept.order(:title).map { |s| [ s.title, s.id ] },
include_blank: "- none -",
hint: "One pipeline per software. Picking one that another pipeline already " \
"has moves the link here — that pipeline is left without a software, " \
@@ -124,7 +124,7 @@ ActiveAdmin.register WarpEngine::Pipeline, as: "Pipeline" do
controller do
def scoped_collection
super.includes(:software)
super.kept.includes(:software)
end
end
end
@@ -14,6 +14,12 @@ ActiveAdmin.register WarpEngine::PlatformLink, as: "Platform Link" do
scope("Bevy") { |s| s.where(platform: "bevy") }
scope("Phaser") { |s| s.where(platform: "phaser") }
controller do
def scoped_collection
super.kept
end
end
index do
selectable_column
id_column
@@ -17,6 +17,12 @@ ActiveAdmin.register WarpEngine::Release, as: "Release" do
filter :version
controller do
def scoped_collection
super.kept
end
end
show do
attributes_table do
row :id
+1 -1
View File
@@ -150,7 +150,7 @@ ActiveAdmin.register WarpEngine::Software, as: "Software" do
controller do
def scoped_collection
super.includes({ releases: :release_assets }, software_images: :image)
super.kept.includes({ releases: :release_assets }, software_images: :image)
end
def find_resource
@@ -1,5 +1,4 @@
module WarpEngine
module SubjectAuthentication
extend ActiveSupport::Concern
@@ -1,5 +1,4 @@
module WarpEngine
module UpdateAuthentication
extend ActiveSupport::Concern
@@ -43,7 +42,7 @@ module WarpEngine
token = current_application_token
return true if token.nil? || token.unrestricted?
software = WarpEngine::Software.find_by(name: name)
software = WarpEngine::Software.kept.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
@@ -53,7 +52,7 @@ module WarpEngine
token = current_application_token
return if token.nil? || token.unrestricted?
software = WarpEngine::Software.find_by(name: name)
software = WarpEngine::Software.kept.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)
@@ -1,5 +1,4 @@
module WarpEngine
class Api::Auth::DevicesController < ApiController
before_action :ensure_identity_configured
@@ -1,5 +1,4 @@
module WarpEngine
class Api::Auth::TokensController < ApiController
resource_description do
short "Client tokens"
@@ -1,5 +1,4 @@
module WarpEngine
class Api::ServiceController < ApiController
resource_description do
short "Service descriptor"
@@ -1,3 +1,5 @@
require "warp_engine/access_denied"
module WarpEngine
class ApiController < ActionController::API
resource_description do
@@ -10,7 +12,7 @@ module WarpEngine
include WarpEngine::ApiErrorRendering
include WarpEngine::SubjectAuthentication
rescue_from WarpEngine::DownloadService::Denied do
rescue_from WarpEngine::AccessDenied do
render json: { error: "Forbidden" }, status: :forbidden
end
@@ -1,6 +1,5 @@
module WarpEngine
module Build
class ConfigsController < ApiController
resource_description do
short "CI pipeline configs"
@@ -0,0 +1,22 @@
module WarpEngine
module SoftDeletable
extend ActiveSupport::Concern
included do
scope :kept, -> { where(deleted_at: nil) }
scope :discarded, -> { where.not(deleted_at: nil) }
end
def soft_delete!
update_column(:deleted_at, Time.current)
end
def restore!
update_column(:deleted_at, nil)
end
def discarded?
deleted_at.present?
end
end
end
@@ -1,5 +1,13 @@
module WarpEngine
class ApplicationRecord < ActiveRecord::Base
self.abstract_class = true
def self.ransackable_attributes(auth_object = nil)
column_names
end
def self.ransackable_associations(auth_object = nil)
reflect_on_all_associations.map(&:name).map(&:to_s)
end
end
end
@@ -9,12 +9,13 @@ module WarpEngine
CATALOG_SCOPE = "catalog".freeze
include SoftDeletable
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) }
scope :active, -> { kept.where("expires_at IS NULL OR expires_at > ?", Time.current) }
before_validation :assign_owner_type, on: :create
before_validation :generate_token, on: :create
@@ -45,7 +46,7 @@ module WarpEngine
end
def revoke!
update_column(:deleted_at, Time.current)
soft_delete!
end
def touch_last_used!
@@ -60,10 +61,6 @@ module WarpEngine
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
def self.ransackable_associations(auth_object = nil)
[]
end
@@ -1,5 +1,4 @@
module WarpEngine
class DeviceGrant < ApplicationRecord
self.table_name = "device_grants"
@@ -46,10 +45,6 @@ module WarpEngine
where(expires_at: ...Time.current).where.not(issued_token: nil).update_all(issued_token: nil)
end
def self.ransackable_attributes(auth_object = nil)
%w[approved_at client_name created_at denied_at expires_at id subject_id subject_type updated_at user_code]
end
def self.ransackable_associations(auth_object = nil)
[]
end
@@ -62,7 +57,6 @@ module WarpEngine
end
def self.generate_user_code
10.times do
candidate = Array.new(USER_CODE_LENGTH) { USER_CODE_ALPHABET.chars.sample }.join
return candidate unless exists?(user_code: candidate)
@@ -1,19 +1,11 @@
module WarpEngine
class Download < ApplicationRecord
include SoftDeletable
belongs_to :release, optional: true
validates :file_path, presence: true
default_scope { where(deleted_at: nil) }
def self.ransackable_attributes(auth_object = nil)
%w[created_at deleted_at file_path id ip_address referer release_id updated_at user_agent]
end
def self.ransackable_associations(auth_object = nil)
%w[release]
end
ActiveSupport.run_load_hooks(:warp_engine_download, self)
end
end
@@ -2,21 +2,13 @@ module WarpEngine
class ExternalLink < ApplicationRecord
self.table_name = "external_links"
include SoftDeletable
belongs_to :software
validates :label, presence: true
validates :url, presence: true
default_scope { where(deleted_at: nil) }
def self.ransackable_attributes(auth_object = nil)
%w[created_at deleted_at id label software_id updated_at url]
end
def self.ransackable_associations(auth_object = nil)
%w[software]
end
ActiveSupport.run_load_hooks(:warp_engine_external_link, self)
end
end
@@ -4,12 +4,12 @@ module WarpEngine
UNKNOWN_PLATFORM = "unknown".freeze
include SoftDeletable
belongs_to :software, class_name: "WarpEngine::Software", optional: true
attr_reader :software_taken_from
default_scope { where(deleted_at: nil) }
before_save :claim_software_from_other_pipelines, if: :will_save_change_to_software_id?
validates :woodpecker_repo_id, presence: true, uniqueness: true
@@ -18,7 +18,9 @@ module WarpEngine
validates :platform, presence: true,
inclusion: { in: WarpEngine::PlatformLink::SUPPORTED_PLATFORMS + [ UNKNOWN_PLATFORM ] }
scope :active, -> { where(active: true) }
scope :active, -> { kept.where(active: true) }
scope :by_remote_repo_id, ->(id) { where(woodpecker_repo_id: id) }
def full_name
"#{repo_owner}/#{repo_name}"
@@ -32,13 +34,8 @@ module WarpEngine
self.woodpecker_repo_id = value
end
def self.ransackable_attributes(auth_object = nil)
%w[active created_at deleted_at id last_pipeline_at last_pipeline_status
platform repo_name repo_owner software_id woodpecker_repo_id]
end
def self.ransackable_associations(auth_object = nil)
%w[software]
def self.find_or_initialize_by_remote_repo_id(id)
find_or_initialize_by(woodpecker_repo_id: id)
end
private
@@ -4,23 +4,16 @@ module WarpEngine
SUPPORTED_PLATFORMS = WarpEngine::Platform::NAMES
include SoftDeletable
validates :name, presence: true
validates :url, presence: true
validates :platform, presence: true, inclusion: { in: SUPPORTED_PLATFORMS }
default_scope { where(deleted_at: nil) }
scope :ordered, -> { order(:position) }
def self.ransackable_attributes(auth_object = nil)
%w[created_at deleted_at id name platform position updated_at url]
end
def self.ransackable_associations(auth_object = nil)
[]
end
def self.for_platform(platform)
ordered.where(platform: platform).to_a
kept.ordered.where(platform: platform).to_a
end
ActiveSupport.run_load_hooks(:warp_engine_platform_link, self)
@@ -2,19 +2,25 @@ module WarpEngine
class Release < ApplicationRecord
self.table_name = "releases"
include SoftDeletable
belongs_to :software
has_many :downloads
has_many :release_assets
has_many :downloads, -> { kept }
has_many :release_assets, -> { kept }
accepts_nested_attributes_for :release_assets, allow_destroy: true
validates :version, presence: true
validates :version, uniqueness: { scope: :software_id }
default_scope { where(deleted_at: nil) }
validate :c64_cannot_be_web_playable
def self.latest_non_dev(releases)
sorted = releases.sort_by { |r| [ r.created_at || Time.at(0), r.id ] }.reverse
candidates = sorted.reject { |r| r.version.to_s.start_with?("dev-") }
candidates.empty? ? sorted.first : candidates.first
end
def c64_cannot_be_web_playable
return unless software&.platform == "c64"
@@ -24,14 +30,6 @@ module WarpEngine
end
end
def self.ransackable_attributes(auth_object = nil)
%w[created_at deleted_at id software_id updated_at version]
end
def self.ransackable_associations(auth_object = nil)
%w[downloads release_assets software]
end
ActiveSupport.run_load_hooks(:warp_engine_release, self)
end
end
@@ -4,6 +4,8 @@ module WarpEngine
win_x86 win_x64 linux_x86 linux_x64 linux_arm64
mac_x64 mac_arm64 mac_universal].freeze
include SoftDeletable
belongs_to :release
enum :kind, KINDS.index_by(&:itself)
@@ -11,21 +13,11 @@ module WarpEngine
validates :path, presence: true
validates :kind, uniqueness: { scope: :release_id }
default_scope { where(deleted_at: nil) }
def self.for_relative_path(relative)
absolute = File.join(WarpEngine.config.file_container_path, relative.to_s)
find_by(path: absolute) || where("path LIKE ?", "%/#{sanitize_sql_like(relative.to_s)}").first
end
def self.ransackable_attributes(auth_object = nil)
%w[created_at deleted_at id kind path release_id updated_at]
end
def self.ransackable_associations(auth_object = nil)
%w[release]
end
ActiveSupport.run_load_hooks(:warp_engine_release_asset, self)
end
end
@@ -4,14 +4,16 @@ module WarpEngine
STATUSES = %w[development demo released archived].freeze
include SoftDeletable
belongs_to :owner, polymorphic: true, optional: true
has_many :software_images, -> { ordered }, foreign_key: :software_id, dependent: :destroy
has_many :images, through: :software_images
has_many :releases, foreign_key: :software_id
has_many :releases, -> { kept }, foreign_key: :software_id
has_many :downloads, through: :releases
has_many :external_links, foreign_key: :software_id
has_one :pipeline, foreign_key: :software_id
has_many :external_links, -> { kept }, foreign_key: :software_id
has_one :pipeline, -> { kept }, foreign_key: :software_id
accepts_nested_attributes_for :software_images, allow_destroy: true
accepts_nested_attributes_for :external_links, allow_destroy: true
@@ -22,16 +24,6 @@ module WarpEngine
validates :platform, presence: true, inclusion: { in: WarpEngine::Platform::NAMES }
validates :status, inclusion: { in: STATUSES }, allow_blank: true
default_scope { where(deleted_at: nil) }
def self.ransackable_attributes(auth_object = nil)
%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)
%w[releases external_links software_images images]
end
ActiveSupport.run_load_hooks(:warp_engine_software, self)
end
end
@@ -15,14 +15,6 @@ module WarpEngine
scope :ordered, -> { order(:position) }
def self.ransackable_attributes(auth_object = nil)
%w[created_at id image_id is_default position software_id updated_at]
end
def self.ransackable_associations(auth_object = nil)
%w[image software]
end
ActiveSupport.run_load_hooks(:warp_engine_software_image, self)
private
@@ -0,0 +1,16 @@
module WarpEngine
module AccessPolicyGuard
private
def authorize_download!(asset:, subject:, request: nil)
grant = WarpEngine.access_policy.authorize_download(asset: asset, subject: subject, request: request)
raise WarpEngine::AccessDenied if grant.nil?
grant
rescue WarpEngine::AccessDenied
raise
rescue StandardError => e
Rails.logger.error("[WarpEngine::AccessPolicy] #{e.class}: #{e.message}")
raise WarpEngine::AccessDenied
end
end
end
@@ -1,7 +1,6 @@
module WarpEngine
module Platforms
module Builds
module BuildLinuxArm64
extend ActiveSupport::Concern
@@ -11,6 +11,19 @@ module WarpEngine
raw = JSON.parse(File.read(path), symbolize_names: true)
raw.slice(*METADATA_KEYS)
end
def parse_lua_metadata(source_path)
metadata = {}
File.foreach(source_path) do |line|
break unless line.start_with?("--")
parts = line[2..].split(":", 2)
next if parts.length != 2
key = parts[0].strip.downcase.to_sym
value = parts[1].strip
metadata[key] = value
end
metadata.slice(*METADATA_KEYS)
end
end
end
end
@@ -4,7 +4,7 @@ module WarpEngine
private
def update_or_create_software(attrs)
software = WarpEngine::Software.unscoped.find_or_initialize_by(name: attrs[:name])
software = WarpEngine::Software.find_or_initialize_by(name: attrs[:name])
software.assign_attributes(attrs.except(:name))
software.deleted_at = nil
software.save!
@@ -12,14 +12,14 @@ module WarpEngine
end
def upsert_external_link(software_id, label, url)
link = WarpEngine::ExternalLink.unscoped.find_or_initialize_by(software_id: software_id, label: label)
link = WarpEngine::ExternalLink.find_or_initialize_by(software_id: software_id, label: label)
link.url = url
link.deleted_at = nil
link.save!
end
def create_release_if_not_exists(attrs)
existing = WarpEngine::Release.unscoped.find_by(software_id: attrs[:software_id], version: attrs[:version])
existing = WarpEngine::Release.find_by(software_id: attrs[:software_id], version: attrs[:version])
return existing if existing
WarpEngine::Release.create!(attrs)
@@ -27,7 +27,7 @@ module WarpEngine
def sync_release_assets(release, kind_paths)
kind_paths.each do |kind, path|
asset = WarpEngine::ReleaseAsset.unscoped.find_or_initialize_by(release_id: release.id, kind: kind)
asset = WarpEngine::ReleaseAsset.find_or_initialize_by(release_id: release.id, kind: kind)
asset.path = path
asset.deleted_at = nil
asset.save!
@@ -67,7 +67,7 @@ module WarpEngine
private
def scope
relation = WarpEngine::Software.order(:name).includes(releases: :release_assets)
relation = WarpEngine::Software.kept.order(:name).includes(releases: :release_assets)
relation = relation.where(name: name) if name
relation = relation.where(platform: platform) if platform
relation
@@ -113,9 +113,7 @@ module WarpEngine
def sorted(all) = all.sort_by { |release| [ release.created_at || Time.at(0), release.id ] }.reverse
def latest(all)
candidates = all.reject { |release| release.version.to_s.start_with?("dev-") }
candidates = all if candidates.empty?
sorted(candidates).first
Release.latest_non_dev(all)
end
end
end
@@ -11,7 +11,7 @@ module WarpEngine
end
def show(name)
software = WarpEngine::Software.find_by!(name: name)
software = WarpEngine::Software.kept.find_by!(name: name)
expected = WarpEngine::Platform.find!(software.platform).expected_kinds
releases = software.releases.includes(:release_assets).order(updated_at: :desc)
@@ -1,5 +1,4 @@
module WarpEngine
class DeviceGrantService
class NotConfigured < StandardError; end
class UnknownCode < StandardError; end
@@ -1,14 +1,17 @@
require "warp_engine/access_denied"
module WarpEngine
class DownloadService
Denied = WarpEngine::AccessDenied
class Denied < StandardError; end
include AccessPolicyGuard
def locate(path:, ip:, user_agent:, referer:, subject: nil, request: nil)
relative = path.to_s
return nil unless storage.file?(relative)
asset = find_asset(relative)
grant = authorize!(asset, subject, request)
grant = authorize_download!(asset: asset, subject: subject, request: request)
log_download(relative, asset: asset, ip: ip, user_agent: user_agent, referer: referer, subject: subject)
@@ -31,23 +34,13 @@ module WarpEngine
WarpEngine.storage
end
def authorize!(asset, subject, request)
grant = WarpEngine.access_policy.authorize_download(asset: asset, subject: subject, request: request)
raise Denied if grant.nil?
grant
rescue Denied
raise
rescue StandardError => e
Rails.logger.error("[WarpEngine::AccessPolicy] #{e.class}: #{e.message}")
raise Denied
end
def find_asset(relative)
WarpEngine::ReleaseAsset.for_relative_path(relative)
end
def log_download(relative, asset:, ip:, user_agent:, referer:, subject:)
return if WarpEngine.config.ignored_user_agent?(user_agent)
download = WarpEngine::Download.create!(
file_path: relative,
release: asset&.release,
@@ -1,6 +1,5 @@
module WarpEngine
class FileManagerService
def base_path
@base_path ||= Pathname.new(WarpEngine.config.file_container_path)
end
@@ -30,7 +29,7 @@ module WarpEngine
safe_name = sanitize_name(uploaded_file.original_filename)
target = dir.join(safe_name)
raise ArgumentError, "Path escape" unless target.to_s.start_with?(base_path.to_s)
ensure_within_base!(target)
IO.copy_stream(uploaded_file.to_io, target.to_s)
target.relative_path_from(base_path).to_s
@@ -53,7 +52,7 @@ module WarpEngine
safe_name = sanitize_name(new_name)
new_full = full.parent.join(safe_name)
raise ArgumentError, "Path escape" unless new_full.to_s.start_with?(base_path.to_s)
ensure_within_base!(new_full)
full.rename(new_full)
new_full.relative_path_from(base_path).to_s
@@ -65,7 +64,7 @@ module WarpEngine
safe_name = sanitize_name(folder_name)
new_dir = parent.join(safe_name)
raise ArgumentError, "Path escape" unless new_dir.to_s.start_with?(base_path.to_s)
ensure_within_base!(new_dir)
new_dir.mkdir
new_dir.relative_path_from(base_path).to_s
@@ -73,6 +72,10 @@ module WarpEngine
private
def ensure_within_base!(path)
raise ArgumentError, "Path escape" unless path.to_s.start_with?(base_path.to_s)
end
def safe_path!(relative_path)
cleaned = relative_path.to_s.gsub("..", "").squeeze("/").gsub(%r{^/|/$}, "")
full = base_path.join(cleaned)
@@ -1,5 +1,8 @@
require "warp_engine/access_denied"
module WarpEngine
class FileService
include AccessPolicyGuard
def show(input, subject: nil)
relative = input.path.to_s
@@ -21,19 +24,10 @@ module WarpEngine
private
def authorize!(relative, subject)
return WarpEngine::Access::Grant::OPEN if WarpEngine::AccessPolicy.open?
asset = WarpEngine::ReleaseAsset.for_relative_path(relative)
grant = WarpEngine.access_policy.authorize_download(asset: asset, subject: subject, request: nil)
raise WarpEngine::DownloadService::Denied if grant.nil?
grant
rescue WarpEngine::DownloadService::Denied
raise
rescue StandardError => e
Rails.logger.error("[WarpEngine::AccessPolicy] #{e.class}: #{e.message}")
raise WarpEngine::DownloadService::Denied
authorize_download!(asset: asset, subject: subject, request: nil)
end
def storage
@@ -10,7 +10,7 @@ module WarpEngine
remote_ids = remote_repos.map(&:id)
remote_repos.each do |remote|
record = Pipeline.unscoped.find_or_initialize_by(woodpecker_repo_id: remote.id)
record = Pipeline.find_or_initialize_by_remote_repo_id(remote.id)
was_new = record.new_record?
record.assign_attributes(
@@ -27,7 +27,7 @@ module WarpEngine
results[was_new ? :created : :updated] << record
end
Pipeline.where.not(woodpecker_repo_id: remote_ids).find_each do |orphan|
Pipeline.kept.where.not(woodpecker_repo_id: remote_ids).find_each do |orphan|
orphan.update!(active: false) if orphan.active?
results[:deactivated] << orphan
end
@@ -42,7 +42,7 @@ module WarpEngine
def deactivate(repo_id)
@ci.deactivate_repo(repo_id)
record = Pipeline.find_by!(woodpecker_repo_id: repo_id)
record = Pipeline.kept.find_by!(woodpecker_repo_id: repo_id)
record.update!(active: false)
end
@@ -50,7 +50,7 @@ module WarpEngine
def sync_single(repo_id)
remote = @ci.repo(repo_id)
record = Pipeline.unscoped.find_or_initialize_by(woodpecker_repo_id: repo_id)
record = Pipeline.find_or_initialize_by_remote_repo_id(repo_id)
record.assign_attributes(
repo_name: remote.name, repo_owner: remote.owner,
active: remote.active?, deleted_at: nil
@@ -63,7 +63,7 @@ module WarpEngine
def assign_platform(record, repo_name)
return unless record.platform.blank? || record.platform == Pipeline::UNKNOWN_PLATFORM
sw = Software.find_by(name: repo_name)
sw = Software.kept.find_by(name: repo_name)
record.platform = sw&.platform || Pipeline::UNKNOWN_PLATFORM
record.software = sw if sw
end
@@ -21,19 +21,6 @@ module WarpEngine
def parse_metadata(versioned)
parse_lua_metadata(full_path("#{versioned}.lua"))
end
def parse_lua_metadata(source_path)
metadata = {}
File.foreach(source_path) do |line|
break unless line.start_with?("--")
parts = line[2..].split(":", 2)
next if parts.length != 2
key = parts[0].strip.downcase.to_sym
value = parts[1].strip
metadata[key] = value
end
metadata.slice(*MetadataParsing::METADATA_KEYS)
end
end
end
end
@@ -1,6 +1,5 @@
module WarpEngine
class PublishService
NOTIFICATION = "warp_engine.publish".freeze
def publish(input)
@@ -36,22 +36,28 @@ module WarpEngine
pipelines = pipelines_for_token(application_token)
return { rotated: false, reason: "no pipelines" } if pipelines.empty?
new_token = ApplicationToken.create!(
name: "#{application_token.name} (rotated #{Date.current})",
owner_id: application_token.owner_id,
owner_type: application_token.owner_type,
scopes: application_token.scopes,
expires_at: application_token.expires_at,
unrestricted: application_token.unrestricted?
)
new_token = nil
ActiveRecord::Base.transaction do
new_token = ApplicationToken.create!(
name: "#{application_token.name} (rotated #{Date.current})",
owner_id: application_token.owner_id,
owner_type: application_token.owner_type,
scopes: application_token.scopes,
expires_at: application_token.expires_at,
unrestricted: application_token.unrestricted?
)
application_token.soft_delete!
end
result = provision(new_token.plain_token, pipelines: pipelines)
if result[:synced].any?
application_token.revoke!
{ rotated: true, new_token: new_token, sync_result: result }
else
new_token.revoke!
ActiveRecord::Base.transaction do
application_token.restore!
new_token.soft_delete!
end
{ rotated: false, reason: "all pipelines failed", sync_result: result }
end
end
@@ -60,7 +66,7 @@ module WarpEngine
if application_token.unrestricted?
Pipeline.active.to_a
else
software_ids = Software.where(
software_ids = Software.kept.where(
owner_type: application_token.owner_type,
owner_id: application_token.owner_id
).pluck(:id)
@@ -3,13 +3,15 @@ module WarpEngine
include SoftwareResponseBuilder
def index(subject: nil)
software = visible_scope(subject).includes(:external_links, :software_images)
.where(highlighted: true)
.order(id: :desc)
.first
software = visible_scope(subject)
.includes(releases: :release_assets)
.includes(:external_links, :software_images)
.where(highlighted: true)
.order(id: :desc)
.first
return nil unless software
releases = WarpEngine::Release.includes(:release_assets).where(software_id: software.id).to_a
releases = software.releases.to_a
build_response(software, releases, download_counts_for(releases.map(&:id)), subject: subject)
end
end
@@ -4,7 +4,7 @@ module WarpEngine
def build_response(software, releases, download_counts, subject: nil)
sorted = releases.sort_by { |r| r.created_at || Time.at(0) }.reverse
latest = sorted.reject { |r| r.version.to_s.start_with?("dev-") }.first
latest = Release.latest_non_dev(releases)
web_playable = latest if latest&.release_assets&.any? { |a| a.kind == "html" }
total_downloads = releases.sum { |r| download_counts.fetch(r.id, 0) }
@@ -36,7 +36,7 @@ module WarpEngine
def download_counts_for(release_ids)
return {} if release_ids.empty?
WarpEngine::Download.where(release_id: release_ids).group(:release_id).count
WarpEngine::Download.kept.where(release_id: release_ids).group(:release_id).count
end
end
end
@@ -12,7 +12,7 @@ namespace :warp_engine do
checklist = WarpEngine::AssetCoverageChecklist.new(
coverage, only_missing: ENV["ONLY"] == "missing"
)
puts checklist.to_s
puts checklist
if ENV["STRICT"] == "1" && coverage.totals[:missing].positive?
abort "\nSTRICT: #{coverage.totals[:missing]} expected assets are missing."
+1 -1
View File
@@ -8,11 +8,11 @@ require "warp_engine/configuration"
require "warp_engine/platform"
require "warp_engine/storage"
require "warp_engine/access"
require "warp_engine/access_denied"
require "warp_engine/images"
require "warp_engine/ci"
module WarpEngine
def self.table_name_prefix
""
end
@@ -1,10 +1,8 @@
module WarpEngine
module AccessPolicy
class Open
def visible_software_scope(subject: nil)
WarpEngine::Software.all
WarpEngine::Software.kept
end
def access_for(software:, subject: nil)
@@ -39,7 +37,6 @@ module WarpEngine
end
class Access
attr_reader :gated, :entitled, :price_cents, :currency, :purchase_url, :web_url
def initialize(gated: false, entitled: true, price_cents: nil, currency: nil,
@@ -0,0 +1,3 @@
module WarpEngine
class AccessDenied < StandardError; end
end
+8 -1
View File
@@ -1,5 +1,4 @@
module WarpEngine
module CI
class Error < StandardError; end
@@ -69,23 +68,30 @@ module WarpEngine
case configured
when nil, :woodpecker, "woodpecker" then woodpecker_adapter
when :gitlab, "gitlab" then gitlab_adapter
when :none, "none", :null then null_adapter
else configured
end
end
def woodpecker? = adapter.is_a?(Woodpecker::Adapter)
def gitlab? = adapter.is_a?(Gitlab::Adapter)
def woodpecker_adapter
@woodpecker_adapter ||= Woodpecker::Adapter.new
end
def gitlab_adapter
@gitlab_adapter ||= Gitlab::Adapter.new
end
def null_adapter
@null_adapter ||= Null.new
end
def reset!
@woodpecker_adapter = nil
@gitlab_adapter = nil
@null_adapter = nil
end
end
@@ -93,3 +99,4 @@ module WarpEngine
end
require "warp_engine/ci/woodpecker"
require "warp_engine/ci/gitlab"
@@ -0,0 +1,4 @@
require "warp_engine/ci/gitlab/client"
require "warp_engine/ci/gitlab/signature_verifier"
require "warp_engine/ci/gitlab/pipeline_config"
require "warp_engine/ci/gitlab/adapter"
@@ -0,0 +1,144 @@
module WarpEngine
module CI
module Gitlab
class Adapter
attr_reader :url, :update_server
def initialize(url: ENV["GITLAB_URL"],
api_token: ENV["GITLAB_API_TOKEN"],
webhook_secret: ENV["GITLAB_WEBHOOK_SECRET"],
platforms: {},
update_server: nil)
@url = url.presence
@api_token = api_token.presence
@webhook_secret = webhook_secret.presence
@update_server = update_server.presence
@config = PipelineConfig.new(platforms: platforms || {})
end
def name = "GitLab"
def configured? = @url.present? && @api_token.present?
def platforms = @config.platforms
def built_kinds(platform) = @config.built_kinds(platform)
def client
@client ||= Client.new(url: @url, token: @api_token)
end
def repos
Array(client.list_repos).map { |remote| to_repo(remote) }
end
def repo(project_id)
to_repo(client.get_repo(project_id))
end
def activate_repo(project_id)
client.activate_repo(project_id)
nil
end
def deactivate_repo(project_id)
client.deactivate_repo(project_id)
nil
end
def runs(project_id, page: 1)
Array(client.list_pipelines(project_id, page: page)).map { |run| to_run(run) }
end
def run(project_id, number)
remote = if number.to_s == "latest"
client.latest_pipeline(project_id)
else
client.get_pipeline(project_id, number)
end
remote && to_run(remote)
end
def trigger(project_id, branch: "main")
to_run(client.trigger_pipeline(project_id, branch: branch || "main"))
end
def secret_names(project_id)
Array(client.list_secrets(project_id)).map { |var| var["key"] }
end
def secret_set(project_id, name:, value:)
if secret_names(project_id).include?(name)
client.update_secret(project_id, name, value: value)
else
client.create_secret(project_id, name: name, value: value)
end
nil
end
def secret_delete(project_id, name)
client.delete_secret(project_id, name)
nil
end
def verify_config_request(request)
SignatureVerifier.new(request, webhook_secret: @webhook_secret).valid?
end
def config_marker(params)
configs = params[:configuration].presence || params[:configs].presence || []
Array(configs).each do |config|
parsed = parse_marker(config[:data].to_s)
next unless parsed
return { platform: parsed["platform"].to_s,
name: parsed["name"].presence || params.dig(:repo, :name).to_s }
end
nil
end
def pipeline_config(platform:, name:, update_server:)
@config.render(platform: platform, name: name, update_server: update_server)
end
def config_response(platform:, config:)
{ configs: [ { name: platform, data: config } ] }
end
private
def parse_marker(data)
parsed = begin
YAML.safe_load(data)
rescue Psych::Exception
nil
end
parsed if parsed.is_a?(Hash) && parsed.key?("platform")
end
def to_repo(remote)
return nil if remote.nil?
namespace = remote.dig("namespace", "path") || remote.dig("namespace", "name")
Repo.new(id: remote["id"], name: remote["path"] || remote["name"],
owner: namespace, active: true, raw: remote)
end
def to_run(remote)
return nil if remote.nil?
created = remote["created_at"] ? Time.zone.parse(remote["created_at"]) : nil
Run.new(
number: remote["id"],
status: remote["status"],
branch: remote["ref"],
message: nil,
created_at: created,
url: remote["web_url"],
raw: remote
)
end
end
end
end
end
@@ -0,0 +1,145 @@
require "net/http"
require "json"
require "uri"
module WarpEngine
module CI
module Gitlab
class Client
def initialize(url:, token:)
@base_url = url.to_s.chomp("/")
@token = token
end
def list_repos
get("/api/v4/projects", params: { membership: true, simple: true, per_page: 100 })
end
def get_repo(project_id)
get("/api/v4/projects/#{project_id}")
end
def activate_repo(_project_id)
nil
end
def deactivate_repo(_project_id)
nil
end
def list_secrets(project_id)
get("/api/v4/projects/#{project_id}/variables")
end
def create_secret(project_id, name:, value:)
post("/api/v4/projects/#{project_id}/variables",
body: { key: name, value: value, protected: false, masked: true })
end
def update_secret(project_id, secret_name, value:)
put("/api/v4/projects/#{project_id}/variables/#{secret_name}",
body: { value: value })
end
def delete_secret(project_id, secret_name)
delete("/api/v4/projects/#{project_id}/variables/#{secret_name}")
end
def list_pipelines(project_id, page: 1, per_page: 25)
get("/api/v4/projects/#{project_id}/pipelines",
params: { page: page, per_page: per_page })
end
def latest_pipeline(project_id)
results = get("/api/v4/projects/#{project_id}/pipelines",
params: { per_page: 1, sort: "desc" })
Array(results).first
end
def get_pipeline(project_id, pipeline_id)
get("/api/v4/projects/#{project_id}/pipelines/#{pipeline_id}")
end
def trigger_pipeline(project_id, branch: "main")
post("/api/v4/projects/#{project_id}/pipeline",
body: { ref: branch })
end
private
def get(path, params: {})
uri = build_uri(path, params)
execute(uri, Net::HTTP::Get.new(uri))
end
def post(path, body: {})
uri = build_uri(path)
request = Net::HTTP::Post.new(uri)
request.body = body.to_json
request.content_type = "application/json"
execute(uri, request)
end
def put(path, body: {})
uri = build_uri(path)
request = Net::HTTP::Put.new(uri)
request.body = body.to_json
request.content_type = "application/json"
execute(uri, request)
end
def delete(path)
uri = build_uri(path)
execute(uri, Net::HTTP::Delete.new(uri))
end
def build_uri(path, params = {})
uri = URI.parse("#{@base_url}#{path}")
uri.query = URI.encode_www_form(params) if params.any?
uri
end
def execute(uri, request)
request["PRIVATE-TOKEN"] = @token
request["Accept"] = "application/json"
response = Net::HTTP.start(uri.hostname, uri.port,
use_ssl: uri.scheme == "https",
open_timeout: 10,
read_timeout: 30) do |http|
http.request(request)
end
handle_response(uri, response)
rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Net::OpenTimeout,
Net::ReadTimeout, SocketError => e
raise CI::ConnectionError, "Cannot reach GitLab at #{@base_url}: #{e.message}"
end
def handle_response(uri, response)
case response
when Net::HTTPSuccess, Net::HTTPNoContent
return nil if response.body.blank?
begin
JSON.parse(response.body)
rescue JSON::ParserError
raise CI::ApiError.new(
"Expected JSON from #{uri.path} but got: #{response.body.truncate(80)}",
status: response.code.to_i, body: response.body
)
end
when Net::HTTPNotFound
raise CI::ApiError.new("Not found: #{uri.path}", status: 404, body: response.body)
else
raise CI::ApiError.new(
"GitLab API error #{response.code}: #{response.body&.truncate(200)}",
status: response.code.to_i,
body: response.body
)
end
end
end
end
end
end
@@ -0,0 +1,59 @@
require "erb"
require "pathname"
module WarpEngine
module CI
module Gitlab
class PipelineConfig
PLATFORM_FORMAT = /\A[a-z0-9_-]+\z/
BUILT_KINDS = {
"tic80" => %w[cartridge source html docs win_x64 linux_x64 mac_x64],
"ebitengine" => %w[html win_x86 win_x64 linux_x64 linux_arm64],
"godot" => %w[html win_x86 win_x64 linux_x64 mac_universal],
"love" => %w[html win_x64 linux_x64 mac_universal],
"bevy" => %w[html win_x64 linux_x64 linux_arm64],
"c64" => %w[cartridge],
"phaser" => %w[html]
}.freeze
def initialize(platforms: {})
@platforms = platforms.to_h { |key, spec| [ key.to_s, spec.to_h.symbolize_keys ] }
end
def platforms
@platforms
end
def render(platform:, name:, update_server:)
platform = platform.to_s
return nil unless platform.match?(PLATFORM_FORMAT)
spec = @platforms[platform]
return nil if spec.nil?
path = self.class.templates_dir.join(platform, "pipeline.yaml.erb")
return nil unless path.exist?
ERB.new(path.read, trim_mode: "-").result_with_hash(
name: name.to_s,
update_server: update_server.to_s,
builder: spec[:builder],
exporter: spec[:exporter]
)
end
def built_kinds(platform)
platform = platform.to_s
return nil unless @platforms.key?(platform)
BUILT_KINDS[platform]
end
def self.templates_dir
Pathname.new(__dir__).join("platforms")
end
end
end
end
end
@@ -0,0 +1,129 @@
# Generated pipeline — WarpEngine /build/config (platform: bevy, name: <%= name %>)
stages:
- version
- build
- binaries
- upload
- publish
version:
stage: version
image: alpine
script:
- 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
artifacts:
paths:
- .version
- metadata.json
build:
stage: build
image: <%= builder %>
script:
- |
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
artifacts:
paths:
- "*.zip"
binaries:
stage: binaries
image: <%= builder %>
script:
- |
VERSION=$(cat .version)
# Native binaries. linux-x64: glibc build in the debian-based builder
# image; win-x64: mingw-w64 cross-compile (x86_64-pc-windows-gnu).
# Mac needs osxcross, it is not built here.
# The zip gets the assets/ dir too if the project has one — bevy loads
# it at runtime, it is not embedded in the binary.
set -e
pack_binary() {
P_SLUG="$1"; P_BIN="$2"; P_NAME="$3"
PKG_DIR="<%= name %>-$VERSION-$P_SLUG"
rm -rf "$PKG_DIR" "$PKG_DIR.zip"
mkdir -p "$PKG_DIR"
cp "$P_BIN" "$PKG_DIR/$P_NAME"
chmod +x "$PKG_DIR/$P_NAME"
if [ -d assets ]; then cp -r assets "$PKG_DIR/assets"; fi
zip -qr "$PKG_DIR.zip" "$PKG_DIR"
rm -rf "$PKG_DIR"
echo "==> $PKG_DIR.zip kesz"
}
echo "==> Building linux-x64 binary"
cargo build --release
pack_binary "linux-x64" "target/release/<%= name %>" "<%= name %>"
echo "==> Building win-x64 binary"
CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER=x86_64-w64-mingw32-gcc \
cargo build --release --target x86_64-pc-windows-gnu
pack_binary "win-x64" "target/x86_64-pc-windows-gnu/release/<%= name %>.exe" "<%= name %>.exe"
# linux-arm64: Raspberry Pi, Odroid, retro handhelds. pkg-config has to
# be told it may cross, and pointed at the arm64 .pc files, otherwise
# alsa-sys/libudev-sys pick up the host x86_64 libraries.
echo "==> Building linux-arm64 binary"
PKG_CONFIG_ALLOW_CROSS=1 \
PKG_CONFIG_PATH=/usr/lib/aarch64-linux-gnu/pkgconfig \
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc \
cargo build --release --target aarch64-unknown-linux-gnu
pack_binary "linux-arm64" "target/aarch64-unknown-linux-gnu/release/<%= name %>" "<%= name %>"
artifacts:
paths:
- "*.zip"
upload:
stage: upload
image: alpine
variables:
UPDATE_SERVER: "<%= update_server %>"
UPDATE_SECRET: "$application_token"
script:
- apk add --no-cache curl
- |
VERSION=$(cat .version)
FILE="<%= name %>-$VERSION.html.zip"
META_SRC="metadata.json"
META_DST="<%= name %>-$VERSION.metadata.json"
cp $META_SRC $META_DST
BINS=""
for slug in win-x64 linux-x64 linux-arm64; do
[ -f "<%= name %>-$VERSION-$slug.zip" ] && BINS="$BINS <%= name %>-$VERSION-$slug.zip"
done
for f in $FILE $META_DST $BINS; do
curl -fsS -H "X-Update-Secret: $UPDATE_SECRET" \
-F "file=@$f" \
"$UPDATE_SERVER/build/upload?name=<%= name %>&version=$VERSION" || exit 1
done
publish:
stage: publish
image: alpine
variables:
UPDATE_SERVER: "<%= update_server %>"
UPDATE_SECRET: "$application_token"
script:
- 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,74 @@
# Generated pipeline — WarpEngine /build/config (platform: c64, name: <%= name %>)
stages:
- version
- build
- artifact
- publish
version:
stage: version
image: alpine
script:
- |
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"
BRANCH=$(echo "$BRANCH" | tr '/' '-')
if [ "$BRANCH" != "main" ] && [ "$BRANCH" != "master" ] && [ -n "$BRANCH" ]; then
VERSION="dev-$VERSION-$BRANCH"
fi
echo "VERSION is: $VERSION"
echo $VERSION > .version
artifacts:
paths:
- .version
- metadata.json
build:
stage: build
image: <%= builder %>
script:
- |
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.*
artifacts:
paths:
- "*.prg"
- "*.metadata.json"
artifact:
stage: artifact
image: alpine
variables:
UPDATE_SERVER: "<%= update_server %>"
UPDATE_SECRET: "$application_token"
script:
- 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
publish:
stage: publish
image: alpine
variables:
UPDATE_SERVER: "<%= update_server %>"
UPDATE_SECRET: "$application_token"
script:
- 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,118 @@
# Generated pipeline — WarpEngine /build/config (platform: ebitengine, name: <%= name %>)
stages:
- version
- build
- binaries
- artifact
- publish
version:
stage: version
image: alpine
script:
- 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
artifacts:
paths:
- .version
- metadata.json
build:
stage: build
image: <%= builder %>
script:
- |
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
artifacts:
paths:
- "*.zip"
binaries:
stage: binaries
image: <%= builder %>
script:
- |
VERSION=$(cat .version)
# win-x86 / win-x64: pure Go cross-compile (Windowson nem kell cgo)
# linux-x64: cgo build, linux/amd64 hoston fut (builder image, X11/GL dev libekkel)
# helper: builds one target + zips it with a single root folder
# (unix zip keeps the executable bit)
binary_build() {
B_GOOS="$1"; B_GOARCH="$2"; B_CGO="$3"; B_EXT="$4"; B_TARGET="$5"; B_CC="$6"
PKG_DIR="<%= name %>-$VERSION-$B_TARGET"
echo "==> Building $PKG_DIR"
rm -rf "$PKG_DIR" "$PKG_DIR.zip"
mkdir -p "$PKG_DIR"
# cgo cross-compile needs an explicit cross gcc; a native build must
# not see CC at all, otherwise go picks the wrong compiler
if [ -n "$B_CC" ]; then export CC="$B_CC"; else unset CC; fi
CGO_ENABLED=$B_CGO GOOS=$B_GOOS GOARCH=$B_GOARCH go build -o "$PKG_DIR/<%= name %>$B_EXT" .
if [ -f LICENSE ]; then cp LICENSE "$PKG_DIR/"; fi
if [ -f README.md ]; then cp README.md "$PKG_DIR/"; fi
zip -r "$PKG_DIR.zip" "$PKG_DIR" >/dev/null
rm -rf "$PKG_DIR"
echo "==> $PKG_DIR.zip kesz"
}
# CI (linux builder) builds these four:
binary_build "windows" "386" "0" ".exe" "win-x86"
binary_build "windows" "amd64" "0" ".exe" "win-x64"
binary_build "linux" "amd64" "1" "" "linux-x64"
# linux-arm64: Raspberry Pi, Odroid, retro handhelds — cgo cross-build
# against the arm64 X11/GL/ALSA headers in the builder image
binary_build "linux" "arm64" "1" "" "linux-arm64" "aarch64-linux-gnu-gcc"
artifacts:
paths:
- "*.zip"
artifact:
stage: artifact
image: alpine
variables:
UPDATE_SERVER: "<%= update_server %>"
UPDATE_SECRET: "$application_token"
script:
- 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
publish:
stage: publish
image: alpine
variables:
UPDATE_SERVER: "<%= update_server %>"
UPDATE_SECRET: "$application_token"
script:
- 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,114 @@
# Generated pipeline — WarpEngine /build/config (platform: godot, name: <%= name %>)
stages:
- version
- build
- upload
- publish
version:
stage: version
image: alpine
script:
- 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
artifacts:
paths:
- .version
- metadata.json
build:
stage: build
image: <%= builder %>
script:
- |
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"
artifacts:
paths:
- "*.zip"
upload:
stage: upload
image: alpine
variables:
UPDATE_SERVER: "<%= update_server %>"
UPDATE_SECRET: "$application_token"
script:
- 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
publish:
stage: publish
image: alpine
variables:
UPDATE_SERVER: "<%= update_server %>"
UPDATE_SECRET: "$application_token"
script:
- 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,191 @@
# Generated pipeline — WarpEngine /build/config (platform: love, name: <%= name %>)
stages:
- version
- export
- binaries
- upload
- publish
version:
stage: version
image: alpine
script:
- 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
artifacts:
paths:
- .version
- metadata.json
export:
stage: export
image: <%= builder %>
script:
- |
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
artifacts:
paths:
- "*.zip"
binaries:
stage: binaries
image: <%= builder %>
script:
- |
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"
artifacts:
paths:
- "*.zip"
upload:
stage: upload
image: alpine
variables:
UPDATE_SERVER: "<%= update_server %>"
UPDATE_SECRET: "$application_token"
script:
- 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
publish:
stage: publish
image: alpine
variables:
UPDATE_SERVER: "<%= update_server %>"
UPDATE_SECRET: "$application_token"
script:
- 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,106 @@
# Generated pipeline — WarpEngine /build/config (platform: phaser, name: <%= name %>)
stages:
- version
- build
- upload
- publish
version:
stage: version
image: alpine
script:
- 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
artifacts:
paths:
- .version
- metadata.json
build:
stage: build
image: <%= builder %>
script:
- |
VERSION=$(cat .version)
# Ketfele projektforma el egymas mellett: a sima JS (a forrasok
# osszefuzve, a Phaser CDN-rol) es a bundleres (Vite + TypeScript),
# ami maga allitja elo a kesz webes csomagot.
if [ -f package.json ] && grep -q '"build"' package.json; then
echo "==> Bundled project — npm ci && npm run build"
npm ci
npm run build
# A Vite kimenete onmagaban teljes: index.html + a beforgatott
# assetek. A vite.config base-enek relativnak kell lennie, mert a
# jatek a /file/<nev>-<verzio>/ alkonyvtarbol szolgal ki.
if [ ! -f dist/index.html ]; then
echo "ERROR: a build nem hagyott dist/index.html-t" >&2
exit 1
fi
echo "==> Packaging web build for $VERSION"
(cd dist && zip -r "../<%= name %>-$VERSION.html.zip" .)
echo "==> Cleaning temporary files"
rm -rf dist
else
echo "==> Checking JS syntax"
# A bundleres projektben nincs src/*.js, es a shell ilyenkor a
# mintat adja tovabb literalkent — a node MODULE_NOT_FOUND-dal
# szall el rajta.
for f in src/*.js; do [ -e "$f" ] || continue; node --check "$f"; done
mkdir -p dist/web
echo "==> Downloading Phaser 3.90.0"
curl -sSL https://cdn.jsdelivr.net/npm/phaser@3.90.0/dist/phaser.min.js -o dist/web/phaser.min.js
echo "==> Downloading index.html"
curl -sSL https://git.teletypegames.org/build/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
fi
artifacts:
paths:
- "*.zip"
upload:
stage: upload
image: alpine
variables:
UPDATE_SERVER: "<%= update_server %>"
UPDATE_SECRET: "$application_token"
script:
- 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
publish:
stage: publish
image: alpine
variables:
UPDATE_SERVER: "<%= update_server %>"
UPDATE_SECRET: "$application_token"
script:
- 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,232 @@
# 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.
stages:
- version
- lint
- minify
- docs
- export
- binaries
- upload
- publish
version:
stage: version
image: alpine
script:
- |
VERSION=$(sed -n "s/^-- version: //p" inc/meta/meta.header.lua | head -n 1 | tr -d "[:space:]")
BRANCH="$CI_COMMIT_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
artifacts:
paths:
- .version
lint:
stage: lint
image: alpine
script:
- apk add --no-cache lua5.4 lua5.4-dev luarocks gcc musl-dev
- ln -sf /usr/bin/lua5.4 /usr/bin/lua
- ln -sf /usr/bin/luarocks-5.4 /usr/bin/luarocks
- luarocks install luacheck
- |
echo "==> Merging..."
rm -f /tmp/_lint_combined.lua /tmp/_lint_map.txt
touch /tmp/_lint_combined.lua
line=1
while IFS= read -r f || [ -n "$f" ]; do
f=$(printf '%s' "$f" | tr -d '\r')
[ -z "$f" ] && continue
before=$(wc -l < /tmp/_lint_combined.lua)
cat "inc/$f" >> /tmp/_lint_combined.lua
printf '\n' >> /tmp/_lint_combined.lua
after=$(wc -l < /tmp/_lint_combined.lua)
linecount=$((after - before))
echo "$line $linecount inc/$f" >> /tmp/_lint_map.txt
line=$((line + linecount))
done < <%= name %>.inc
echo "==> luacheck..."
LINT_OUTPUT=$(luacheck --no-max-line-length /tmp/_lint_combined.lua 2>&1 | awk -v map=/tmp/_lint_map.txt '
BEGIN {
NR_map = 0;
while ((getline line < map) > 0) {
n = split(line, a, " ");
start[NR_map] = a[1]+0;
count[NR_map] = a[2]+0;
fname[NR_map] = a[3];
NR_map++;
}
}
/^[^:]+:[0-9]+:[0-9]+:/ {
colon1 = index($0, ":");
rest1 = substr($0, colon1+1);
colon2 = index(rest1, ":");
absline = substr(rest1, 1, colon2-1) + 0;
rest2 = substr(rest1, colon2+1);
colon3 = index(rest2, ":");
col = substr(rest2, 1, colon3-1);
rest = substr(rest2, colon3);
found = 0;
for (i = 0; i < NR_map; i++) {
end_line = start[i] + count[i] -1;
if (absline >= start[i] && absline <= end_line) {
relline = absline - start[i] + 1;
print fname[i] ":" relline ":" col ":" rest;
found = 1;
break;
}
}
if (!found) print $0;
next;
}
{ print }
')
echo "$LINT_OUTPUT"
NUM_ISSUES=$(echo "$LINT_OUTPUT" | grep -cE "^[^:]+:[0-9]+:[0-9]+:" || true)
if [ "$NUM_ISSUES" -gt 0 ]; then
echo "Total: $NUM_ISSUES issue(s) found, commit aborted."
exit 1
else
echo "Checking /tmp/_lint_combined.lua OK"
echo "Total: 0 warnings / 0 errors in 1 file"
fi
rm -f /tmp/_lint_combined.lua /tmp/_lint_map.txt
minify:
stage: minify
image: alpine
script:
- apk add --no-cache lua5.4 curl
- ln -sf /usr/bin/lua5.4 /usr/bin/lua
- |
rm -f <%= name %>.lua
sed 's/\r$//' <%= name %>.inc | while read f; do
cat "inc/$f" >> <%= name %>.lua
echo "" >> <%= name %>.lua
done
test -f minify.lua || { echo "==> Downloading minify.lua"; curl -fsSL https://raw.githubusercontent.com/ztimar31/lua-minify-tic80/refs/heads/master/minify.lua -o minify.lua; }
echo "==> Minifying <%= name %>.lua"
cp <%= name %>.lua <%= name %>.original.lua
lua minify.lua minify <%= name %>.original.lua > <%= name %>.lua
artifacts:
paths:
- "<%= name %>.lua"
- "<%= name %>.original.lua"
docs:
stage: docs
image: alpine
script:
- apk add --no-cache lua5.4 lua5.4-dev luarocks gcc musl-dev zip
- ln -sf /usr/bin/lua5.4 /usr/bin/lua
- ln -sf /usr/bin/luarocks-5.4 /usr/bin/luarocks
- luarocks install ldoc
- |
VERSION=$(cat .version)
echo "==> Generating docs from <%= name %>.original.lua"
ldoc <%= name %>.original.lua -d docs
echo "==> Zipping docs for version $VERSION"
(cd docs && zip -r ../<%= name %>-$VERSION-docs.zip .)
cp <%= name %>-$VERSION-docs.zip <%= name %>-docs.zip
echo "==> Docs zip created"
artifacts:
paths:
- "*-docs.zip"
export:
stage: export
image: <%= builder %>
variables:
XDG_RUNTIME_DIR: /tmp
script:
- |
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
artifacts:
paths:
- "<%= name %>.lua"
- "*.tic"
- "*.html.zip"
binaries:
stage: binaries
image: <%= builder %>
variables:
XDG_RUNTIME_DIR: /tmp
script:
- |
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 %>
artifacts:
paths:
- "*.zip"
upload:
stage: upload
image: alpine
variables:
UPDATE_SERVER: "<%= update_server %>"
UPDATE_SECRET: "$application_token"
script:
- 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
publish:
stage: publish
image: alpine
variables:
UPDATE_SERVER: "<%= update_server %>"
UPDATE_SECRET: "$application_token"
script:
- 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"

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