Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ffbc7a2ca | ||
|
|
653894a876 | ||
|
|
97d71a7c99 | ||
|
|
a58e91520e | ||
|
|
259de781bb | ||
|
|
6f2a39fe1b | ||
|
|
fbdfbe954e | ||
|
|
b51bf0d094 | ||
|
|
fc1076f8fc | ||
|
|
3e70717e8f | ||
|
|
c0252928c6 | ||
|
|
3a524d5003 |
Executable
+10
@@ -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")
|
||||
@@ -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
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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']}",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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']}",
|
||||
|
||||
@@ -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,5 +1,4 @@
|
||||
class StoreService
|
||||
|
||||
def index
|
||||
StoreSerializer.render_as_hash(Store.active.ordered)
|
||||
end
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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}"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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`)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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/**/*"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
-4
@@ -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."
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
+118
@@ -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
Reference in New Issue
Block a user