Compare commits
37
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
546201c886 | ||
|
|
c853cfadbc | ||
|
|
b1139a43bc | ||
|
|
bae6fc06a6 | ||
|
|
731b267aa1 | ||
|
|
508e869080 | ||
|
|
435d22b71d | ||
|
|
f499b7f2af | ||
|
|
267a13b600 | ||
|
|
b530dcd50d | ||
|
|
068c4db3f8 | ||
|
|
565c086a0d | ||
|
|
dd69dd79ab | ||
|
|
fd0b64850f | ||
|
|
61ad1a87f2 | ||
|
|
7c9c8ff510 | ||
|
|
4b32252d2b | ||
|
|
dc45f2eb35 | ||
|
|
c067d303bb | ||
|
|
0c981b4590 | ||
|
|
f8ff7c394c | ||
|
|
3ade17ce9a | ||
|
|
d0ce26c0a3 | ||
|
|
b2c780b697 | ||
|
|
a217bcc14e | ||
|
|
b69eff7866 | ||
|
|
7737a59850 | ||
|
|
1501f7f0b6 | ||
|
|
a15f0ce24b | ||
|
|
f2c07c83c5 | ||
|
|
2358fe22ab | ||
|
|
6833ab2070 | ||
|
|
b09610bc33 | ||
|
|
211dcccbf3 | ||
|
|
b1b619befa | ||
|
|
af779b0988 | ||
|
|
b4d0198d2a |
@@ -0,0 +1,57 @@
|
||||
# Read-only split mirror: a libs/ruby/warp_engine alkönyvtárat kitükrözi a
|
||||
# tools/warp_engine repóba (fejlesztés itt, a monorepóban történik; a tükör
|
||||
# csak publikálásra való). Tag-elt release (warp_engine-v*) esetén a gem a
|
||||
# Forgejo rubygems registry-be is felmegy.
|
||||
#
|
||||
# Szükséges Woodpecker secret: forge_token — Forgejo access token
|
||||
# repository:write (tools/warp_engine) és package:write joggal.
|
||||
|
||||
when:
|
||||
- event: push
|
||||
branch: master
|
||||
path: "libs/ruby/warp_engine/**"
|
||||
- event: manual
|
||||
- event: tag
|
||||
ref: refs/tags/warp_engine-v*
|
||||
|
||||
clone:
|
||||
git:
|
||||
image: woodpeckerci/plugin-git
|
||||
settings:
|
||||
partial: false
|
||||
depth: 0 # a subtree splithez teljes history kell
|
||||
|
||||
steps:
|
||||
split-mirror:
|
||||
image: alpine/git
|
||||
environment:
|
||||
FORGE_TOKEN:
|
||||
from_secret: forge_token
|
||||
commands:
|
||||
- apk add --no-cache git-subtree
|
||||
- git subtree split --prefix=libs/ruby/warp_engine HEAD -b warp-engine-split
|
||||
# a secretbe másolt token végén lehet sortörés — levágjuk
|
||||
- TOKEN="$$(printf '%s' "$${FORGE_TOKEN}" | tr -d '[:space:]')"
|
||||
- git push --force "https://ci:$${TOKEN}@git.teletypegames.org/tools/warp_engine.git" warp-engine-split:master
|
||||
when:
|
||||
- event: push
|
||||
branch: master
|
||||
- event: manual
|
||||
|
||||
publish-gem:
|
||||
image: ruby:3.3-slim
|
||||
environment:
|
||||
FORGE_TOKEN:
|
||||
from_secret: forge_token
|
||||
commands:
|
||||
- cd libs/ruby/warp_engine
|
||||
- gem build warp_engine.gemspec
|
||||
- mkdir -p ~/.gem
|
||||
- TOKEN="$$(printf '%s' "$${FORGE_TOKEN}" | tr -d '[:space:]')"
|
||||
- |
|
||||
printf -- '---\n:https://git.teletypegames.org/api/packages/tools/rubygems: Bearer %s\n' "$${TOKEN}" > ~/.gem/credentials
|
||||
- chmod 600 ~/.gem/credentials
|
||||
- gem push --host https://git.teletypegames.org/api/packages/tools/rubygems warp_engine-*.gem
|
||||
when:
|
||||
- event: tag
|
||||
ref: refs/tags/warp_engine-v*
|
||||
@@ -17,7 +17,7 @@ The API is split in two layers:
|
||||
|
||||
- **WarpEngine** (`libs/ruby/warp_engine`) owns the software catalog: models
|
||||
(softwares, releases, release assets, images, platform links, download stats),
|
||||
the CI-callable `/update` endpoint, the public read-only JSON API
|
||||
the CI-callable `/build/*` publishing endpoints, the public read-only JSON API
|
||||
(`/api/software*`, `/api/builds*`, `/api/image`, `/api/download`, `/file/*`)
|
||||
and the catalog ActiveAdmin resources. See its [README](libs/ruby/warp_engine/README.md).
|
||||
- **The host app** (`apps/api`) owns everything TTG-specific: members, events,
|
||||
|
||||
@@ -4,7 +4,7 @@ class Api::WikiController < ApiController
|
||||
end
|
||||
|
||||
api :GET, "/api/wiki/pages", "List wiki pages filtered by tag"
|
||||
param :tag, String, required: false, desc: "Filter by tag (blog, howto)"
|
||||
param :tag, String, required: false, desc: "Filter by tag (blog, howto, engine)"
|
||||
param :limit, :number, required: false, desc: "Limit number of results"
|
||||
param :body, String, required: false, desc: "Include body content (1 = yes)"
|
||||
returns code: 200, desc: "Wiki pages response" do
|
||||
@@ -20,6 +20,7 @@ class Api::WikiController < ApiController
|
||||
property :locale, String, desc: "Locale code"
|
||||
property :route, String, desc: "URL slug"
|
||||
property :tags, Array, of: String, desc: "Tags"
|
||||
property :repo, String, desc: "Git repository URL (from page metadata, engines)"
|
||||
property :render, String, desc: "Rendered HTML content"
|
||||
property :content, String, desc: "Raw markdown content"
|
||||
end
|
||||
|
||||
@@ -43,4 +43,7 @@ class WikiService
|
||||
rescue StandardError => e
|
||||
{ "tag" => tag, "count" => 0, "pages" => [], "error" => e.message }
|
||||
end
|
||||
|
||||
# Az RSS feedek ezen a néven hívják.
|
||||
alias_method :pages, :index
|
||||
end
|
||||
|
||||
@@ -1,7 +1,36 @@
|
||||
# WarpEngine host-konfiguráció. to_prepare: reload után is újrafut, ezért
|
||||
# értékadás (nem <<), hogy idempotens legyen.
|
||||
# WarpEngine host configuration. to_prepare: re-runs after reloads, hence
|
||||
# assignment (not <<) to stay idempotent.
|
||||
Rails.application.config.to_prepare do
|
||||
WarpEngine.configure do |c|
|
||||
# Owner of the /build/* DB tokens. Switching to :database mode
|
||||
# (c.application_token_source = :database) must wait until CI uses DB
|
||||
# tokens — the flip invalidates UPDATE_SECRET immediately.
|
||||
c.application_token_source = :database
|
||||
c.application_token_owner_class = "AdminUser"
|
||||
|
||||
# Woodpecker configuration extension (/build/config): the served platforms
|
||||
# and their builder images. An image bump is one line here, rolled out to
|
||||
# every repo by the deploy.
|
||||
c.ci_platforms = {
|
||||
"godot" => { builder: "git.teletypegames.org/internal/godot-builder:4.7.1" },
|
||||
"phaser" => { builder: "git.teletypegames.org/internal/phaser-builder:latest" },
|
||||
"love" => { builder: "git.teletypegames.org/internal/love-builder:latest" },
|
||||
"bevy" => { builder: "git.teletypegames.org/internal/bevy-builder:latest" },
|
||||
"c64" => { builder: "git.teletypegames.org/internal/c64-builder:latest" },
|
||||
"ebitengine" => { builder: "git.teletypegames.org/internal/ebitengine-builder:latest" },
|
||||
"tic80" => { builder: "git.teletypegames.org/internal/tic80pro:latest" }
|
||||
}
|
||||
# Explicit URL: request.base_url would yield http:// behind the host nginx
|
||||
# (no X-Forwarded-Proto reaches Rails), and the resulting 301 makes the
|
||||
# pipeline's curl steps silently no-op.
|
||||
c.ci_update_server = "https://teletypegames.org"
|
||||
c.ci_extension_public_key_url = "https://ci.teletypegames.org/api/signature/public-key"
|
||||
|
||||
# Woodpecker CI management (repo sync, secret provisioning, pipeline control)
|
||||
c.woodpecker_url = ENV["WOODPECKER_URL"] # e.g. "https://ci.teletypegames.org"
|
||||
c.woodpecker_api_token = ENV["WOODPECKER_API_TOKEN"] # Woodpecker PAT with admin access
|
||||
c.woodpecker_repo_owner = ENV["WOODPECKER_REPO_OWNER"] # forge org (e.g. "games")
|
||||
|
||||
c.image_owners = [
|
||||
{
|
||||
label: "member",
|
||||
|
||||
@@ -14,7 +14,7 @@ Rails.application.routes.draw do
|
||||
end
|
||||
|
||||
# Utolsó sor: a host route-jai nyernek, a katalógus-útvonalakat
|
||||
# (/api/software*, /api/builds*, /api/image, /api/download, /update, /file/*)
|
||||
# (/api/software*, /api/builds*, /api/image, /api/download, /build/*, /file/*)
|
||||
# az engine adja.
|
||||
mount WarpEngine::Engine => "/"
|
||||
end
|
||||
|
||||
+41
-1
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.1].define(version: 2026_08_04_000002) do
|
||||
ActiveRecord::Schema[8.1].define(version: 2026_08_06_000001) do
|
||||
create_table "admin_users", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "deleted_at", precision: 3
|
||||
@@ -27,6 +27,42 @@ ActiveRecord::Schema[8.1].define(version: 2026_08_04_000002) do
|
||||
t.index ["reset_password_token"], name: "index_admin_users_on_reset_password_token", unique: true
|
||||
end
|
||||
|
||||
create_table "application_tokens", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.datetime "created_at", precision: 3
|
||||
t.datetime "deleted_at", precision: 3
|
||||
t.datetime "expires_at", precision: 3
|
||||
t.datetime "last_used_at", precision: 3
|
||||
t.string "name", limit: 128, null: false
|
||||
t.bigint "owner_id", null: false, unsigned: true
|
||||
t.string "owner_type", limit: 128, null: false
|
||||
t.json "scopes"
|
||||
t.string "token_digest", limit: 64, null: false
|
||||
t.string "token_prefix", limit: 12, null: false
|
||||
t.boolean "unrestricted", default: false, null: false
|
||||
t.datetime "updated_at", precision: 3
|
||||
t.index ["deleted_at"], name: "idx_application_tokens_deleted_at"
|
||||
t.index ["owner_type", "owner_id"], name: "idx_application_tokens_owner"
|
||||
t.index ["token_digest"], name: "idx_application_tokens_token_digest", unique: true
|
||||
end
|
||||
|
||||
create_table "ci_repositories", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.boolean "active", default: true, null: false
|
||||
t.datetime "created_at", precision: 3
|
||||
t.datetime "deleted_at", precision: 3
|
||||
t.datetime "last_pipeline_at", precision: 3
|
||||
t.string "last_pipeline_status", limit: 32
|
||||
t.string "platform", limit: 32, null: false
|
||||
t.string "repo_name", limit: 128, null: false
|
||||
t.string "repo_owner", limit: 128, null: false
|
||||
t.bigint "software_id", unsigned: true
|
||||
t.datetime "updated_at", precision: 3
|
||||
t.bigint "woodpecker_repo_id", null: false, unsigned: true
|
||||
t.index ["deleted_at"], name: "idx_ci_repos_deleted"
|
||||
t.index ["repo_owner", "repo_name"], name: "idx_ci_repos_owner_name", unique: true
|
||||
t.index ["software_id"], name: "idx_ci_repos_software"
|
||||
t.index ["woodpecker_repo_id"], name: "idx_ci_repos_wp_id", unique: true
|
||||
end
|
||||
|
||||
create_table "downloads", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.datetime "created_at", precision: 3
|
||||
t.datetime "deleted_at", precision: 3
|
||||
@@ -232,6 +268,8 @@ ActiveRecord::Schema[8.1].define(version: 2026_08_04_000002) do
|
||||
t.boolean "highlighted", default: false
|
||||
t.string "license", limit: 128
|
||||
t.string "name", limit: 128
|
||||
t.bigint "owner_id", unsigned: true
|
||||
t.string "owner_type", limit: 128
|
||||
t.string "platform", limit: 128
|
||||
t.string "site"
|
||||
t.string "status", limit: 20, default: "development"
|
||||
@@ -240,9 +278,11 @@ ActiveRecord::Schema[8.1].define(version: 2026_08_04_000002) do
|
||||
t.datetime "updated_at", precision: 3
|
||||
t.index ["deleted_at"], name: "idx_softwares_deleted_at"
|
||||
t.index ["name"], name: "idx_softwares_name", unique: true
|
||||
t.index ["owner_type", "owner_id"], name: "idx_softwares_owner"
|
||||
end
|
||||
|
||||
add_foreign_key "admin_users", "members"
|
||||
add_foreign_key "ci_repositories", "softwares", name: "fk_ci_repos_software", on_delete: :nullify
|
||||
add_foreign_key "downloads", "releases", name: "fk_downloads_release", on_delete: :nullify
|
||||
add_foreign_key "external_links", "softwares", name: "fk_softwares_external_links", on_delete: :cascade
|
||||
add_foreign_key "members", "images"
|
||||
|
||||
@@ -15,6 +15,7 @@ interface RawWikiPage {
|
||||
updatedAt: string
|
||||
createdAt: string
|
||||
locale: string
|
||||
repo?: string | null
|
||||
}
|
||||
|
||||
async function fetchPages(
|
||||
@@ -67,6 +68,21 @@ const getBlogPage = async (slug: string): Promise<WikiPageContent | null> => {
|
||||
}
|
||||
}
|
||||
|
||||
const listEnginePages = async (): Promise<WikiPageWithContent[]> => {
|
||||
const pages = await fetchPages('engine', { body: true })
|
||||
return pages.map((p): WikiPageWithContent => ({
|
||||
id: p.id,
|
||||
path: p.path,
|
||||
title: p.title || p.path,
|
||||
description: p.description ?? '',
|
||||
content: p.content ?? '',
|
||||
updatedAt: p.updatedAt,
|
||||
createdAt: p.createdAt,
|
||||
locale: p.locale,
|
||||
repo: p.repo ?? null,
|
||||
}))
|
||||
}
|
||||
|
||||
const listHowtoPages = async (): Promise<WikiPage[]> => {
|
||||
const pages = await fetchPages('howto', { limit: 30 })
|
||||
return pages.map((p): WikiPage => ({
|
||||
@@ -81,4 +97,4 @@ const listHowtoPages = async (): Promise<WikiPage[]> => {
|
||||
}
|
||||
|
||||
export { WIKI_BASE }
|
||||
export default { listBlogPages, getBlogPage, listHowtoPages }
|
||||
export default { listBlogPages, getBlogPage, listHowtoPages, listEnginePages }
|
||||
|
||||
@@ -4,6 +4,7 @@ export default {
|
||||
catalog: 'Catalog',
|
||||
blog: 'Blog',
|
||||
howtos: 'How-tos',
|
||||
engines: 'Engines',
|
||||
code: 'Code',
|
||||
team: 'Team',
|
||||
contact: 'Contact us',
|
||||
@@ -168,6 +169,17 @@ export default {
|
||||
title: 'Our Team',
|
||||
subtitle: 'Meet the brilliant minds behind Teletype Games.',
|
||||
},
|
||||
engines: {
|
||||
badge: 'In-house Tech',
|
||||
titleLead: 'Our',
|
||||
titleAccent: 'Engines',
|
||||
subtitle: 'The engines and frameworks we build, maintain and ship our games and services on.',
|
||||
errorTitle: 'Failed to connect to Wiki',
|
||||
noPagesTitle: 'No engines found',
|
||||
noPagesDesc: "It seems like there aren't any engine pages available on the wiki at the moment.",
|
||||
explore: 'Explore',
|
||||
openWiki: 'Open in Wiki',
|
||||
},
|
||||
howtos: {
|
||||
badge: 'Knowledge Base',
|
||||
title: 'Tech HowTo Center',
|
||||
|
||||
@@ -4,6 +4,7 @@ export default {
|
||||
catalog: 'Katalógus',
|
||||
blog: 'Blog',
|
||||
howtos: 'Hogyan csináld',
|
||||
engines: 'Motorok',
|
||||
code: 'Kód',
|
||||
team: 'Csapat',
|
||||
contact: 'Kapcsolat',
|
||||
@@ -168,6 +169,17 @@ export default {
|
||||
title: 'Csapatunk',
|
||||
subtitle: 'Ismerd meg a Teletype Games mögött álló zseniális elméket.',
|
||||
},
|
||||
engines: {
|
||||
badge: 'Saját technológia',
|
||||
titleLead: 'Saját',
|
||||
titleAccent: 'motorjaink',
|
||||
subtitle: 'Az általunk épített és karbantartott motorok és keretrendszerek, amelyekre a játékaink és szolgáltatásaink épülnek.',
|
||||
errorTitle: 'Nem sikerült csatlakozni a Wikihez',
|
||||
noPagesTitle: 'Nem találhatók motorok',
|
||||
noPagesDesc: 'Úgy tűnik, jelenleg nincsenek motoroldalak a wikin.',
|
||||
explore: 'Felfedezés',
|
||||
openWiki: 'Megnyitás a Wikiben',
|
||||
},
|
||||
howtos: {
|
||||
badge: 'Tudásbázis',
|
||||
title: 'Tech HowTo Központ',
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<RouterLink to="/catalog" class="nav-link"><i class="fa-solid fa-gamepad nav-icon"></i>{{ t('nav.catalog') }}</RouterLink>
|
||||
<RouterLink to="/blog" class="nav-link"><i class="fa-solid fa-newspaper nav-icon"></i>{{ t('nav.blog') }}</RouterLink>
|
||||
<RouterLink to="/howtos" class="nav-link"><i class="fa-solid fa-lightbulb nav-icon"></i>{{ t('nav.howtos') }}</RouterLink>
|
||||
<RouterLink to="/engines" class="nav-link"><i class="fa-solid fa-cubes nav-icon"></i>{{ t('nav.engines') }}</RouterLink>
|
||||
<RouterLink to="/code" class="nav-link"><i class="fa-solid fa-code nav-icon"></i>{{ t('nav.code') }}</RouterLink>
|
||||
<RouterLink to="/team" class="nav-link"><i class="fa-solid fa-users nav-icon"></i>{{ t('nav.team') }}</RouterLink>
|
||||
<RouterLink to="/contact" class="nav-link"><i class="fa-solid fa-envelope nav-icon"></i>{{ t('nav.contact') }}</RouterLink>
|
||||
@@ -33,6 +34,7 @@
|
||||
<RouterLink to="/catalog" class="nav-mobile-link"><i class="fa-solid fa-gamepad nav-icon"></i>{{ t('nav.catalog') }}</RouterLink>
|
||||
<RouterLink to="/blog" class="nav-mobile-link"><i class="fa-solid fa-newspaper nav-icon"></i>{{ t('nav.blog') }}</RouterLink>
|
||||
<RouterLink to="/howtos" class="nav-mobile-link"><i class="fa-solid fa-lightbulb nav-icon"></i>{{ t('nav.howtos') }}</RouterLink>
|
||||
<RouterLink to="/engines" class="nav-mobile-link"><i class="fa-solid fa-cubes nav-icon"></i>{{ t('nav.engines') }}</RouterLink>
|
||||
<RouterLink to="/code" class="nav-mobile-link"><i class="fa-solid fa-code nav-icon"></i>{{ t('nav.code') }}</RouterLink>
|
||||
<RouterLink to="/team" class="nav-mobile-link"><i class="fa-solid fa-users nav-icon"></i>{{ t('nav.team') }}</RouterLink>
|
||||
<RouterLink to="/contact" class="nav-mobile-link"><i class="fa-solid fa-envelope nav-icon"></i>{{ t('nav.contact') }}</RouterLink>
|
||||
@@ -114,13 +116,13 @@ function switchLocale(lang: string) {
|
||||
@apply text-xl font-bold;
|
||||
}
|
||||
.nav-desktop {
|
||||
@apply hidden md:flex items-center space-x-4;
|
||||
@apply hidden lg:flex items-center space-x-1;
|
||||
}
|
||||
.nav-link {
|
||||
@apply p-2 hover:text-purple-300 transition-colors duration-200;
|
||||
@apply px-2.5 py-2 text-sm hover:text-purple-300 transition-colors duration-200 whitespace-nowrap;
|
||||
}
|
||||
.nav-link-admin {
|
||||
@apply p-2 hover:text-yellow-300 transition-colors duration-200;
|
||||
@apply px-2.5 py-2 text-sm hover:text-yellow-300 transition-colors duration-200 whitespace-nowrap;
|
||||
}
|
||||
.locale-switcher {
|
||||
@apply flex items-center gap-1 text-sm font-bold ml-2 border-l border-gray-600 pl-4;
|
||||
@@ -131,14 +133,19 @@ function switchLocale(lang: string) {
|
||||
.locale-separator {
|
||||
@apply text-gray-600;
|
||||
}
|
||||
/* Az ikonok viszik a legtöbb helyet a sok menüpont mellett — desktopon csak
|
||||
extra széles kijelzőn jelennek meg, a mobil menüben mindig. */
|
||||
.nav-desktop .nav-icon {
|
||||
@apply hidden xl:inline-block;
|
||||
}
|
||||
.nav-icon {
|
||||
@apply mr-1.5 text-xs opacity-70;
|
||||
}
|
||||
.hamburger-btn {
|
||||
@apply md:hidden p-2 focus:outline-none focus:ring-2 focus:ring-purple-500 rounded-md;
|
||||
@apply lg:hidden p-2 focus:outline-none focus:ring-2 focus:ring-purple-500 rounded-md;
|
||||
}
|
||||
.nav-mobile {
|
||||
@apply md:hidden absolute top-full left-0 w-full bg-gray-800 flex flex-col items-center py-4 space-y-2 z-50;
|
||||
@apply lg:hidden absolute top-full left-0 w-full bg-gray-800 flex flex-col items-center py-4 space-y-2 z-50;
|
||||
}
|
||||
.nav-mobile-link {
|
||||
@apply block p-2 w-full text-center hover:bg-gray-700 transition-colors duration-200;
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface WikiPage {
|
||||
updatedAt: string
|
||||
createdAt: string
|
||||
locale: string
|
||||
repo?: string | null
|
||||
}
|
||||
|
||||
export interface WikiPageWithContent extends WikiPage {
|
||||
|
||||
@@ -96,7 +96,7 @@
|
||||
<span class="latest-release-badge">{{ t('catalogShow.latestStable') }}</span>
|
||||
</div>
|
||||
<h2 class="latest-release-version">{{ latestStable.version }}</h2>
|
||||
<p class="latest-release-date"><i class="fa-solid fa-calendar mr-1"></i>{{ t('catalogShow.released') }} {{ formatDateTime(latestStable.UpdatedAt) }}</p>
|
||||
<p class="latest-release-date"><i class="fa-solid fa-calendar mr-1"></i>{{ t('catalogShow.released') }} {{ formatDateTime(latestStable.updatedAt) }}</p>
|
||||
</div>
|
||||
|
||||
<a v-if="latestStable.htmlFolderPath" :href="latestStable.htmlFolderPath" target="_blank" class="latest-release-play-btn"><i class="fa-solid fa-play mr-2"></i>{{ t('catalogShow.playNow') }}</a>
|
||||
@@ -155,7 +155,7 @@
|
||||
</div>
|
||||
<a v-if="release.htmlFolderPath" :href="release.htmlFolderPath" target="_blank" class="release-play-link">{{ t('catalogShow.play') }}</a>
|
||||
</td>
|
||||
<td class="release-date-col">{{ formatDateTime(release.UpdatedAt) }}</td>
|
||||
<td class="release-date-col">{{ formatDateTime(release.updatedAt) }}</td>
|
||||
</tr>
|
||||
<tr v-if="tableAssets(release).length">
|
||||
<td colspan="2" class="px-8 pb-4 pt-0">
|
||||
@@ -185,7 +185,7 @@
|
||||
<div class="dev-release-version">{{ release.version }}</div>
|
||||
<a v-if="release.htmlFolderPath" :href="release.htmlFolderPath" target="_blank" class="release-play-link">{{ t('catalogShow.play') }}</a>
|
||||
</td>
|
||||
<td class="release-date-col">{{ formatDateTime(release.UpdatedAt) }}</td>
|
||||
<td class="release-date-col">{{ formatDateTime(release.updatedAt) }}</td>
|
||||
</tr>
|
||||
<tr v-if="tableAssets(release).length" class="bg-yellow-50/10">
|
||||
<td colspan="2" class="px-8 pb-4 pt-0">
|
||||
@@ -218,7 +218,7 @@
|
||||
<div class="mobile-release-header">
|
||||
<div>
|
||||
<div class="mobile-release-version">{{ release.version }}</div>
|
||||
<div class="mobile-release-date">{{ formatDateTime(release.UpdatedAt) }}</div>
|
||||
<div class="mobile-release-date">{{ formatDateTime(release.updatedAt) }}</div>
|
||||
</div>
|
||||
<span v-if="release.version.startsWith('dev-')" class="dev-badge">Dev</span>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
<template>
|
||||
<div class="engines-container">
|
||||
<header class="hero-section-slate">
|
||||
<div class="engines-header-decor">
|
||||
<div class="hero-decor-blob -top-24 -left-24 h-96 w-96 bg-emerald-600"></div>
|
||||
<div class="hero-decor-blob -bottom-24 -right-24 h-96 w-96 bg-teal-600"></div>
|
||||
</div>
|
||||
|
||||
<div class="hero-container">
|
||||
<div class="hero-badge">{{ t('engines.badge') }}</div>
|
||||
<h1 class="hero-title">
|
||||
{{ t('engines.titleLead') }} <span class="text-transparent bg-clip-text bg-gradient-to-r from-emerald-400 to-teal-300">{{ t('engines.titleAccent') }}</span>
|
||||
</h1>
|
||||
<p class="hero-subtitle mb-10">{{ t('engines.subtitle') }}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="engines-main">
|
||||
<div v-if="error" class="error-banner" role="alert">
|
||||
<i class="fa-solid fa-triangle-exclamation text-2xl text-yellow-500"></i>
|
||||
<div>
|
||||
<h3 class="error-banner-title">{{ t('engines.errorTitle') }}</h3>
|
||||
<p class="error-banner-desc">{{ error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SkeletonCard v-else-if="loading" :count="3" />
|
||||
|
||||
<div v-else-if="enginePages.length === 0" class="empty-state">
|
||||
<div class="empty-state-icon"><i class="fa-solid fa-inbox"></i></div>
|
||||
<h2 class="empty-state-title">{{ t('engines.noPagesTitle') }}</h2>
|
||||
<p class="empty-state-desc">{{ t('engines.noPagesDesc') }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="engine-list">
|
||||
<article v-for="(page, index) in enginePages" :key="page.id" class="engine-card group">
|
||||
<div class="engine-card-index">{{ String(index + 1).padStart(2, '0') }}</div>
|
||||
|
||||
<div class="engine-card-body">
|
||||
<h2 class="engine-card-title">
|
||||
<a :href="exploreUrl(page)" target="_blank" rel="noopener">{{ page.title }}</a>
|
||||
</h2>
|
||||
|
||||
<p v-if="page.description" class="engine-card-desc">{{ page.description }}</p>
|
||||
<p v-if="page.content" class="engine-card-preview">{{ getCleanPreview(page.content) }}</p>
|
||||
|
||||
<div class="engine-card-actions">
|
||||
<a :href="exploreUrl(page)" target="_blank" rel="noopener" class="engine-explore-btn">
|
||||
<i class="fa-solid fa-code-branch text-sm"></i>
|
||||
{{ t('engines.explore') }}
|
||||
<i class="fa-solid fa-arrow-right text-sm"></i>
|
||||
</a>
|
||||
<a :href="`${WIKI_BASE}/${page.path}`" target="_blank" rel="noopener" class="engine-wiki-link">
|
||||
<i class="fa-solid fa-book mr-1"></i>{{ t('engines.openWiki') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { WIKI_BASE } from '../../api/wiki.api'
|
||||
import { useEnginesStore } from '../../stores/engines.store'
|
||||
import type { WikiPageWithContent } from '../../lib/interfaces/wiki.interface'
|
||||
import SkeletonCard from '../../components/SkeletonCard.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const store = useEnginesStore()
|
||||
const { pages: enginePages, loading, error } = storeToRefs(store)
|
||||
const { getCleanPreview } = store
|
||||
|
||||
// Explore points at the engine's git repository (from wiki metadata);
|
||||
// pages without one fall back to their wiki page.
|
||||
const exploreUrl = (page: WikiPageWithContent): string =>
|
||||
page.repo || `${WIKI_BASE}/${page.path}`
|
||||
|
||||
onMounted(() => store.fetch())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.engines-container {
|
||||
@apply bg-slate-950 min-h-screen pb-24;
|
||||
}
|
||||
.engines-header-decor {
|
||||
@apply absolute inset-0 opacity-30;
|
||||
}
|
||||
.engines-main {
|
||||
@apply max-w-5xl mx-auto px-4 md:px-8 mt-16 relative z-10;
|
||||
}
|
||||
.banner-base {
|
||||
@apply max-w-7xl mx-auto border-l-4 p-6 rounded-r-xl shadow-lg mb-12 flex items-start gap-4;
|
||||
}
|
||||
.error-banner { @apply banner-base bg-red-50 border-red-500; }
|
||||
.error-banner-title { @apply text-red-800 font-bold text-lg; }
|
||||
.error-banner-desc { @apply text-red-700 mt-1; }
|
||||
.empty-state {
|
||||
@apply max-w-7xl mx-auto bg-slate-900 rounded-2xl shadow-xl p-12 text-center border border-slate-800;
|
||||
}
|
||||
.empty-state-icon { @apply text-6xl mb-4; }
|
||||
.empty-state-title { @apply text-2xl font-bold text-white mb-2; }
|
||||
.empty-state-desc { @apply text-slate-400 max-w-md mx-auto; }
|
||||
|
||||
/* Few engines, so every one of them gets a full-width, poster-like card. */
|
||||
.engine-list {
|
||||
@apply flex flex-col gap-10;
|
||||
}
|
||||
.engine-card {
|
||||
@apply relative overflow-hidden bg-slate-900 rounded-3xl border border-slate-800 border-l-4 border-l-emerald-500 shadow-2xl transition-all hover:border-l-teal-300 hover:-translate-y-1;
|
||||
}
|
||||
.engine-card-index {
|
||||
@apply absolute -top-6 right-4 text-[9rem] leading-none font-black text-slate-800/60 select-none pointer-events-none transition-colors;
|
||||
}
|
||||
.engine-card:hover .engine-card-index {
|
||||
@apply text-slate-800;
|
||||
}
|
||||
.engine-card-body {
|
||||
@apply relative p-8 md:p-12;
|
||||
}
|
||||
.engine-card-title {
|
||||
@apply text-3xl md:text-5xl font-black text-white mb-4 leading-tight;
|
||||
}
|
||||
.engine-card-title a {
|
||||
@apply hover:text-emerald-400 transition-colors;
|
||||
}
|
||||
.engine-card-desc {
|
||||
@apply text-lg md:text-xl text-emerald-100/90 font-semibold mb-3 leading-relaxed max-w-3xl;
|
||||
}
|
||||
.engine-card-preview {
|
||||
@apply text-base text-slate-400 leading-relaxed mb-8 max-w-3xl;
|
||||
}
|
||||
.engine-card-actions {
|
||||
@apply flex flex-wrap items-center gap-6;
|
||||
}
|
||||
.engine-explore-btn {
|
||||
@apply inline-flex items-center gap-2 bg-emerald-500 text-slate-950 px-6 py-3 rounded-xl font-bold hover:bg-emerald-400 transition-all shadow-lg shadow-emerald-900/40 group-hover:translate-x-1;
|
||||
}
|
||||
.engine-wiki-link {
|
||||
@apply text-slate-400 font-semibold hover:text-white transition-colors;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
export const enginesRouter: RouteRecordRaw[] = [
|
||||
{ path: '/engines', name: 'enginesIndex', component: () => import('../page/engines/EnginesIndexPage.vue') },
|
||||
]
|
||||
@@ -4,6 +4,7 @@ import { blogRouter } from './blog.router'
|
||||
import { catalogRouter } from './catalog.router'
|
||||
import { codeRouter } from './code.router'
|
||||
import { contactRouter } from './contact.router'
|
||||
import { enginesRouter } from './engines.router'
|
||||
import { howtosRouter } from './howtos.router'
|
||||
import { teamRouter } from './team.router'
|
||||
import { buildsRouter } from './builds.router'
|
||||
@@ -16,6 +17,7 @@ export const router = createRouter({
|
||||
...catalogRouter,
|
||||
...codeRouter,
|
||||
...contactRouter,
|
||||
...enginesRouter,
|
||||
...howtosRouter,
|
||||
...teamRouter,
|
||||
...buildsRouter,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import wikiApi from '../api/wiki.api'
|
||||
import { useLoadable } from '../composables/useLoadable'
|
||||
import type { WikiPageWithContent } from '../lib/interfaces/wiki.interface'
|
||||
|
||||
export const useEnginesStore = defineStore('engines', () => {
|
||||
const pages = ref<WikiPageWithContent[]>([])
|
||||
const { loading, error, withCache, invalidate } = useLoadable()
|
||||
|
||||
async function fetch() {
|
||||
await withCache(async () => {
|
||||
pages.value = await wikiApi.listEnginePages()
|
||||
})
|
||||
}
|
||||
|
||||
function getCleanPreview(content: string): string {
|
||||
if (!content) return ''
|
||||
return content.replace(/[#*`_[\]()>|-]/g, '').replace(/\s+/g, ' ').trim().slice(0, 260) + '...'
|
||||
}
|
||||
|
||||
return { pages, loading, error, fetch, getCleanPreview, invalidate }
|
||||
})
|
||||
+8
-20
@@ -54,6 +54,10 @@ services:
|
||||
WOODPECKER_GITEA_SECRET: ${GITEA_CLIENT_SECRET}
|
||||
WOODPECKER_SERVER_ADDR: ":8000"
|
||||
WOODPECKER_AGENT_SECRET: ${WOODPECKER_AGENT_SECRET}
|
||||
# Global configuration extension: every pipeline start asks the update
|
||||
# server for the config; non-marker repos fall back to their own YAML (204).
|
||||
WOODPECKER_CONFIG_EXTENSION_ENDPOINT: "https://${WEBAPP_DOMAIN}/build/config"
|
||||
WOODPECKER_ADMIN: mr.zero
|
||||
volumes:
|
||||
- ./data/woodpecker:/var/lib/woodpecker
|
||||
labels:
|
||||
@@ -162,6 +166,9 @@ services:
|
||||
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@teletype.hu}
|
||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-password123}
|
||||
- WIKI_GRAV_URL=${WIKI_GRAV_URL:-https://wiki.teletypegames.org}
|
||||
- WOODPECKER_URL=${WOODPECKER_URL:-http://woodpecker-server:8000}
|
||||
- WOODPECKER_API_TOKEN=${WOODPECKER_API_TOKEN:-}
|
||||
- WOODPECKER_REPO_OWNER=${WOODPECKER_REPO_OWNER:-}
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
@@ -178,7 +185,7 @@ services:
|
||||
- "traefik.http.middlewares.api-cors.headers.accesscontrolallowheaders=Content-Type,Authorization,Accept,X-Requested-With"
|
||||
- "traefik.http.middlewares.api-cors.headers.accesscontrolmaxage=3600"
|
||||
- "traefik.http.middlewares.api-cors.headers.addvaryheader=true"
|
||||
- "traefik.http.routers.api.rule=Host(`${WEBAPP_DOMAIN}`) && (PathPrefix(`/api`) || PathPrefix(`/file`) || PathPrefix(`/update`) || PathPrefix(`/admin`))"
|
||||
- "traefik.http.routers.api.rule=Host(`${WEBAPP_DOMAIN}`) && (PathPrefix(`/api`) || PathPrefix(`/file`) || PathPrefix(`/build`) || PathPrefix(`/admin`))"
|
||||
- "traefik.http.routers.api.entrypoints=web"
|
||||
- "traefik.http.routers.api.priority=10"
|
||||
- "traefik.http.routers.api.middlewares=api-cors"
|
||||
@@ -192,25 +199,6 @@ services:
|
||||
- proxy
|
||||
- interstack
|
||||
|
||||
droparea:
|
||||
image: linuxserver/openssh-server
|
||||
container_name: droparea
|
||||
environment:
|
||||
PUID: 1
|
||||
PGID: 1
|
||||
TZ: Europe/Budapest
|
||||
SUDO_ACCESS: "false"
|
||||
PASSWORD_ACCESS: "true"
|
||||
USER_NAME: drop
|
||||
USER_PASSWORD: ${DROP_PASSWORD}
|
||||
volumes:
|
||||
- ./data/softwares:/home/drop
|
||||
ports:
|
||||
- "${DROPAREA_SSH_PORT}:2222"
|
||||
networks:
|
||||
- proxy
|
||||
- interstack
|
||||
|
||||
volumes:
|
||||
gitea:
|
||||
woodpecker:
|
||||
|
||||
@@ -13,7 +13,6 @@ PHPMYADMIN_DOMAIN=db.teletype.hu
|
||||
|
||||
# Ports
|
||||
GITEA_SSH_PORT=2222
|
||||
DROPAREA_SSH_PORT=2223
|
||||
TRAEFIK_WEB_PORT=9100
|
||||
TRAEFIK_API_PORT=9101
|
||||
|
||||
@@ -23,7 +22,6 @@ WOODPECKER_AGENT_SECRET=
|
||||
MYSQL_ROOT_PASSWORD=
|
||||
DB_PASSWORD=
|
||||
UPDATE_SECRET=
|
||||
DROP_PASSWORD=
|
||||
WEBAPP_WIKIJS_TOKEN=
|
||||
DISCORD_INVITE_LINK=
|
||||
|
||||
|
||||
@@ -8,5 +8,6 @@ group :development, :test do
|
||||
gem "rspec-rails", "~> 7.0"
|
||||
gem "factory_bot_rails"
|
||||
gem "shoulda-matchers", "~> 6.0"
|
||||
gem "webmock", "~> 3.0"
|
||||
gem "debug", platforms: %i[mri windows]
|
||||
end
|
||||
|
||||
+293
-46
@@ -1,89 +1,336 @@
|
||||
# WarpEngine
|
||||
|
||||
Mountable Rails engine: a retro software catalog with a CI-pipeline-callable
|
||||
release updater, a public read-only JSON API, and ActiveAdmin resources that
|
||||
load into the host application's admin.
|
||||
A mountable Rails engine that turns any Rails application into a retro
|
||||
software catalog: catalog models, a CI-pipeline-callable release updater, a
|
||||
public read-only JSON API, and optional ActiveAdmin resources that plug into
|
||||
your app's existing admin.
|
||||
|
||||
## What it provides
|
||||
Repository: `https://git.teletypegames.org/tools/warp_engine`
|
||||
|
||||
- **Models**: `Software`, `Release`, `ReleaseAsset`, `ExternalLink`,
|
||||
`PlatformLink`, `Image`, `SoftwareImage`, `Download` (all under
|
||||
`WarpEngine::`, with unprefixed table names)
|
||||
- **Updater**: `GET /update?platform=&name=&version=` (auth via the
|
||||
`X-Update-Secret` header or `?secret=`) — CI copies build artifacts under
|
||||
`file_container_path` using the `<name>-<version>*` naming convention, then
|
||||
calls the endpoint; the updater extracts archives, parses metadata, and
|
||||
upserts the Software/Release/ReleaseAsset/ExternalLink records.
|
||||
Supported platforms: tic80, ebitengine, love, c64, godot, bevy, phaser.
|
||||
- **Public API**: `/api/software`, `/api/software/highlighted`, `/api/builds`,
|
||||
`/api/softwares/:name/builds`, `/api/image/:id`, `/api/download?path=`,
|
||||
`/file/*path`
|
||||
- **Admin**: ActiveAdmin resource files (softwares with a 3-level nested form,
|
||||
releases, external links, platform links, images with orphan management, a
|
||||
Files file-manager page with picker mode, download stats) — loaded into the
|
||||
host's single ActiveAdmin instance.
|
||||
## Features
|
||||
|
||||
- **Catalog domain**: `Software`, `Release`, `ReleaseAsset`, `ExternalLink`,
|
||||
`PlatformLink`, `Image`, `SoftwareImage`, `Download` models with soft-delete
|
||||
semantics and download statistics.
|
||||
- **CI-callable updater**: your build pipeline uploads artifacts over HTTP
|
||||
and calls one endpoint — WarpEngine extracts archives, parses metadata
|
||||
and upserts the catalog records. Supported platforms out of the
|
||||
box: TIC-80, Ebitengine, LÖVE, C64, Godot, Bevy, Phaser. Authenticated by
|
||||
a shared secret or by per-owner database tokens with expiry and scopes
|
||||
(`ApplicationToken`, managed in the admin).
|
||||
- **Public JSON API**: catalog listing, highlighted title, per-platform build
|
||||
matrix, image serving, download tracking, and a static file server for
|
||||
web-playable builds.
|
||||
- **Admin (optional)**: if the host runs ActiveAdmin, WarpEngine contributes
|
||||
ready-made resources — a catalog editor with nested release/asset forms, an
|
||||
image library with orphan cleanup, a file manager with a picker mode, and
|
||||
download statistics. Without ActiveAdmin the engine runs headless
|
||||
(API + updater only).
|
||||
|
||||
## Requirements
|
||||
|
||||
- Rails >= 8.0
|
||||
- A relational database (developed and tested against MySQL 8)
|
||||
- Optional: ActiveAdmin + Devise in the host app for the admin UI
|
||||
|
||||
## Example stack (docker compose)
|
||||
|
||||
`examples/compose` boots everything the engine's workflow assumes, end to
|
||||
end: the catalog app itself and — behind a compose profile — a Gitea forge
|
||||
with Woodpecker CI, so you can watch a pipeline publish a release into the
|
||||
catalog.
|
||||
|
||||
| Service | Role | Where |
|
||||
| --- | --- | --- |
|
||||
| `app` | Minimal Rails host with the engine mounted as a path gem (headless: API + updater) | `http://localhost:8080` |
|
||||
| `mysql` | Catalog database | internal |
|
||||
| `gitea` | Git forge (profile `ci`) | `http://gitea:3000` |
|
||||
| `woodpecker` + agent | CI wired to gitea (profile `ci`) | `http://woodpecker:8000` |
|
||||
|
||||
### Quickstart — catalog only
|
||||
|
||||
```sh
|
||||
cd examples/compose
|
||||
cp .env.example .env # defaults work for a throwaway local demo
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
The first boot takes a few minutes: the app container bundles, runs
|
||||
`rails g warp_engine:install` and `rails db:prepare`, then serves on
|
||||
`http://localhost:8080`:
|
||||
|
||||
- `http://localhost:8080/api/software` — the (empty) catalog
|
||||
- `http://localhost:8080/api/builds` — the platform build matrix
|
||||
- `http://localhost:8080/api/docs` — apipie API docs
|
||||
|
||||
### Publish a release by hand
|
||||
|
||||
The updater contract is nothing but a handful of HTTP calls, so you can play
|
||||
the role of the CI pipeline yourself:
|
||||
|
||||
```sh
|
||||
# 1. Fake a build: metadata, a web build and a windows artifact, named by
|
||||
# convention (the love platform requires the .html.zip web build)
|
||||
cat > demo-0.1.0.metadata.json <<'JSON'
|
||||
{ "name": "demo", "title": "Demo Game", "author": "You", "desc": "Hello", "license": "MIT" }
|
||||
JSON
|
||||
echo '<h1>demo</h1>' > index.html && zip demo-0.1.0.html.zip index.html
|
||||
echo hello > game.bin && zip demo-0.1.0-win-x64.zip game.bin
|
||||
|
||||
# 2. Upload them (one request per file)
|
||||
for f in demo-0.1.0.*; do
|
||||
curl -fs -H "X-Update-Secret: example-update-secret" \
|
||||
-F "file=@$f" "http://localhost:8080/build/upload?name=demo&version=0.1.0"
|
||||
done
|
||||
|
||||
# 3. Publish the release
|
||||
curl -X POST -H "X-Update-Secret: example-update-secret" \
|
||||
"http://localhost:8080/build/publish?platform=love&name=demo&version=0.1.0"
|
||||
```
|
||||
|
||||
`GET /api/software` now lists *Demo Game* with `html` and `win_x64` assets,
|
||||
`http://localhost:8080/file/demo-0.1.0/index.html` serves the extracted web
|
||||
build, and `GET /api/download?path=demo-0.1.0-win-x64.zip` serves the
|
||||
artifact while logging a download record.
|
||||
|
||||
### Full loop — forge + CI (profile `ci`)
|
||||
|
||||
gitea and woodpecker address each other by service name, so let your browser
|
||||
resolve those names too:
|
||||
|
||||
```sh
|
||||
echo "127.0.0.1 gitea woodpecker" | sudo tee -a /etc/hosts
|
||||
```
|
||||
|
||||
1. `docker compose --profile ci up -d gitea`, open `http://gitea:3000`,
|
||||
finish the install wizard (SQLite is fine) and create your admin user.
|
||||
2. In gitea: *Settings → Applications → Manage OAuth2 Applications*, create
|
||||
an app with redirect URI `http://woodpecker:8000/authorize`; copy the
|
||||
client id/secret into `WOODPECKER_GITEA_CLIENT` / `WOODPECKER_GITEA_SECRET`
|
||||
in `.env`.
|
||||
3. `docker compose --profile ci up -d` — then log in at
|
||||
`http://woodpecker:8000` (OAuth via gitea) and enable your repository.
|
||||
|
||||
A pipeline publishes a release exactly like the by-hand steps above — build,
|
||||
upload, publish:
|
||||
|
||||
```yaml
|
||||
# .woodpecker.yaml in a game repo hosted on the example gitea
|
||||
steps:
|
||||
publish:
|
||||
image: alpine
|
||||
environment:
|
||||
UPDATE_SECRET:
|
||||
from_secret: update_secret
|
||||
commands:
|
||||
- apk add --no-cache curl zip
|
||||
- # ... build your game, produce mygame-1.0.0.metadata.json + artifacts ...
|
||||
- for f in mygame-1.0.0.*; do curl -fs -H "X-Update-Secret: $UPDATE_SECRET" -F "file=@$f" "http://app:3000/build/upload?name=mygame&version=1.0.0"; done
|
||||
- curl -fs -X POST -H "X-Update-Secret: $UPDATE_SECRET" "http://app:3000/build/publish?platform=love&name=mygame&version=1.0.0"
|
||||
```
|
||||
|
||||
(The agent attaches pipeline containers to the stack network, so `app`
|
||||
resolves. For real projects, the per-platform
|
||||
[`tools/*-tools`](https://git.teletypegames.org) repos ship ready-made
|
||||
Makefile + pipeline templates implementing this contract.)
|
||||
|
||||
Tear the stack down with `docker compose --profile ci down -v`.
|
||||
|
||||
## Installation
|
||||
|
||||
From the git repository:
|
||||
|
||||
```ruby
|
||||
# Gemfile
|
||||
gem "warp_engine", path: "../../libs/ruby/warp_engine"
|
||||
gem "warp_engine", git: "https://git.teletypegames.org/tools/warp_engine.git"
|
||||
```
|
||||
|
||||
Or from the Forgejo rubygems registry (tagged releases):
|
||||
|
||||
```ruby
|
||||
source "https://git.teletypegames.org/api/packages/tools/rubygems" do
|
||||
gem "warp_engine"
|
||||
end
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
```sh
|
||||
rails g warp_engine:install # initializer + create_warp_engine_tables migration
|
||||
rails db:migrate
|
||||
```
|
||||
|
||||
```ruby
|
||||
# config/routes.rb — keep it the last entry so host routes win
|
||||
# config/routes.rb — keep it the last entry so your own routes win
|
||||
mount WarpEngine::Engine => "/"
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
```ruby
|
||||
# config/initializers/warp_engine.rb
|
||||
Rails.application.config.to_prepare do
|
||||
WarpEngine.configure do |c|
|
||||
# Where CI drops build artifacts and where images are stored
|
||||
c.file_container_path = ENV.fetch("FILE_CONTAINER_PATH", "/softwares")
|
||||
c.image_container_path = ENV.fetch("IMAGE_CONTAINER_PATH", "/images")
|
||||
c.update_secret = ENV["UPDATE_SECRET"] # nil => /update rejects everything
|
||||
# If host models also reference catalog images:
|
||||
c.image_owners = [
|
||||
{
|
||||
label: "member",
|
||||
image_ids: -> { Member.where.not(image_id: nil).distinct.pluck(:image_id) },
|
||||
usage_label: ->(image) { "member" if Member.where(image_id: image.id).exists? }
|
||||
}
|
||||
]
|
||||
|
||||
# Shared secret for the /build/* endpoints.
|
||||
# nil => the endpoints reject every request.
|
||||
c.update_secret = ENV["UPDATE_SECRET"]
|
||||
|
||||
# Authentication source for /build/* — an exclusive choice:
|
||||
# :env — the shared secret above is accepted (default)
|
||||
# :database — only WarpEngine::ApplicationToken records with the
|
||||
# "update" scope are accepted; the shared secret stops
|
||||
# working the moment you switch.
|
||||
# :database mode also requires the owner class every token belongs to:
|
||||
# c.application_token_source = :database
|
||||
# c.application_token_owner_class = "AdminUser"
|
||||
|
||||
# Size cap for /build/upload and the admin file manager, in bytes (default 500MB).
|
||||
# c.max_upload_size = 500 * 1024 * 1024
|
||||
|
||||
# Owner isolation: a database token may only upload/publish softwares
|
||||
# owned by its own owner (unrestricted tokens are exempt). Enable only
|
||||
# after backfilling owners — ownerless softwares are claimable by anyone.
|
||||
# c.enforce_software_ownership = true
|
||||
|
||||
# If your app's own models reference catalog images, register them so the
|
||||
# admin Images page counts them as "in use":
|
||||
# c.image_owners = [
|
||||
# {
|
||||
# label: "member",
|
||||
# image_ids: -> { Member.where.not(image_id: nil).distinct.pluck(:image_id) },
|
||||
# usage_label: ->(image) { "member" if Member.where(image_id: image.id).exists? }
|
||||
# }
|
||||
# ]
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
## Host expectations
|
||||
## The updater contract
|
||||
|
||||
- **ActiveAdmin + Devise live in the host**: authentication, theme, assets and
|
||||
the `/admin` routes are the host's responsibility; the engine only appends
|
||||
its resource files to `ActiveAdmin.application.load_paths`.
|
||||
- **Files picker JS**: the file-picker next to release-asset path inputs relies
|
||||
on a few lines of JS in the host's `active_admin.js` (an iframe pointing at
|
||||
`/admin/files?picker=1&field=<dom_id>`) — copy that over to a new host too.
|
||||
- **apipie**: if the host generates apipie docs, add the engine to the matcher:
|
||||
`"#{WarpEngine::Engine.root}/app/controllers/**/*.rb"`.
|
||||
Publishing a release from CI is two steps:
|
||||
|
||||
1. **Upload** build artifacts into `file_container_path`, named by convention:
|
||||
`<name>-<version>.metadata.json`, `<name>-<version>.html.zip`,
|
||||
`<name>-<version>-win-x64.zip`, `<name>-<version>.tic`, ... (each platform
|
||||
declares which asset kinds it expects — see `GET /api/builds`). Push them
|
||||
over HTTP — one request per file, `upload` scope, optional `sha256`
|
||||
integrity check:
|
||||
|
||||
```sh
|
||||
curl -H "X-Update-Secret: $UPDATE_SECRET" \
|
||||
-F "file=@mygame-1.2.0.html.zip" \
|
||||
"https://your-host/build/upload?name=mygame&version=1.2.0"
|
||||
```
|
||||
|
||||
2. **Publish the release**:
|
||||
|
||||
```sh
|
||||
curl -X POST -H "X-Update-Secret: $UPDATE_SECRET" \
|
||||
"https://your-host/build/publish?platform=tic80&name=mygame&version=1.2.0"
|
||||
```
|
||||
|
||||
WarpEngine extracts the archives, parses the metadata (JSON, or the Lua
|
||||
comment header for TIC-80), and upserts the `Software`, `ExternalLink`,
|
||||
`Release` and `ReleaseAsset` records in a single transaction. Previously
|
||||
deleted records are resurrected on re-ingest.
|
||||
|
||||
### Updater authentication
|
||||
|
||||
The `X-Update-Secret` header carries one of two credentials, selected by
|
||||
`application_token_source` — the modes are exclusive, the endpoint never
|
||||
accepts both:
|
||||
|
||||
- **`:env`** (default): the single shared secret from `update_secret`.
|
||||
- **`:database`**: `WarpEngine::ApplicationToken` records. Each token
|
||||
belongs to an owner (the class named by `application_token_owner_class`,
|
||||
e.g. `AdminUser`), carries a free-form scope list — publishing requires
|
||||
the `"update"` scope, `/build/upload` the `"upload"` scope — and an
|
||||
optional expiry. Tokens are created in the admin
|
||||
(*App Tokens*): the plain token is generated server-side and shown exactly
|
||||
once after creation; only its SHA256 digest is stored. Deleting a token in
|
||||
the admin revokes it (soft delete), and `last_used_at` records when each
|
||||
token last authenticated successfully.
|
||||
|
||||
When switching to `:database`, create the tokens and move your pipelines to
|
||||
them first — the flip invalidates the shared secret immediately.
|
||||
|
||||
## CI pipeline configs (Woodpecker)
|
||||
|
||||
WarpEngine can act as a [Woodpecker configuration extension](https://woodpecker-ci.org/docs/usage/extensions/configuration-extension):
|
||||
instead of a copy-pasted `.woodpecker.yaml` in every game repo, the repo holds a
|
||||
one-line marker and the engine serves the full per-platform pipeline
|
||||
(version → build → upload → publish, calling `/build/upload` + `/build/publish`
|
||||
with the `application_token` Woodpecker secret):
|
||||
|
||||
```yaml
|
||||
# .woodpecker.yaml in a game repo
|
||||
platform: godot
|
||||
```
|
||||
|
||||
- `POST /build/config` — the extension endpoint Woodpecker calls on every
|
||||
pipeline start (httpsig/ed25519-signed request, verified against
|
||||
`ci_extension_public_key(_url)`). Non-marker configs get a `204` so the
|
||||
repo's own YAML keeps running — opt-in migration, and putting a full
|
||||
pipeline back into the repo is the opt-out.
|
||||
- `GET /build/config?platform=godot` — renders the same pipeline as a preview.
|
||||
|
||||
Configuration: `ci_platforms` maps platform names to builder images
|
||||
(`{ "godot" => { builder: "..." }, "tic80" => { builder: ..., exporter: ... } }`);
|
||||
an empty map (default) disables the feature. Set the Woodpecker side with
|
||||
`WOODPECKER_CONFIG_EXTENSION_ENDPOINT=https://your-host/build/config` (or
|
||||
per-repo in Settings → Extensions). Templates live in
|
||||
`app/services/warp_engine/platforms/<platform>/pipeline.yaml.erb`.
|
||||
|
||||
## Public API
|
||||
|
||||
| Endpoint | Purpose |
|
||||
| --- | --- |
|
||||
| `GET /api/software` | Full catalog with releases, assets, links, download counts; `?owner_id=` filters to one publisher |
|
||||
| `GET /api/software/highlighted` | The currently highlighted title |
|
||||
| `GET /api/builds` | Expected asset kinds per platform (build matrix) |
|
||||
| `GET /api/softwares/:name/builds` | Actual vs. missing build assets per release |
|
||||
| `GET /api/image/:id` | Serves catalog images |
|
||||
| `GET /api/download?path=` | Serves an artifact and logs a download record |
|
||||
| `GET /file/*path` | Serves static build output (web-playable games, docs) |
|
||||
|
||||
## Admin integration
|
||||
|
||||
The host owns the single ActiveAdmin instance — authentication (Devise),
|
||||
theme, assets and the `/admin` routes. WarpEngine only appends its resource
|
||||
files to `ActiveAdmin.application.load_paths`. Two things to copy into a new
|
||||
host:
|
||||
|
||||
- the small file-picker JS for release-asset path inputs (an iframe pointing
|
||||
at `/admin/files?picker=1&field=<dom_id>`) in your `active_admin.js`;
|
||||
- if you generate apipie docs, add
|
||||
`"#{WarpEngine::Engine.root}/app/controllers/**/*.rb"` to your
|
||||
`api_controllers_matcher`.
|
||||
|
||||
## Behavioral notes
|
||||
|
||||
- Every model is soft-deleted (`default_scope { where(deleted_at: nil) }`);
|
||||
the updater "resurrects" re-submitted, previously deleted records via
|
||||
`.unscoped`.
|
||||
- The JSON shape is intentionally bug-compatible with the former Go backend
|
||||
(Go zero-time timestamps, camelCase keys, legacy flat path fields).
|
||||
- Every model is soft-deleted (`default_scope { where(deleted_at: nil) }`).
|
||||
- The JSON shape is stable and intentionally bug-compatible with the project's
|
||||
former Go backend (Go zero-time timestamps, camelCase keys, legacy flat
|
||||
path fields).
|
||||
- Model extension points: `ActiveSupport.on_load(:warp_engine_<model>)` hooks.
|
||||
|
||||
## Tests
|
||||
|
||||
The engine ships an RSpec suite running against a bundled dummy app:
|
||||
|
||||
```sh
|
||||
bundle install
|
||||
bundle exec rake app:db:prepare RAILS_ENV=test # warp_engine_test DB for the dummy app
|
||||
bundle exec rake app:db:prepare RAILS_ENV=test
|
||||
bundle exec rspec
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
This repository is a **read-only split mirror** — development happens in the
|
||||
[`tools/teletypegames`](https://git.teletypegames.org/tools/teletypegames)
|
||||
monorepo under `libs/ruby/warp_engine`, and CI republishes the mirror on every
|
||||
change. Please do not open pull requests against the mirror.
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
ActiveAdmin.register WarpEngine::ApplicationToken, as: "Application Token" do
|
||||
actions :index, :show, :new, :create, :edit, :update, :destroy
|
||||
permit_params :name, :owner_id, :expires_at, :scopes_string, :unrestricted
|
||||
|
||||
menu parent: "🌀 WarpEngine", priority: 9, label: "🎟️ App Tokens"
|
||||
|
||||
config.sort_order = "created_at_desc"
|
||||
config.batch_actions = false
|
||||
|
||||
scope :all, default: true
|
||||
scope("Active") { |scope| scope.where("expires_at IS NULL OR expires_at > ?", Time.current) }
|
||||
scope("Expired") { |scope| scope.where("expires_at <= ?", Time.current) }
|
||||
|
||||
index do
|
||||
id_column
|
||||
column :name
|
||||
column("Token") { |t| code "#{t.token_prefix}…", style: "font-family:monospace;" }
|
||||
column("Owner") { |t| t.owner.try(:email) || t.owner.try(:name) || "#{t.owner_type} ##{t.owner_id}" }
|
||||
column("Scopes") { |t| t.scopes_string }
|
||||
column :unrestricted
|
||||
column :expires_at
|
||||
column :last_used_at
|
||||
column :created_at
|
||||
actions
|
||||
end
|
||||
|
||||
filter :name_cont, label: "Name"
|
||||
filter :token_prefix_cont, label: "Token prefix"
|
||||
filter :expires_at
|
||||
filter :last_used_at
|
||||
|
||||
form do |f|
|
||||
owner_class = WarpEngine.config.application_token_owner_class&.safe_constantize
|
||||
f.inputs do
|
||||
if f.object.new_record?
|
||||
if owner_class
|
||||
f.input :owner_id, as: :select, label: owner_class.name,
|
||||
collection: owner_class.all.map { |o| [ o.try(:email) || o.try(:name) || "##{o.id}", o.id ] },
|
||||
include_blank: false
|
||||
else
|
||||
f.template.concat(f.template.content_tag(:li,
|
||||
"application_token_owner_class is not configured — tokens cannot be created.",
|
||||
class: "flash flash_error"))
|
||||
end
|
||||
end
|
||||
f.input :name
|
||||
f.input :scopes_string, label: "Scopes (comma separated)",
|
||||
hint: %(The "update" scope is required for /build/publish, the "upload" scope for /build/upload.)
|
||||
f.input :unrestricted, hint: "Internal token: exempt from owner isolation (enforce_software_ownership)."
|
||||
f.input :expires_at, hint: "Leave empty for a token that never expires."
|
||||
end
|
||||
f.actions
|
||||
end
|
||||
|
||||
action_item :rotate, only: :show do
|
||||
if WarpEngine.woodpecker_configured?
|
||||
link_to "Rotate Token", rotate_admin_application_token_path(resource),
|
||||
method: :post, data: { confirm: "This will revoke the current token, create a new one, and push it to Woodpecker. Continue?" }
|
||||
end
|
||||
end
|
||||
|
||||
member_action :rotate, method: :post do
|
||||
result = WarpEngine::CiSecretSyncService.new.rotate(resource)
|
||||
if result[:rotated]
|
||||
session[:warp_engine_plain_token] = result[:new_token].plain_token
|
||||
redirect_to resource_path(result[:new_token]),
|
||||
notice: "Token rotated and synced to #{result.dig(:sync_result, :synced)&.size || 0} repo(s)"
|
||||
else
|
||||
redirect_to resource_path(resource),
|
||||
alert: "Rotation failed: #{result[:reason]}"
|
||||
end
|
||||
end
|
||||
|
||||
show do
|
||||
if (plain = controller.instance_variable_get(:@plain_token))
|
||||
panel "⚠️ Token — shown only once, copy it now!" do
|
||||
pre plain, style: "font-family:monospace;font-size:14px;padding:8px;background:#fff3cd;user-select:all;"
|
||||
end
|
||||
end
|
||||
attributes_table do
|
||||
row :id
|
||||
row :name
|
||||
row("Token") { |t| code "#{t.token_prefix}… (SHA256 digest stored)" }
|
||||
row("Owner") { |t| "#{t.owner_type} ##{t.owner_id} — #{t.owner.try(:email) || t.owner.try(:name)}" }
|
||||
row("Scopes") { |t| t.scopes_string }
|
||||
row :unrestricted
|
||||
row :expires_at
|
||||
row :last_used_at
|
||||
row :created_at
|
||||
row :updated_at
|
||||
end
|
||||
end
|
||||
|
||||
controller do
|
||||
# The plain token only exists right after creation; it travels via the
|
||||
# session to its one-time display (flash is unsuitable: the AA layout
|
||||
# renders every flash key as a message bar).
|
||||
def create
|
||||
create! do |success, _failure|
|
||||
success.html do
|
||||
session[:warp_engine_plain_token] = resource.plain_token
|
||||
|
||||
if WarpEngine.woodpecker_configured?
|
||||
service = WarpEngine::CiSecretSyncService.new
|
||||
repos = service.repos_for_token(resource)
|
||||
if repos.any?
|
||||
result = service.provision(resource.plain_token, repos: repos)
|
||||
flash[:notice] = "Token created and synced to #{result[:synced].size} repo(s)."
|
||||
if result[:failed].any?
|
||||
flash[:alert] = "Failed to sync to #{result[:failed].size} repo(s)."
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
redirect_to resource_path(resource) and return
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def show
|
||||
@plain_token = session.delete(:warp_engine_plain_token)
|
||||
show!
|
||||
end
|
||||
|
||||
def destroy
|
||||
if WarpEngine.woodpecker_configured?
|
||||
WarpEngine::CiSecretSyncService.new.deprovision(resource)
|
||||
end
|
||||
resource.revoke!
|
||||
redirect_to collection_path, notice: "Token revoked and Woodpecker secrets cleaned up."
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,49 @@
|
||||
ActiveAdmin.register_page "CI Dashboard" do
|
||||
menu parent: "🌀 WarpEngine", priority: 11, label: "🚀 CI Dashboard"
|
||||
|
||||
content do
|
||||
if WarpEngine.woodpecker_configured?
|
||||
begin
|
||||
entries = WarpEngine::CiPipelineService.new.dashboard
|
||||
rescue => e
|
||||
entries = []
|
||||
div class: "flash flash_alert" do
|
||||
"Error connecting to Woodpecker: #{e.message}"
|
||||
end
|
||||
end
|
||||
|
||||
entries.group_by { |e| e[:repo].platform }.sort.each do |platform, group|
|
||||
panel platform.titleize do
|
||||
table_for group do
|
||||
column("Repository") { |e| link_to e[:repo].full_name, admin_ci_repository_path(e[:repo]) }
|
||||
column("Software") { |e| e[:repo].software ? link_to(e[:repo].software.title, admin_software_path(e[:repo].software)) : "-" }
|
||||
column("Status") { |e|
|
||||
if e[:pipeline]
|
||||
status_tag e[:pipeline]["status"],
|
||||
class: e[:pipeline]["status"] == "success" ? "ok" : "error"
|
||||
else
|
||||
status_tag "unknown", class: "warning"
|
||||
end
|
||||
}
|
||||
column("Branch") { |e| e.dig(:pipeline, "branch") || "-" }
|
||||
column("When") { |e| e.dig(:pipeline, "created_at") || "-" }
|
||||
column("Actions") { |e|
|
||||
if e[:repo].active
|
||||
text_node link_to("Trigger", trigger_admin_ci_repository_path(e[:repo]),
|
||||
method: :post, class: "member_link")
|
||||
end
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if entries.empty?
|
||||
para "No CI repositories tracked. Sync repos from the CI Repos page.",
|
||||
style: "color:#999;text-align:center;padding:40px 0;"
|
||||
end
|
||||
else
|
||||
para "Woodpecker is not configured. Set woodpecker_url and woodpecker_api_token in the WarpEngine initializer.",
|
||||
style: "color:#999;text-align:center;padding:40px 0;"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,118 @@
|
||||
ActiveAdmin.register WarpEngine::CiRepository, as: "CI Repository" do
|
||||
actions :index, :show, :edit, :update
|
||||
|
||||
menu parent: "🌀 WarpEngine", priority: 10, label: "🔧 CI Repos"
|
||||
|
||||
config.sort_order = "repo_name_asc"
|
||||
config.batch_actions = false
|
||||
|
||||
scope :all, default: true
|
||||
scope("Active") { |s| s.where(active: true) }
|
||||
scope("Inactive") { |s| s.where(active: false) }
|
||||
|
||||
WarpEngine::PlatformLink::SUPPORTED_PLATFORMS.each do |p|
|
||||
scope(p.capitalize) { |s| s.where(platform: p) }
|
||||
end
|
||||
|
||||
index do
|
||||
id_column
|
||||
column :repo_owner
|
||||
column :repo_name
|
||||
column :platform
|
||||
column("Software") { |r| r.software ? link_to(r.software.title, admin_software_path(r.software)) : "-" }
|
||||
column(:active) { |r| status_tag(r.active ? "active" : "inactive", class: r.active ? "ok" : "warning") }
|
||||
column("Pipeline") { |r|
|
||||
if r.last_pipeline_status
|
||||
status_tag r.last_pipeline_status,
|
||||
class: r.last_pipeline_status == "success" ? "ok" : "error"
|
||||
else
|
||||
"-"
|
||||
end
|
||||
}
|
||||
column :last_pipeline_at
|
||||
actions defaults: true do |repo|
|
||||
if repo.active && WarpEngine.woodpecker_configured?
|
||||
item "Trigger", trigger_admin_ci_repository_path(repo), method: :post, class: "member_link"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
filter :repo_name
|
||||
filter :platform, as: :select, collection: WarpEngine::PlatformLink::SUPPORTED_PLATFORMS
|
||||
filter :active
|
||||
|
||||
form do |f|
|
||||
f.inputs do
|
||||
f.input :platform, as: :select, collection: WarpEngine::PlatformLink::SUPPORTED_PLATFORMS
|
||||
f.input :software_id, as: :select,
|
||||
collection: WarpEngine::Software.order(:title).map { |s| [ s.title, s.id ] },
|
||||
include_blank: "- none -"
|
||||
end
|
||||
f.actions
|
||||
end
|
||||
|
||||
show do
|
||||
attributes_table do
|
||||
row :id
|
||||
row :woodpecker_repo_id
|
||||
row :repo_owner
|
||||
row :repo_name
|
||||
row :platform
|
||||
row("Software") { |r| r.software ? link_to(r.software.title, admin_software_path(r.software)) : "-" }
|
||||
row(:active) { |r| status_tag(r.active ? "active" : "inactive") }
|
||||
row :last_pipeline_status
|
||||
row :last_pipeline_at
|
||||
row :created_at
|
||||
row :updated_at
|
||||
end
|
||||
|
||||
if WarpEngine.woodpecker_configured? && resource.active
|
||||
panel "Recent Pipelines" do
|
||||
begin
|
||||
pipelines = WarpEngine::CiPipelineService.new.list_pipelines(resource, page: 1)
|
||||
if pipelines.is_a?(Array) && pipelines.any?
|
||||
table_for pipelines.first(10) do
|
||||
column("Number") { |p| p["number"] }
|
||||
column("Status") { |p| status_tag p["status"], class: p["status"] == "success" ? "ok" : "error" }
|
||||
column("Branch") { |p| p["branch"] }
|
||||
column("Message") { |p| p["message"]&.truncate(60) }
|
||||
column("Created") { |p| p["created_at"] }
|
||||
end
|
||||
else
|
||||
para "No pipelines found.", style: "color:#999;"
|
||||
end
|
||||
rescue => e
|
||||
para "Error fetching pipelines: #{e.message}", style: "color:red;"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
member_action :trigger, method: :post do
|
||||
repo = WarpEngine::CiRepository.find(params[:id])
|
||||
WarpEngine::CiPipelineService.new.trigger(repo)
|
||||
redirect_to resource_path(repo), notice: "Pipeline triggered for #{repo.full_name}"
|
||||
rescue => e
|
||||
redirect_to resource_path(repo), alert: "Trigger failed: #{e.message}"
|
||||
end
|
||||
|
||||
collection_action :sync, method: :post do
|
||||
result = WarpEngine::CiRepoSyncService.new.sync_all
|
||||
redirect_to collection_path,
|
||||
notice: "Synced: #{result[:created].size} new, #{result[:updated].size} updated, #{result[:deactivated].size} deactivated"
|
||||
rescue => e
|
||||
redirect_to collection_path, alert: "Sync failed: #{e.message}"
|
||||
end
|
||||
|
||||
action_item :sync_repos, only: :index do
|
||||
if WarpEngine.woodpecker_configured?
|
||||
link_to "Sync from Woodpecker", sync_admin_ci_repositories_path, method: :post
|
||||
end
|
||||
end
|
||||
|
||||
controller do
|
||||
def scoped_collection
|
||||
super.includes(:software)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,7 +1,7 @@
|
||||
ActiveAdmin.register WarpEngine::Download, as: "Download" do
|
||||
actions :index, :show
|
||||
|
||||
menu priority: 7, label: "📊 Download Stats"
|
||||
menu parent: "🌀 WarpEngine", priority: 7, label: "📊 Download Stats"
|
||||
|
||||
scope :all, default: true
|
||||
scope("Today") { |scope| scope.where("downloads.created_at >= ?", Date.current.beginning_of_day) }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
ActiveAdmin.register_page "Files" do
|
||||
menu priority: 6, label: "📁 Files"
|
||||
menu parent: "🌀 WarpEngine", priority: 6, label: "📁 Files"
|
||||
|
||||
content do
|
||||
service = WarpEngine::FileManagerService.new
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
ActiveAdmin.register WarpEngine::Image, as: "Image" do
|
||||
permit_params :file_upload
|
||||
|
||||
menu priority: 5, label: "🖼️ Images"
|
||||
menu parent: "🌀 WarpEngine", priority: 5, label: "🖼️ Images"
|
||||
|
||||
# SoftwareImage a natív használó; a hoston regisztrált image_owners
|
||||
# (WarpEngine.config) további használókat adhat hozzá (pl. TTG Member).
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
ActiveAdmin.register WarpEngine::PlatformLink, as: "Platform Link" do
|
||||
permit_params :name, :url, :platform, :position
|
||||
|
||||
menu priority: 5, label: "🔗 Platform Links"
|
||||
menu parent: "🌀 WarpEngine", priority: 5, label: "🔗 Platform Links"
|
||||
|
||||
config.sort_order = "platform_asc"
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ ActiveAdmin.register WarpEngine::Software, as: "Software" do
|
||||
releases_attributes: [ :id, :version, :_destroy,
|
||||
{ release_assets_attributes: [ :id, :kind, :path, :_destroy ] } ]
|
||||
|
||||
menu priority: 2, label: "🎮 Softwares"
|
||||
menu parent: "🌀 WarpEngine", priority: 2, label: "🎮 Softwares"
|
||||
|
||||
actions :all, except: [ :edit ]
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
module WarpEngine
|
||||
# Token authentication for the publishing (/build/*) endpoints.
|
||||
# The auth source is exclusive: in :database mode the shared secret is not
|
||||
# accepted, in :env mode DB tokens are not.
|
||||
module UpdateAuthentication
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
private
|
||||
|
||||
attr_reader :current_application_token
|
||||
|
||||
# The token is accepted from the X-Update-Secret header only — in the URL
|
||||
# it would leak into proxy and access logs.
|
||||
def update_authorized?(required_scope:)
|
||||
token = request.headers["X-Update-Secret"].presence
|
||||
return false if token.blank?
|
||||
|
||||
case WarpEngine.config.application_token_source
|
||||
when :database then database_token_authorized?(token, required_scope)
|
||||
else env_secret_authorized?(token)
|
||||
end
|
||||
end
|
||||
|
||||
def env_secret_authorized?(token)
|
||||
expected = WarpEngine.config.update_secret
|
||||
# With no secret configured the endpoint stays closed.
|
||||
expected.present? && ActiveSupport::SecurityUtils.secure_compare(token, expected)
|
||||
end
|
||||
|
||||
def database_token_authorized?(token, required_scope)
|
||||
if WarpEngine.config.application_token_owner_class.blank?
|
||||
Rails.logger.error("[#{self.class.name}] application_token_source=:database but application_token_owner_class is not set — rejecting every request")
|
||||
return false
|
||||
end
|
||||
|
||||
record = WarpEngine::ApplicationToken.authenticate(token, required_scope: required_scope)
|
||||
return false if record.nil?
|
||||
|
||||
record.touch_last_used!
|
||||
@current_application_token = record
|
||||
true
|
||||
end
|
||||
|
||||
# Ownership enforcement applies only in :database mode (there is a token)
|
||||
# with enforce_software_ownership on. An ownerless software is up for grabs
|
||||
# until the backfill — backfill before enabling the enforcement.
|
||||
def software_ownership_authorized?(name)
|
||||
return true unless WarpEngine.config.enforce_software_ownership
|
||||
|
||||
token = current_application_token
|
||||
return true if token.nil? || token.unrestricted?
|
||||
|
||||
software = WarpEngine::Software.find_by(name: name)
|
||||
return true if software.nil? || software.owner_id.nil?
|
||||
|
||||
software.owner_type == token.owner_type && software.owner_id == token.owner_id
|
||||
end
|
||||
|
||||
# A first-published (or pre-backfill, ownerless) software gets the
|
||||
# submitting token's owner. Unrestricted (internal) tokens claim nothing.
|
||||
def claim_software_ownership(name)
|
||||
token = current_application_token
|
||||
return if token.nil? || token.unrestricted?
|
||||
|
||||
software = WarpEngine::Software.find_by(name: name)
|
||||
return if software.nil? || software.owner_id.present?
|
||||
|
||||
software.update_columns(owner_type: token.owner_type, owner_id: token.owner_id)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,74 @@
|
||||
module WarpEngine
|
||||
module Api
|
||||
class CiController < ApiController
|
||||
include UpdateAuthentication
|
||||
|
||||
before_action :require_woodpecker!
|
||||
|
||||
resource_description do
|
||||
short "CI repository and pipeline management"
|
||||
end
|
||||
|
||||
api :GET, "/api/ci/repos", "List active CI repositories"
|
||||
returns code: 200, desc: "JSON array of tracked repos"
|
||||
error code: 503, desc: "Woodpecker not configured"
|
||||
def repos
|
||||
records = CiRepository.active.includes(:software)
|
||||
render json: records.map { |r| repo_json(r) }
|
||||
end
|
||||
|
||||
api :GET, "/api/ci/repos/:id/status", "Get a CI repository with its latest pipeline"
|
||||
param :id, :number, required: true, desc: "CiRepository id"
|
||||
returns code: 200, desc: "JSON with repo and pipeline data"
|
||||
error code: 503, desc: "Woodpecker not configured"
|
||||
def status
|
||||
repo = CiRepository.find(params[:id])
|
||||
pipeline = begin
|
||||
CiPipelineService.new.pipeline_detail(repo, "latest")
|
||||
rescue WoodpeckerClient::ApiError
|
||||
nil
|
||||
end
|
||||
render json: { repo: repo_json(repo), pipeline: pipeline }
|
||||
end
|
||||
|
||||
api :POST, "/api/ci/repos/:id/trigger", "Trigger a pipeline for a CI repository"
|
||||
header "X-Update-Secret", "Shared secret or application token (update scope)", required: true
|
||||
param :id, :number, required: true, desc: "CiRepository id"
|
||||
param :branch, String, required: false, desc: "Branch to build (default: main)"
|
||||
returns code: 200, desc: "JSON with triggered pipeline data"
|
||||
error code: 401, desc: "Invalid secret"
|
||||
error code: 503, desc: "Woodpecker not configured"
|
||||
def trigger
|
||||
unless update_authorized?(required_scope: ApplicationToken::UPDATE_SCOPE)
|
||||
return render json: { error: "Unauthorized" }, status: :unauthorized
|
||||
end
|
||||
|
||||
repo = CiRepository.find(params[:id])
|
||||
result = CiPipelineService.new.trigger(repo, branch: params[:branch] || "main")
|
||||
render json: { triggered: true, pipeline: result }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def require_woodpecker!
|
||||
return if WarpEngine.woodpecker_configured?
|
||||
|
||||
render json: { error: "Woodpecker not configured" }, status: :service_unavailable
|
||||
end
|
||||
|
||||
def repo_json(repo)
|
||||
{
|
||||
id: repo.id,
|
||||
woodpecker_repo_id: repo.woodpecker_repo_id,
|
||||
repo_owner: repo.repo_owner,
|
||||
repo_name: repo.repo_name,
|
||||
platform: repo.platform,
|
||||
active: repo.active,
|
||||
software_name: repo.software&.name,
|
||||
last_pipeline_status: repo.last_pipeline_status,
|
||||
last_pipeline_at: repo.last_pipeline_at
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -29,6 +29,7 @@ module WarpEngine
|
||||
end
|
||||
|
||||
api :GET, "/api/software", "List all software entries with releases"
|
||||
param :owner_id, :number, required: false, desc: "Filter to the softwares of one owner (publisher)"
|
||||
returns code: 200, desc: "Wrapper object with softwares array" do
|
||||
property :softwares, Array, desc: "Array of software entries" do
|
||||
property :ID, Integer, desc: "Software ID"
|
||||
@@ -55,7 +56,7 @@ module WarpEngine
|
||||
end
|
||||
end
|
||||
def index
|
||||
render json: WarpEngine::SoftwareService.new.index
|
||||
render json: WarpEngine::SoftwareService.new.index(owner_id: params[:owner_id])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
module WarpEngine
|
||||
module Build
|
||||
# Woodpecker configuration-extension endpoint: on every pipeline start the
|
||||
# CI server POSTs the repo's marker file and receives the platform's full
|
||||
# pipeline YAML. GET renders the same thing as a preview.
|
||||
class ConfigsController < ApiController
|
||||
resource_description do
|
||||
short "Woodpecker CI pipeline configs"
|
||||
end
|
||||
|
||||
api :GET, "/build/config", "Preview the generated pipeline config for a platform"
|
||||
param :platform, String, required: true, desc: "Platform (a configured ci_platforms key, e.g. tic80)"
|
||||
param :name, String, required: false, desc: "Software name substituted into the pipeline (default: example)"
|
||||
returns code: 200, desc: "Pipeline YAML (text/yaml)"
|
||||
error code: 404, desc: "Unknown platform"
|
||||
def show
|
||||
yaml = render_config(platform: params[:platform], name: params[:name].presence || "example")
|
||||
return render json: { error: "Unknown platform" }, status: :not_found if yaml.nil?
|
||||
|
||||
render plain: yaml, content_type: "text/yaml"
|
||||
end
|
||||
|
||||
api :POST, "/build/config", "Woodpecker configuration extension endpoint"
|
||||
description <<~DESC
|
||||
Called by the Woodpecker server on every pipeline start (httpsig-signed request).
|
||||
If the repo's .woodpecker.yaml is a marker (has a `platform:` key), responds with
|
||||
the generated pipeline; otherwise responds 204 so the repo's own config runs.
|
||||
DESC
|
||||
returns code: 200, desc: %(JSON: {"configs": [{"name": ..., "data": "<pipeline YAML>"}]})
|
||||
returns code: 204, desc: "Not a marker config — keep the repo's own configuration"
|
||||
error code: 403, desc: "Missing or invalid request signature"
|
||||
error code: 422, desc: "Marker requests an unknown platform"
|
||||
def create
|
||||
unless WarpEngine::CiSignatureVerifier.new(request).valid?
|
||||
return render json: { error: "Invalid signature" }, status: :forbidden
|
||||
end
|
||||
|
||||
marker = find_marker
|
||||
return head :no_content if marker.nil?
|
||||
|
||||
platform = marker["platform"].to_s
|
||||
name = marker["name"].presence || repo_name
|
||||
yaml = render_config(platform: platform, name: name)
|
||||
if yaml.nil?
|
||||
return render json: { error: "Unknown platform: #{platform}" }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
render json: { configs: [ { name: platform, data: yaml } ] }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def render_config(platform:, name:)
|
||||
WarpEngine::CiConfigService.new.render(
|
||||
platform: platform,
|
||||
name: name,
|
||||
update_server: WarpEngine.config.ci_update_server.presence || request.base_url
|
||||
)
|
||||
end
|
||||
|
||||
# The first submitted config that parses as a marker (Hash with a `platform`
|
||||
# key). The docs call the key "configuration", the example-config-service
|
||||
# uses "configs" — accept both.
|
||||
def find_marker
|
||||
configs = params[:configuration].presence || params[:configs].presence || []
|
||||
configs.each do |config|
|
||||
data = config[:data].to_s
|
||||
parsed = begin
|
||||
YAML.safe_load(data)
|
||||
rescue Psych::Exception
|
||||
nil
|
||||
end
|
||||
return parsed if parsed.is_a?(Hash) && parsed.key?("platform")
|
||||
end
|
||||
nil
|
||||
end
|
||||
|
||||
def repo_name
|
||||
params.dig(:repo, :name).to_s
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,45 @@
|
||||
module WarpEngine
|
||||
module Build
|
||||
class PublishController < ApiController
|
||||
include UpdateAuthentication
|
||||
|
||||
resource_description do
|
||||
short "Build release publishing"
|
||||
end
|
||||
|
||||
api :POST, "/build/publish", "Register an uploaded build as a release"
|
||||
header "X-Update-Secret", "Shared secret or application token (update scope)", required: true
|
||||
param :name, String, required: true, desc: "Software name"
|
||||
param :platform, String, required: true, desc: "Platform (tic80, love, ebitengine, c64, godot, bevy, phaser)"
|
||||
param :version, String, required: true, desc: "Version string"
|
||||
returns code: 200, desc: "JSON with the published name/platform/version"
|
||||
error code: 401, desc: "Invalid secret"
|
||||
error code: 403, desc: "Token does not own this software"
|
||||
error code: 400, desc: "Missing or invalid arguments"
|
||||
def create
|
||||
unless update_authorized?(required_scope: WarpEngine::ApplicationToken::UPDATE_SCOPE)
|
||||
return render json: { error: "Unauthorized" }, status: :unauthorized
|
||||
end
|
||||
|
||||
%i[name platform version].each do |key|
|
||||
return render json: { error: "#{key.to_s.capitalize} not provided" }, status: :bad_request if params[key].blank?
|
||||
end
|
||||
|
||||
unless software_ownership_authorized?(params[:name])
|
||||
return render json: { error: "Forbidden" }, status: :forbidden
|
||||
end
|
||||
|
||||
input = WarpEngine::PublishInputDto.new(
|
||||
platform: params[:platform],
|
||||
name: params[:name],
|
||||
version: params[:version]
|
||||
)
|
||||
|
||||
WarpEngine::PublishService.new.publish(input)
|
||||
claim_software_ownership(params[:name])
|
||||
|
||||
render json: { published: true, name: params[:name], platform: params[:platform], version: params[:version] }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,65 @@
|
||||
require "digest"
|
||||
|
||||
module WarpEngine
|
||||
module Build
|
||||
class UploadsController < ApiController
|
||||
include UpdateAuthentication
|
||||
|
||||
resource_description do
|
||||
short "Build artifact upload"
|
||||
end
|
||||
|
||||
# Release file naming convention: <name>-<version>.<ext> or
|
||||
# <name>-<version>-<target>.zip — the updater looks for these too.
|
||||
NAME_FORMAT = /\A[A-Za-z0-9._-]+\z/
|
||||
|
||||
api :POST, "/build/upload", "Upload a build artifact into the artifact directory"
|
||||
header "X-Update-Secret", "Shared secret or application token (upload scope)", required: true
|
||||
param :name, String, required: true, desc: "Software name (filename must be prefixed with <name>-<version>)"
|
||||
param :version, String, required: true, desc: "Version string"
|
||||
param :file, File, required: true, desc: "Artifact file (multipart)"
|
||||
param :sha256, String, required: false, desc: "Expected SHA256 hex digest; on mismatch the upload is rejected"
|
||||
returns code: 200, desc: "JSON with stored file name, size and sha256"
|
||||
error code: 401, desc: "Invalid secret"
|
||||
error code: 403, desc: "Token does not own this software"
|
||||
error code: 400, desc: "Missing or invalid arguments"
|
||||
error code: 413, desc: "File larger than max_upload_size"
|
||||
error code: 422, desc: "SHA256 mismatch"
|
||||
def create
|
||||
unless update_authorized?(required_scope: WarpEngine::ApplicationToken::UPLOAD_SCOPE)
|
||||
return render json: { error: "Unauthorized" }, status: :unauthorized
|
||||
end
|
||||
|
||||
name = params[:name].to_s
|
||||
version = params[:version].to_s
|
||||
file = params[:file]
|
||||
|
||||
return render json: { error: "Invalid name" }, status: :bad_request unless name.match?(NAME_FORMAT)
|
||||
return render json: { error: "Invalid version" }, status: :bad_request unless version.match?(NAME_FORMAT)
|
||||
return render json: { error: "File not provided" }, status: :bad_request unless file.respond_to?(:original_filename)
|
||||
|
||||
unless software_ownership_authorized?(name)
|
||||
return render json: { error: "Forbidden" }, status: :forbidden
|
||||
end
|
||||
|
||||
filename = File.basename(file.original_filename.to_s)
|
||||
unless filename.start_with?("#{name}-#{version}.", "#{name}-#{version}-")
|
||||
return render json: { error: "Filename must be prefixed with #{name}-#{version}" }, status: :bad_request
|
||||
end
|
||||
|
||||
max = WarpEngine.config.max_upload_size
|
||||
if file.size > max
|
||||
return render json: { error: "File too large (max #{max / (1024 * 1024)}MB)" }, status: :payload_too_large
|
||||
end
|
||||
|
||||
digest = Digest::SHA256.file(file.tempfile.path).hexdigest
|
||||
if params[:sha256].present? && !ActiveSupport::SecurityUtils.secure_compare(params[:sha256].downcase, digest)
|
||||
return render json: { error: "SHA256 mismatch" }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
stored = WarpEngine::FileManagerService.new.upload("", file)
|
||||
render json: { file: stored, size: file.size, sha256: digest }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,49 +0,0 @@
|
||||
module WarpEngine
|
||||
class UpdateController < ApiController
|
||||
resource_description do
|
||||
short "Software updater"
|
||||
formats [ "text" ]
|
||||
end
|
||||
|
||||
rescue_from ArgumentError do |e|
|
||||
render plain: e.message, status: :bad_request
|
||||
end
|
||||
|
||||
rescue_from StandardError do |e|
|
||||
Rails.logger.error("[UpdateController] #{e.class}: #{e.message}\n#{e.backtrace.first(5).join("\n")}")
|
||||
render plain: "Internal server error", status: :internal_server_error
|
||||
end
|
||||
|
||||
api :GET, "/update", "Update software version in database"
|
||||
param :secret, String, required: true, desc: "Authorization secret"
|
||||
param :platform, String, required: false, desc: "Platform (tic80, love, ebitengine, c64, godot, bevy, phaser)"
|
||||
param :name, String, required: false, desc: "Software name"
|
||||
param :version, String, required: true, desc: "Version string"
|
||||
returns code: 200, desc: "Plain text 'Updated'"
|
||||
error code: 401, desc: "Invalid secret"
|
||||
error code: 400, desc: "Version not provided or invalid arguments"
|
||||
error code: 500, desc: "Internal server error"
|
||||
def update
|
||||
return render plain: "Unauthorized", status: :unauthorized unless authorized?
|
||||
return render plain: "Version not provided", status: :bad_request if params[:version].blank?
|
||||
|
||||
input = WarpEngine::UpdateInputDto.new(
|
||||
platform: params[:platform],
|
||||
name: params[:name],
|
||||
version: params[:version]
|
||||
)
|
||||
|
||||
WarpEngine::UpdateService.new.update(input)
|
||||
render plain: "Updated"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def authorized?
|
||||
secret = request.headers["X-Update-Secret"].presence || params[:secret]
|
||||
expected = WarpEngine.config.update_secret
|
||||
# Konfigurálatlan secret esetén az endpoint zárva marad.
|
||||
expected.present? && secret == expected
|
||||
end
|
||||
end
|
||||
end
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
module WarpEngine
|
||||
UpdateInputDto = Struct.new(:platform, :name, :version, keyword_init: true) do
|
||||
PublishInputDto = Struct.new(:platform, :name, :version, keyword_init: true) do
|
||||
def initialize(platform:, name:, version: nil)
|
||||
super
|
||||
end
|
||||
@@ -0,0 +1,8 @@
|
||||
module WarpEngine
|
||||
class ApplicationJob < ActiveJob::Base
|
||||
retry_on WoodpeckerClient::ConnectionError, wait: 30.seconds, attempts: 3
|
||||
discard_on WoodpeckerClient::ApiError do |job, error|
|
||||
Rails.logger.error("[#{job.class.name}] discarded: #{error.message}")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
module WarpEngine
|
||||
class CiRepoSyncJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform
|
||||
CiRepoSyncService.new.sync_all
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,100 @@
|
||||
require "digest"
|
||||
|
||||
module WarpEngine
|
||||
class ApplicationToken < ApplicationRecord
|
||||
self.table_name = "application_tokens"
|
||||
|
||||
UPDATE_SCOPE = "update".freeze
|
||||
UPLOAD_SCOPE = "upload".freeze
|
||||
|
||||
# The generated token is only available in memory at creation time — the DB
|
||||
# stores nothing but the SHA256 digest and the non-secret prefix.
|
||||
attr_reader :plain_token
|
||||
|
||||
belongs_to :owner, polymorphic: true
|
||||
|
||||
default_scope { where(deleted_at: nil) }
|
||||
scope :active, -> { where("expires_at IS NULL OR expires_at > ?", Time.current) }
|
||||
|
||||
before_validation :assign_owner_type, on: :create
|
||||
before_validation :generate_token, on: :create
|
||||
after_initialize { self.scopes = [] if new_record? && scopes.nil? }
|
||||
|
||||
validates :name, presence: true
|
||||
validates :token_digest, presence: true, uniqueness: true
|
||||
validates :token_prefix, presence: true
|
||||
validates :scopes, presence: true
|
||||
validate :owner_type_matches_configuration
|
||||
|
||||
def self.digest(token)
|
||||
Digest::SHA256.hexdigest(token)
|
||||
end
|
||||
|
||||
# The live (not deleted, not expired) token carrying the required scope, else nil.
|
||||
def self.authenticate(token, required_scope: nil)
|
||||
return nil if token.blank?
|
||||
|
||||
record = active.find_by(token_digest: digest(token))
|
||||
return nil if record.nil?
|
||||
return nil if required_scope.present? && !Array(record.scopes).include?(required_scope)
|
||||
|
||||
record
|
||||
end
|
||||
|
||||
def expired?
|
||||
expires_at.present? && expires_at <= Time.current
|
||||
end
|
||||
|
||||
# Revocation = soft delete, the audit trail stays.
|
||||
def revoke!
|
||||
update_column(:deleted_at, Time.current)
|
||||
end
|
||||
|
||||
def touch_last_used!
|
||||
update_column(:last_used_at, Time.current)
|
||||
end
|
||||
|
||||
# Admin form: comma separated scope list
|
||||
def scopes_string
|
||||
Array(scopes).join(", ")
|
||||
end
|
||||
|
||||
def scopes_string=(value)
|
||||
self.scopes = value.to_s.split(",").map(&:strip).reject(&:blank?).uniq
|
||||
end
|
||||
|
||||
def self.ransackable_attributes(auth_object = nil)
|
||||
%w[created_at deleted_at expires_at id last_used_at name owner_id owner_type token_prefix unrestricted updated_at]
|
||||
end
|
||||
|
||||
# Ransack cannot filter on the polymorphic owner association.
|
||||
def self.ransackable_associations(auth_object = nil)
|
||||
[]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def assign_owner_type
|
||||
self.owner_type = WarpEngine.config.application_token_owner_class if owner_type.blank?
|
||||
end
|
||||
|
||||
def generate_token
|
||||
return if token_digest.present?
|
||||
|
||||
@plain_token = SecureRandom.hex(24)
|
||||
self.token_prefix = @plain_token.first(8)
|
||||
self.token_digest = self.class.digest(@plain_token)
|
||||
end
|
||||
|
||||
def owner_type_matches_configuration
|
||||
expected = WarpEngine.config.application_token_owner_class
|
||||
if expected.blank?
|
||||
errors.add(:base, "application_token_owner_class is not configured")
|
||||
elsif owner_type != expected
|
||||
errors.add(:owner_type, "must be #{expected}")
|
||||
end
|
||||
end
|
||||
|
||||
ActiveSupport.run_load_hooks(:warp_engine_application_token, self)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,32 @@
|
||||
module WarpEngine
|
||||
class CiRepository < ApplicationRecord
|
||||
self.table_name = "ci_repositories"
|
||||
|
||||
belongs_to :software, class_name: "WarpEngine::Software", optional: true
|
||||
|
||||
default_scope { where(deleted_at: nil) }
|
||||
|
||||
validates :woodpecker_repo_id, presence: true, uniqueness: true
|
||||
validates :repo_owner, presence: true
|
||||
validates :repo_name, presence: true
|
||||
validates :platform, presence: true,
|
||||
inclusion: { in: WarpEngine::PlatformLink::SUPPORTED_PLATFORMS }
|
||||
|
||||
scope :active, -> { where(active: true) }
|
||||
|
||||
def full_name
|
||||
"#{repo_owner}/#{repo_name}"
|
||||
end
|
||||
|
||||
def self.ransackable_attributes(auth_object = nil)
|
||||
%w[active created_at deleted_at id last_pipeline_at last_pipeline_status
|
||||
platform repo_name repo_owner software_id woodpecker_repo_id]
|
||||
end
|
||||
|
||||
def self.ransackable_associations(auth_object = nil)
|
||||
%w[software]
|
||||
end
|
||||
|
||||
ActiveSupport.run_load_hooks(:warp_engine_ci_repository, self)
|
||||
end
|
||||
end
|
||||
@@ -2,11 +2,16 @@ module WarpEngine
|
||||
class Software < ApplicationRecord
|
||||
self.table_name = "softwares"
|
||||
|
||||
# Owner of the publishing token (e.g. AdminUser) — for 3rd-party isolation,
|
||||
# see enforce_software_ownership. nil = internal / pre-backfill software.
|
||||
belongs_to :owner, polymorphic: true, optional: true
|
||||
|
||||
has_many :software_images, foreign_key: :software_id, dependent: :destroy
|
||||
has_many :images, through: :software_images
|
||||
has_many :releases, foreign_key: :software_id
|
||||
has_many :downloads, through: :releases
|
||||
has_many :external_links, foreign_key: :software_id
|
||||
has_one :ci_repository, foreign_key: :software_id
|
||||
|
||||
accepts_nested_attributes_for :software_images, allow_destroy: true
|
||||
accepts_nested_attributes_for :external_links, allow_destroy: true
|
||||
@@ -19,7 +24,7 @@ module WarpEngine
|
||||
default_scope { where(deleted_at: nil) }
|
||||
|
||||
def self.ransackable_attributes(auth_object = nil)
|
||||
%w[author created_at desc highlighted id license name platform site status story title updated_at]
|
||||
%w[author created_at desc highlighted id license name owner_id owner_type platform site status story title updated_at]
|
||||
end
|
||||
|
||||
def self.ransackable_associations(auth_object = nil)
|
||||
|
||||
@@ -14,6 +14,8 @@ module WarpEngine
|
||||
field(:license) { |sw| sw.license.to_s }
|
||||
field :platform
|
||||
field :status
|
||||
# Public owner id — for the /api/software?owner_id= filter.
|
||||
field(:ownerId) { |sw| sw.owner_id }
|
||||
field(:highlighted) { |sw| sw.highlighted ? true : false }
|
||||
field(:externalLinks) { |sw| ExternalLinkSerializer.render_as_hash(sw.external_links) }
|
||||
field(:platformLinks) { |sw| PlatformLinkSerializer.render_as_hash(WarpEngine::PlatformLink.for_platform(sw.platform)) }
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ require "zip"
|
||||
require "fileutils"
|
||||
|
||||
module WarpEngine
|
||||
module SoftwareUpdater
|
||||
module Platforms
|
||||
module ArchiveExtraction
|
||||
private
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
module WarpEngine
|
||||
module SoftwareUpdater
|
||||
module Platforms
|
||||
module Builds
|
||||
module BuildCartridge
|
||||
extend ActiveSupport::Concern
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
module WarpEngine
|
||||
module SoftwareUpdater
|
||||
module Platforms
|
||||
module Builds
|
||||
module BuildDocs
|
||||
extend ActiveSupport::Concern
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
module WarpEngine
|
||||
module SoftwareUpdater
|
||||
module Platforms
|
||||
module Builds
|
||||
module BuildLinuxX64
|
||||
extend ActiveSupport::Concern
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
module WarpEngine
|
||||
module SoftwareUpdater
|
||||
module Platforms
|
||||
module Builds
|
||||
module BuildMacArm64
|
||||
extend ActiveSupport::Concern
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
module WarpEngine
|
||||
module SoftwareUpdater
|
||||
module Platforms
|
||||
module Builds
|
||||
module BuildMacUniversal
|
||||
extend ActiveSupport::Concern
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
module WarpEngine
|
||||
module SoftwareUpdater
|
||||
module Platforms
|
||||
module Builds
|
||||
module BuildMacX64
|
||||
extend ActiveSupport::Concern
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
module WarpEngine
|
||||
module SoftwareUpdater
|
||||
module Platforms
|
||||
module Builds
|
||||
module BuildSource
|
||||
extend ActiveSupport::Concern
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
module WarpEngine
|
||||
module SoftwareUpdater
|
||||
module Platforms
|
||||
module Builds
|
||||
module BuildWeb
|
||||
extend ActiveSupport::Concern
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
module WarpEngine
|
||||
module SoftwareUpdater
|
||||
module Platforms
|
||||
module Builds
|
||||
module BuildWinX64
|
||||
extend ActiveSupport::Concern
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
module WarpEngine
|
||||
module SoftwareUpdater
|
||||
module Platforms
|
||||
module Builds
|
||||
module BuildWinX86
|
||||
extend ActiveSupport::Concern
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
require "json"
|
||||
|
||||
module WarpEngine
|
||||
module SoftwareUpdater
|
||||
module Platforms
|
||||
module MetadataParsing
|
||||
METADATA_KEYS = %i[name title author desc site repo license].freeze
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
module WarpEngine
|
||||
module SoftwareUpdater
|
||||
module Platforms
|
||||
module SoftwarePersistence
|
||||
private
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
module WarpEngine
|
||||
module SoftwareUpdater
|
||||
module Platforms
|
||||
module Updatable
|
||||
extend ActiveSupport::Concern
|
||||
include ArchiveExtraction
|
||||
@@ -9,7 +9,7 @@ module WarpEngine
|
||||
class_methods do
|
||||
def platform(value = nil)
|
||||
@platform = value if value
|
||||
@platform ||= name.demodulize.delete_suffix("Service").downcase
|
||||
@platform ||= name.deconstantize.demodulize.downcase
|
||||
end
|
||||
|
||||
def label(value = nil)
|
||||
@@ -2,7 +2,7 @@ module WarpEngine
|
||||
class BuildsService
|
||||
def index
|
||||
platforms = WarpEngine::PlatformLink::SUPPORTED_PLATFORMS.each_with_object({}) do |platform, hash|
|
||||
service_class = "WarpEngine::SoftwareUpdater::#{platform.camelize}Service".constantize
|
||||
service_class = "WarpEngine::Platforms::#{platform.camelize}::Service".constantize
|
||||
hash[platform] = {
|
||||
label: service_class.label,
|
||||
kinds: service_class.expected_kinds
|
||||
@@ -16,7 +16,7 @@ module WarpEngine
|
||||
|
||||
def show(name)
|
||||
software = WarpEngine::Software.find_by!(name: name)
|
||||
service_class = "WarpEngine::SoftwareUpdater::#{software.platform.camelize}Service".constantize
|
||||
service_class = "WarpEngine::Platforms::#{software.platform.camelize}::Service".constantize
|
||||
expected = service_class.expected_kinds
|
||||
|
||||
releases = software.releases.includes(:release_assets).order(updated_at: :desc)
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
require "erb"
|
||||
|
||||
module WarpEngine
|
||||
# Renders the /build/config platform templates: the pipeline logic lives in
|
||||
# app/services/warp_engine/platforms/<platform>/pipeline.yaml.erb, the
|
||||
# per-platform builder images come from WarpEngine.config.ci_platforms.
|
||||
class CiConfigService
|
||||
PLATFORM_FORMAT = /\A[a-z0-9_-]+\z/
|
||||
|
||||
# The rendered pipeline YAML, or nil when the platform is not served.
|
||||
def render(platform:, name:, update_server:)
|
||||
platform = platform.to_s
|
||||
return nil unless platform.match?(PLATFORM_FORMAT)
|
||||
|
||||
spec = platform_spec(platform)
|
||||
return nil if spec.nil?
|
||||
|
||||
path = templates_dir.join(platform, "pipeline.yaml.erb")
|
||||
return nil unless path.exist?
|
||||
|
||||
ERB.new(path.read, trim_mode: "-").result_with_hash(
|
||||
name: name.to_s,
|
||||
update_server: update_server.to_s,
|
||||
builder: spec[:builder],
|
||||
exporter: spec[:exporter]
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def platform_spec(platform)
|
||||
spec = WarpEngine.config.ci_platforms.stringify_keys[platform]
|
||||
spec&.symbolize_keys
|
||||
end
|
||||
|
||||
def templates_dir
|
||||
WarpEngine::Engine.root.join("app", "services", "warp_engine", "platforms")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,38 @@
|
||||
module WarpEngine
|
||||
class CiPipelineService
|
||||
def initialize(client: WoodpeckerClient.new)
|
||||
@client = client
|
||||
end
|
||||
|
||||
def dashboard
|
||||
CiRepository.active.includes(:software).map do |repo|
|
||||
pipeline = begin
|
||||
@client.latest_pipeline(repo.woodpecker_repo_id)
|
||||
rescue WoodpeckerClient::ApiError
|
||||
nil
|
||||
end
|
||||
|
||||
if pipeline
|
||||
repo.update_columns(
|
||||
last_pipeline_status: pipeline["status"],
|
||||
last_pipeline_at: pipeline["created_at"]
|
||||
)
|
||||
end
|
||||
|
||||
{ repo: repo, pipeline: pipeline }
|
||||
end
|
||||
end
|
||||
|
||||
def trigger(repo, branch: "main")
|
||||
@client.trigger_pipeline(repo.woodpecker_repo_id, branch: branch)
|
||||
end
|
||||
|
||||
def list_pipelines(repo, page: 1)
|
||||
@client.list_pipelines(repo.woodpecker_repo_id, page: page)
|
||||
end
|
||||
|
||||
def pipeline_detail(repo, number)
|
||||
@client.get_pipeline(repo.woodpecker_repo_id, number)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,73 @@
|
||||
module WarpEngine
|
||||
class CiRepoSyncService
|
||||
def initialize(client: WoodpeckerClient.new)
|
||||
@client = client
|
||||
end
|
||||
|
||||
def sync_all
|
||||
remote_repos = @client.list_repos
|
||||
results = { created: [], updated: [], deactivated: [] }
|
||||
remote_ids = remote_repos.map { |r| r["id"] }
|
||||
|
||||
remote_repos.each do |remote|
|
||||
record = CiRepository.unscoped.find_or_initialize_by(
|
||||
woodpecker_repo_id: remote["id"]
|
||||
)
|
||||
|
||||
was_new = record.new_record?
|
||||
record.assign_attributes(
|
||||
repo_name: remote["name"],
|
||||
repo_owner: remote["owner"],
|
||||
active: remote["active"],
|
||||
deleted_at: nil
|
||||
)
|
||||
|
||||
if record.platform.blank? || record.platform == "unknown"
|
||||
sw = Software.find_by(name: remote["name"])
|
||||
record.platform = sw&.platform || "unknown"
|
||||
record.software = sw if sw
|
||||
end
|
||||
|
||||
next unless record.save
|
||||
|
||||
results[was_new ? :created : :updated] << record
|
||||
end
|
||||
|
||||
CiRepository.where.not(woodpecker_repo_id: remote_ids).find_each do |orphan|
|
||||
orphan.update!(active: false) if orphan.active?
|
||||
results[:deactivated] << orphan
|
||||
end
|
||||
|
||||
results
|
||||
end
|
||||
|
||||
def activate(repo_id)
|
||||
@client.activate_repo(repo_id)
|
||||
sync_single(repo_id)
|
||||
end
|
||||
|
||||
def deactivate(repo_id)
|
||||
@client.deactivate_repo(repo_id)
|
||||
record = CiRepository.find_by!(woodpecker_repo_id: repo_id)
|
||||
record.update!(active: false)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def sync_single(repo_id)
|
||||
remote = @client.get_repo(repo_id)
|
||||
record = CiRepository.unscoped.find_or_initialize_by(woodpecker_repo_id: repo_id)
|
||||
record.assign_attributes(
|
||||
repo_name: remote["name"], repo_owner: remote["owner"],
|
||||
active: remote["active"], deleted_at: nil
|
||||
)
|
||||
if record.platform.blank?
|
||||
sw = Software.find_by(name: remote["name"])
|
||||
record.platform = sw&.platform || "unknown"
|
||||
record.software = sw if sw
|
||||
end
|
||||
record.save!
|
||||
record
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,80 @@
|
||||
module WarpEngine
|
||||
class CiSecretSyncService
|
||||
SECRET_NAME = "application_token".freeze
|
||||
|
||||
def initialize(client: WoodpeckerClient.new)
|
||||
@client = client
|
||||
end
|
||||
|
||||
def provision(plain_token, repos:)
|
||||
results = { synced: [], failed: [] }
|
||||
|
||||
repos.each do |repo|
|
||||
if secret_exists?(repo.woodpecker_repo_id)
|
||||
@client.update_secret(repo.woodpecker_repo_id, SECRET_NAME, value: plain_token)
|
||||
else
|
||||
@client.create_secret(repo.woodpecker_repo_id, name: SECRET_NAME, value: plain_token)
|
||||
end
|
||||
results[:synced] << repo
|
||||
rescue WoodpeckerClient::ApiError, WoodpeckerClient::ConnectionError => e
|
||||
Rails.logger.error("[CiSecretSyncService] failed for #{repo.full_name}: #{e.message}")
|
||||
results[:failed] << { repo: repo, error: e.message }
|
||||
end
|
||||
|
||||
results
|
||||
end
|
||||
|
||||
def deprovision(application_token)
|
||||
repos_for_token(application_token).each do |repo|
|
||||
@client.delete_secret(repo.woodpecker_repo_id, SECRET_NAME)
|
||||
rescue WoodpeckerClient::ApiError => e
|
||||
Rails.logger.warn("[CiSecretSyncService] delete failed for #{repo.full_name}: #{e.message}")
|
||||
end
|
||||
end
|
||||
|
||||
def rotate(application_token)
|
||||
repos = repos_for_token(application_token)
|
||||
return { rotated: false, reason: "no repos" } if repos.empty?
|
||||
|
||||
new_token = ApplicationToken.create!(
|
||||
name: "#{application_token.name} (rotated #{Date.current})",
|
||||
owner_id: application_token.owner_id,
|
||||
owner_type: application_token.owner_type,
|
||||
scopes: application_token.scopes,
|
||||
expires_at: application_token.expires_at,
|
||||
unrestricted: application_token.unrestricted?
|
||||
)
|
||||
|
||||
result = provision(new_token.plain_token, repos: repos)
|
||||
|
||||
if result[:synced].any?
|
||||
application_token.revoke!
|
||||
{ rotated: true, new_token: new_token, sync_result: result }
|
||||
else
|
||||
new_token.revoke!
|
||||
{ rotated: false, reason: "all repos failed", sync_result: result }
|
||||
end
|
||||
end
|
||||
|
||||
def repos_for_token(application_token)
|
||||
if application_token.unrestricted?
|
||||
CiRepository.active.to_a
|
||||
else
|
||||
software_ids = Software.where(
|
||||
owner_type: application_token.owner_type,
|
||||
owner_id: application_token.owner_id
|
||||
).pluck(:id)
|
||||
CiRepository.active.where(software_id: software_ids).to_a
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def secret_exists?(repo_id)
|
||||
secrets = @client.list_secrets(repo_id)
|
||||
secrets.any? { |s| s["name"] == SECRET_NAME }
|
||||
rescue WoodpeckerClient::ApiError
|
||||
false
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,154 @@
|
||||
require "openssl"
|
||||
require "base64"
|
||||
require "net/http"
|
||||
require "digest"
|
||||
|
||||
module WarpEngine
|
||||
# Verifies the signature of Woodpecker configuration-extension requests.
|
||||
# Woodpecker 3.x signs with RFC 9421 HTTP message signatures (ed25519, via
|
||||
# yaronf/httpsign): Signature-Input + Signature + Content-Digest headers,
|
||||
# covered components "@request-target" and "content-digest". Older versions
|
||||
# used draft-cavage http-signatures (a single Signature header) — kept as a
|
||||
# fallback.
|
||||
class CiSignatureVerifier
|
||||
CAVAGE_PARAM = /(\w+)="([^"]*)"/
|
||||
|
||||
@key_cache = {}
|
||||
@key_mutex = Mutex.new
|
||||
|
||||
class << self
|
||||
# The downloaded key is cached process-wide (per URL).
|
||||
def fetch_public_key(url)
|
||||
@key_mutex.synchronize do
|
||||
@key_cache[url] ||= Net::HTTP.get(URI.parse(url))
|
||||
end
|
||||
end
|
||||
|
||||
def reset_key_cache!
|
||||
@key_mutex.synchronize { @key_cache = {} }
|
||||
end
|
||||
end
|
||||
|
||||
def initialize(request)
|
||||
@request = request
|
||||
end
|
||||
|
||||
def valid?
|
||||
pem = public_key_pem
|
||||
if pem.blank?
|
||||
Rails.logger.error("[CiSignatureVerifier] no ci_extension_public_key(_url) configured — rejecting request")
|
||||
return false
|
||||
end
|
||||
|
||||
key = OpenSSL::PKey.read(pem)
|
||||
if @request.headers["Signature-Input"].present?
|
||||
rfc9421_valid?(key)
|
||||
else
|
||||
cavage_valid?(key)
|
||||
end
|
||||
rescue OpenSSL::PKey::PKeyError, ArgumentError => e
|
||||
Rails.logger.error("[CiSignatureVerifier] #{e.class}: #{e.message}")
|
||||
false
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def public_key_pem
|
||||
config = WarpEngine.config
|
||||
return config.ci_extension_public_key if config.ci_extension_public_key.present?
|
||||
return nil if config.ci_extension_public_key_url.blank?
|
||||
|
||||
self.class.fetch_public_key(config.ci_extension_public_key_url)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[CiSignatureVerifier] public key fetch failed: #{e.class}: #{e.message}")
|
||||
nil
|
||||
end
|
||||
|
||||
# --- RFC 9421 ---
|
||||
|
||||
def rfc9421_valid?(key)
|
||||
input = @request.headers["Signature-Input"].to_s
|
||||
match = input.match(/\A\s*([\w.-]+)=(\(.*)\z/m)
|
||||
return false if match.nil?
|
||||
|
||||
label, inner = match[1], match[2]
|
||||
components = inner[/\((.*?)\)/m, 1].to_s.scan(/"([^"]*)"/).flatten
|
||||
return false if components.empty?
|
||||
|
||||
signature = @request.headers["Signature"].to_s[/#{Regexp.escape(label)}=:([A-Za-z0-9+\/=]+):/, 1]
|
||||
return false if signature.blank?
|
||||
return false unless content_digest_valid?(components)
|
||||
|
||||
lines = components.map do |component|
|
||||
value = component_value(component)
|
||||
return false if value.nil?
|
||||
%("#{component}": #{value})
|
||||
end
|
||||
lines << %("@signature-params": #{inner})
|
||||
|
||||
key.verify(nil, Base64.decode64(signature), lines.join("\n"))
|
||||
end
|
||||
|
||||
def component_value(name)
|
||||
case name
|
||||
when "@request-target" then @request.fullpath
|
||||
when "@method" then @request.request_method
|
||||
when "@target-uri" then @request.original_url
|
||||
when "@authority" then @request.host_with_port
|
||||
when "@path" then @request.path
|
||||
when "@query" then "?#{@request.query_string}"
|
||||
when /\A@/ then nil
|
||||
else @request.headers[name]
|
||||
end
|
||||
end
|
||||
|
||||
# When content-digest is a covered component, the body itself must match
|
||||
# the digest header — this is what ties the signature to the payload.
|
||||
def content_digest_valid?(components)
|
||||
return true unless components.include?("content-digest")
|
||||
|
||||
digest = @request.headers["Content-Digest"].to_s[/sha-256=:([A-Za-z0-9+\/=]+):/, 1]
|
||||
return false if digest.blank?
|
||||
|
||||
expected = Digest::SHA256.base64digest(@request.raw_post)
|
||||
ActiveSupport::SecurityUtils.secure_compare(digest, expected)
|
||||
end
|
||||
|
||||
# --- draft-cavage fallback ---
|
||||
|
||||
def cavage_valid?(key)
|
||||
params = cavage_params
|
||||
return false if params.nil? || params["signature"].blank?
|
||||
|
||||
signing_string = cavage_signing_string(params.fetch("headers", "date"))
|
||||
return false if signing_string.nil?
|
||||
|
||||
key.verify(nil, Base64.decode64(params["signature"]), signing_string)
|
||||
end
|
||||
|
||||
# Parameters of the Signature header (or the "Authorization: Signature ..." form).
|
||||
def cavage_params
|
||||
header = @request.headers["Signature"].presence
|
||||
if header.nil?
|
||||
auth = @request.headers["Authorization"].to_s
|
||||
header = auth.delete_prefix("Signature ") if auth.start_with?("Signature ")
|
||||
end
|
||||
return nil if header.blank?
|
||||
|
||||
header.scan(CAVAGE_PARAM).to_h
|
||||
end
|
||||
|
||||
def cavage_signing_string(headers_list)
|
||||
lines = headers_list.split(" ").map do |name|
|
||||
if name == "(request-target)"
|
||||
"(request-target): #{@request.request_method.downcase} #{@request.fullpath}"
|
||||
else
|
||||
value = @request.headers[name]
|
||||
return nil if value.nil?
|
||||
"#{name.downcase}: #{value}"
|
||||
end
|
||||
end
|
||||
lines.join("\n")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -22,10 +22,9 @@ module WarpEngine
|
||||
end
|
||||
end
|
||||
|
||||
MAX_UPLOAD_SIZE = 100 * 1024 * 1024 # 100MB
|
||||
|
||||
def upload(relative_dir, uploaded_file)
|
||||
raise ArgumentError, "File too large (max 100MB)" if uploaded_file.size > MAX_UPLOAD_SIZE
|
||||
max = WarpEngine.config.max_upload_size
|
||||
raise ArgumentError, "File too large (max #{max / (1024 * 1024)}MB)" if uploaded_file.size > max
|
||||
dir = safe_path!(relative_dir)
|
||||
raise ArgumentError, "Not a directory" unless dir.directory?
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
# Generated pipeline — WarpEngine /build/config (platform: bevy, name: <%= name %>)
|
||||
steps:
|
||||
- name: version
|
||||
image: alpine
|
||||
commands:
|
||||
- apk add --no-cache git jq
|
||||
- |
|
||||
if [ -f metadata.json ]; then
|
||||
VERSION=$(jq -r '.version' metadata.json)
|
||||
else
|
||||
VERSION=$(git rev-parse --short HEAD)
|
||||
fi
|
||||
BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
if [ "$BRANCH" != "main" ] && [ "$BRANCH" != "master" ]; then
|
||||
VERSION="dev-$VERSION-$BRANCH"
|
||||
fi
|
||||
echo "VERSION is: $VERSION"
|
||||
echo $VERSION > .version
|
||||
|
||||
- name: build
|
||||
image: <%= builder %>
|
||||
pull: true
|
||||
commands:
|
||||
- |
|
||||
VERSION=$(cat .version)
|
||||
mkdir -p dist
|
||||
cargo build --release --target wasm32-unknown-unknown
|
||||
wasm-bindgen --target web --no-typescript \
|
||||
--out-dir dist --out-name game target/wasm32-unknown-unknown/release/<%= name %>.wasm
|
||||
echo "==> Downloading index.html"
|
||||
curl -sSL https://git.teletypegames.org/tools/bevy-tools/raw/branch/master/web/index.html -o dist/index.html
|
||||
echo "==> Packaging HTML/WASM for $VERSION"
|
||||
zip -r "<%= name %>-$VERSION.html.zip" -j dist/game_bg.wasm dist/game.js dist/index.html
|
||||
echo "==> Cleaning temporary files"
|
||||
rm -f dist/game_bg.wasm dist/game.js dist/index.html
|
||||
|
||||
- name: binaries
|
||||
image: <%= builder %>
|
||||
pull: true
|
||||
commands:
|
||||
- |
|
||||
VERSION=$(cat .version)
|
||||
# Native binaries. linux-x64: glibc build in the debian-based builder
|
||||
# image; win-x64: mingw-w64 cross-compile (x86_64-pc-windows-gnu).
|
||||
# Mac needs osxcross, it is not built here.
|
||||
# The zip gets the assets/ dir too if the project has one — bevy loads
|
||||
# it at runtime, it is not embedded in the binary.
|
||||
set -e
|
||||
pack_binary() {
|
||||
P_SLUG="$1"; P_BIN="$2"; P_NAME="$3"
|
||||
PKG_DIR="<%= name %>-$VERSION-$P_SLUG"
|
||||
rm -rf "$PKG_DIR" "$PKG_DIR.zip"
|
||||
mkdir -p "$PKG_DIR"
|
||||
cp "$P_BIN" "$PKG_DIR/$P_NAME"
|
||||
chmod +x "$PKG_DIR/$P_NAME"
|
||||
if [ -d assets ]; then cp -r assets "$PKG_DIR/assets"; fi
|
||||
zip -qr "$PKG_DIR.zip" "$PKG_DIR"
|
||||
rm -rf "$PKG_DIR"
|
||||
echo "==> $PKG_DIR.zip kesz"
|
||||
}
|
||||
echo "==> Building linux-x64 binary"
|
||||
cargo build --release
|
||||
pack_binary "linux-x64" "target/release/<%= name %>" "<%= name %>"
|
||||
echo "==> Building win-x64 binary"
|
||||
CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER=x86_64-w64-mingw32-gcc \
|
||||
cargo build --release --target x86_64-pc-windows-gnu
|
||||
pack_binary "win-x64" "target/x86_64-pc-windows-gnu/release/<%= name %>.exe" "<%= name %>.exe"
|
||||
|
||||
- name: upload
|
||||
image: alpine
|
||||
environment:
|
||||
UPDATE_SERVER: <%= update_server %>
|
||||
UPDATE_SECRET:
|
||||
from_secret: application_token
|
||||
commands:
|
||||
- apk add --no-cache curl
|
||||
- |
|
||||
VERSION=$(cat .version)
|
||||
FILE="<%= name %>-$VERSION.html.zip"
|
||||
META_SRC="metadata.json"
|
||||
META_DST="<%= name %>-$VERSION.metadata.json"
|
||||
cp $META_SRC $META_DST
|
||||
BINS=""
|
||||
for slug in win-x64 linux-x64; do
|
||||
[ -f "<%= name %>-$VERSION-$slug.zip" ] && BINS="$BINS <%= name %>-$VERSION-$slug.zip"
|
||||
done
|
||||
for f in $FILE $META_DST $BINS; do
|
||||
curl -fsS -H "X-Update-Secret: $UPDATE_SECRET" \
|
||||
-F "file=@$f" \
|
||||
"$UPDATE_SERVER/build/upload?name=<%= name %>&version=$VERSION" || exit 1
|
||||
done
|
||||
|
||||
- name: publish
|
||||
image: alpine
|
||||
environment:
|
||||
UPDATE_SERVER: <%= update_server %>
|
||||
UPDATE_SECRET:
|
||||
from_secret: application_token
|
||||
commands:
|
||||
- apk add --no-cache curl
|
||||
- |
|
||||
VERSION=$(cat .version)
|
||||
curl -fsS -X POST -H "X-Update-Secret: $UPDATE_SECRET" "$UPDATE_SERVER/build/publish?name=<%= name %>&platform=bevy&version=$VERSION"
|
||||
@@ -0,0 +1,14 @@
|
||||
module WarpEngine
|
||||
module Platforms
|
||||
module Bevy
|
||||
class Service
|
||||
include Updatable
|
||||
include Builds::BuildWeb
|
||||
include Builds::BuildWinX64
|
||||
include Builds::BuildLinuxX64
|
||||
|
||||
label "Bevy"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,59 @@
|
||||
# Generated pipeline — WarpEngine /build/config (platform: c64, name: <%= name %>)
|
||||
steps:
|
||||
- name: version
|
||||
image: alpine
|
||||
commands:
|
||||
- |
|
||||
VERSION=$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' metadata.json | head -n 1)
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo "ERROR: no \"version\" field in metadata.json!"
|
||||
exit 1
|
||||
fi
|
||||
BRANCH=${CI_COMMIT_BRANCH:-${WOODPECKER_BRANCH}}
|
||||
BRANCH=$(echo "$BRANCH" | tr '/' '-')
|
||||
if [ "$BRANCH" != "main" ] && [ "$BRANCH" != "master" ] && [ -n "$BRANCH" ]; then
|
||||
VERSION="dev-$VERSION-$BRANCH"
|
||||
fi
|
||||
echo "VERSION is: $VERSION"
|
||||
echo $VERSION > .version
|
||||
|
||||
- name: build
|
||||
image: <%= builder %>
|
||||
commands:
|
||||
- |
|
||||
VERSION=$(cat .version)
|
||||
acme -f cbm -o <%= name %>.prg main.asm
|
||||
echo "==> Creating versioned files for $VERSION"
|
||||
cp <%= name %>.prg <%= name %>-$VERSION.prg
|
||||
cp metadata.json <%= name %>-$VERSION.metadata.json
|
||||
ls -lh <%= name %>-$VERSION.*
|
||||
|
||||
- name: artifact
|
||||
image: alpine
|
||||
environment:
|
||||
UPDATE_SERVER: <%= update_server %>
|
||||
UPDATE_SECRET:
|
||||
from_secret: application_token
|
||||
commands:
|
||||
- apk add --no-cache curl
|
||||
- |
|
||||
VERSION=$(cat .version)
|
||||
echo "==> Uploading artifacts for version $VERSION"
|
||||
for f in <%= name %>-$VERSION.prg <%= name %>-$VERSION.metadata.json; do
|
||||
curl -fsS -H "X-Update-Secret: $UPDATE_SECRET" \
|
||||
-F "file=@$f" \
|
||||
"$UPDATE_SERVER/build/upload?name=<%= name %>&version=$VERSION" || exit 1
|
||||
done
|
||||
|
||||
- name: publish
|
||||
image: alpine
|
||||
environment:
|
||||
UPDATE_SERVER: <%= update_server %>
|
||||
UPDATE_SECRET:
|
||||
from_secret: application_token
|
||||
commands:
|
||||
- apk add --no-cache curl
|
||||
- |
|
||||
VERSION=$(cat .version)
|
||||
echo "==> Publishing version $VERSION"
|
||||
curl -fsS -X POST -H "X-Update-Secret: $UPDATE_SECRET" "$UPDATE_SERVER/build/publish?name=<%= name %>&platform=c64&version=$VERSION"
|
||||
@@ -0,0 +1,14 @@
|
||||
module WarpEngine
|
||||
module Platforms
|
||||
module C64
|
||||
class Service
|
||||
include Updatable
|
||||
include Builds::BuildCartridge
|
||||
|
||||
label "C64"
|
||||
|
||||
def cartridge_ext = ".prg"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,95 @@
|
||||
# Generated pipeline — WarpEngine /build/config (platform: ebitengine, name: <%= name %>)
|
||||
steps:
|
||||
- name: version
|
||||
image: alpine
|
||||
commands:
|
||||
- apk add --no-cache git jq
|
||||
- |
|
||||
if [ -f metadata.json ]; then
|
||||
VERSION=$(jq -r '.version' metadata.json)
|
||||
else
|
||||
VERSION=$(git rev-parse --short HEAD)
|
||||
fi
|
||||
BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
if [ "$BRANCH" != "main" ] && [ "$BRANCH" != "master" ]; then
|
||||
VERSION="dev-$VERSION-$BRANCH"
|
||||
fi
|
||||
echo "VERSION is: $VERSION"
|
||||
echo $VERSION > .version
|
||||
|
||||
- name: build
|
||||
image: <%= builder %>
|
||||
pull: true
|
||||
commands:
|
||||
- |
|
||||
VERSION=$(cat .version)
|
||||
mkdir -p dist
|
||||
GOOS=js GOARCH=wasm go build -o dist/game.wasm .
|
||||
cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" dist/wasm_exec.js
|
||||
echo "==> Downloading index.html"
|
||||
curl -sSL https://git.teletypegames.org/tools/ebitengine-tools/raw/branch/master/web/index.html -o dist/index.html
|
||||
echo "==> Packaging HTML/WASM for $VERSION"
|
||||
zip -r "<%= name %>-$VERSION.html.zip" -j dist/game.wasm dist/wasm_exec.js dist/index.html
|
||||
echo "==> Cleaning temporary files"
|
||||
rm -f dist/game.wasm dist/wasm_exec.js dist/index.html
|
||||
|
||||
- name: binaries
|
||||
image: <%= builder %>
|
||||
pull: true
|
||||
commands:
|
||||
- |
|
||||
VERSION=$(cat .version)
|
||||
# win-x86 / win-x64: pure Go cross-compile (Windowson nem kell cgo)
|
||||
# linux-x64: cgo build, linux/amd64 hoston fut (builder image, X11/GL dev libekkel)
|
||||
# helper: builds one target + zips it with a single root folder
|
||||
# (unix zip keeps the executable bit)
|
||||
binary_build() {
|
||||
B_GOOS="$1"; B_GOARCH="$2"; B_CGO="$3"; B_EXT="$4"; B_TARGET="$5"
|
||||
PKG_DIR="<%= name %>-$VERSION-$B_TARGET"
|
||||
echo "==> Building $PKG_DIR"
|
||||
rm -rf "$PKG_DIR" "$PKG_DIR.zip"
|
||||
mkdir -p "$PKG_DIR"
|
||||
CGO_ENABLED=$B_CGO GOOS=$B_GOOS GOARCH=$B_GOARCH go build -o "$PKG_DIR/<%= name %>$B_EXT" .
|
||||
if [ -f LICENSE ]; then cp LICENSE "$PKG_DIR/"; fi
|
||||
if [ -f README.md ]; then cp README.md "$PKG_DIR/"; fi
|
||||
zip -r "$PKG_DIR.zip" "$PKG_DIR" >/dev/null
|
||||
rm -rf "$PKG_DIR"
|
||||
echo "==> $PKG_DIR.zip kesz"
|
||||
}
|
||||
# CI (linux builder) builds these three:
|
||||
binary_build "windows" "386" "0" ".exe" "win-x86"
|
||||
binary_build "windows" "amd64" "0" ".exe" "win-x64"
|
||||
binary_build "linux" "amd64" "1" "" "linux-x64"
|
||||
|
||||
- name: artifact
|
||||
image: alpine
|
||||
environment:
|
||||
UPDATE_SERVER: <%= update_server %>
|
||||
UPDATE_SECRET:
|
||||
from_secret: application_token
|
||||
commands:
|
||||
- apk add --no-cache curl
|
||||
- |
|
||||
VERSION=$(cat .version)
|
||||
FILE="<%= name %>-$VERSION.html.zip"
|
||||
META_SRC="metadata.json"
|
||||
META_DST="<%= name %>-$VERSION.metadata.json"
|
||||
BINS=$(ls <%= name %>-$VERSION-*.zip 2>/dev/null || true)
|
||||
cp $META_SRC $META_DST
|
||||
for f in $FILE $META_DST $BINS; do
|
||||
curl -fsS -H "X-Update-Secret: $UPDATE_SECRET" \
|
||||
-F "file=@$f" \
|
||||
"$UPDATE_SERVER/build/upload?name=<%= name %>&version=$VERSION" || exit 1
|
||||
done
|
||||
|
||||
- name: publish
|
||||
image: alpine
|
||||
environment:
|
||||
UPDATE_SERVER: <%= update_server %>
|
||||
UPDATE_SECRET:
|
||||
from_secret: application_token
|
||||
commands:
|
||||
- apk add --no-cache curl
|
||||
- |
|
||||
VERSION=$(cat .version)
|
||||
curl -fsS -X POST -H "X-Update-Secret: $UPDATE_SECRET" "$UPDATE_SERVER/build/publish?name=<%= name %>&platform=ebitengine&version=$VERSION"
|
||||
@@ -0,0 +1,17 @@
|
||||
module WarpEngine
|
||||
module Platforms
|
||||
module Ebitengine
|
||||
class Service
|
||||
include Updatable
|
||||
include Builds::BuildWeb
|
||||
include Builds::BuildWinX86
|
||||
include Builds::BuildWinX64
|
||||
include Builds::BuildLinuxX64
|
||||
include Builds::BuildMacX64
|
||||
include Builds::BuildMacArm64
|
||||
|
||||
label "Ebitengine"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,100 @@
|
||||
# Generated pipeline — WarpEngine /build/config (platform: godot, name: <%= name %>)
|
||||
steps:
|
||||
- name: version
|
||||
image: alpine
|
||||
commands:
|
||||
- apk add --no-cache git jq
|
||||
- |
|
||||
if [ -f metadata.json ]; then
|
||||
VERSION=$(jq -r '.version' metadata.json)
|
||||
else
|
||||
VERSION=$(git rev-parse --short HEAD)
|
||||
fi
|
||||
BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
if [ "$BRANCH" != "main" ] && [ "$BRANCH" != "master" ]; then
|
||||
VERSION="dev-$VERSION-$BRANCH"
|
||||
fi
|
||||
echo "VERSION is: $VERSION"
|
||||
echo $VERSION > .version
|
||||
|
||||
- name: build
|
||||
image: <%= builder %>
|
||||
commands:
|
||||
- |
|
||||
VERSION=$(cat .version)
|
||||
echo "==> Importing project"
|
||||
godot --headless --import
|
||||
echo "==> Exporting web build (Web preset)"
|
||||
mkdir -p dist/web
|
||||
godot --headless --export-release "Web" dist/web/index.html
|
||||
echo "==> Packaging web build for $VERSION"
|
||||
(cd dist/web && zip -r "../../<%= name %>-$VERSION.html.zip" .)
|
||||
rm -rf dist/web
|
||||
- |
|
||||
VERSION=$(cat .version)
|
||||
# exports a win/linux target + zips it with a single root folder
|
||||
# (embed_pck makes the export a single executable)
|
||||
binary_build() {
|
||||
B_PRESET="$1"; B_EXT="$2"; B_TARGET="$3"
|
||||
PKG_DIR="<%= name %>-$VERSION-$B_TARGET"
|
||||
echo "==> Building $PKG_DIR"
|
||||
rm -rf "$PKG_DIR" "$PKG_DIR.zip"
|
||||
mkdir -p "$PKG_DIR"
|
||||
godot --headless --export-release "$B_PRESET" "$(pwd)/$PKG_DIR/<%= name %>$B_EXT"
|
||||
if [ -f LICENSE ]; then cp LICENSE "$PKG_DIR/"; fi
|
||||
if [ -f README.md ]; then cp README.md "$PKG_DIR/"; fi
|
||||
zip -r "$PKG_DIR.zip" "$PKG_DIR" >/dev/null
|
||||
rm -rf "$PKG_DIR"
|
||||
echo "==> $PKG_DIR.zip kesz"
|
||||
}
|
||||
# mac: from linux Godot can only export macOS into a .zip (holding the
|
||||
# .app); repackage it to the root-folder convention (zip -ry keeps
|
||||
# exec bits and symlinks)
|
||||
binary_build_mac() {
|
||||
B_PRESET="$1"; B_TARGET="$2"
|
||||
PKG_DIR="<%= name %>-$VERSION-$B_TARGET"
|
||||
echo "==> Building $PKG_DIR"
|
||||
rm -rf "$PKG_DIR" "$PKG_DIR.zip"
|
||||
mkdir -p "$PKG_DIR"
|
||||
godot --headless --export-release "$B_PRESET" "$(pwd)/$PKG_DIR/<%= name %>-mac-tmp.zip"
|
||||
(cd "$PKG_DIR" && unzip -q "<%= name %>-mac-tmp.zip" && rm "<%= name %>-mac-tmp.zip")
|
||||
if [ -f LICENSE ]; then cp LICENSE "$PKG_DIR/"; fi
|
||||
if [ -f README.md ]; then cp README.md "$PKG_DIR/"; fi
|
||||
zip -ry "$PKG_DIR.zip" "$PKG_DIR" >/dev/null
|
||||
rm -rf "$PKG_DIR"
|
||||
echo "==> $PKG_DIR.zip kesz"
|
||||
}
|
||||
binary_build "Windows x86" ".exe" "win-x86"
|
||||
binary_build "Windows x64" ".exe" "win-x64"
|
||||
binary_build "Linux x64" "" "linux-x64"
|
||||
binary_build_mac "Mac universal" "mac-universal"
|
||||
|
||||
- name: upload
|
||||
image: alpine
|
||||
environment:
|
||||
UPDATE_SERVER: <%= update_server %>
|
||||
UPDATE_SECRET:
|
||||
from_secret: application_token
|
||||
commands:
|
||||
- apk add --no-cache curl
|
||||
- |
|
||||
VERSION=$(cat .version)
|
||||
cp metadata.json "<%= name %>-$VERSION.metadata.json"
|
||||
BINS=$(ls <%= name %>-$VERSION-*.zip 2>/dev/null || true)
|
||||
for f in "<%= name %>-$VERSION.html.zip" "<%= name %>-$VERSION.metadata.json" $BINS; do
|
||||
curl -fsS -H "X-Update-Secret: $UPDATE_SECRET" \
|
||||
-F "file=@$f" \
|
||||
"$UPDATE_SERVER/build/upload?name=<%= name %>&version=$VERSION" || exit 1
|
||||
done
|
||||
|
||||
- name: publish
|
||||
image: alpine
|
||||
environment:
|
||||
UPDATE_SERVER: <%= update_server %>
|
||||
UPDATE_SECRET:
|
||||
from_secret: application_token
|
||||
commands:
|
||||
- apk add --no-cache curl
|
||||
- |
|
||||
VERSION=$(cat .version)
|
||||
curl -fsS -X POST -H "X-Update-Secret: $UPDATE_SECRET" "$UPDATE_SERVER/build/publish?name=<%= name %>&platform=godot&version=$VERSION"
|
||||
@@ -0,0 +1,16 @@
|
||||
module WarpEngine
|
||||
module Platforms
|
||||
module Godot
|
||||
class Service
|
||||
include Updatable
|
||||
include Builds::BuildWeb
|
||||
include Builds::BuildWinX86
|
||||
include Builds::BuildWinX64
|
||||
include Builds::BuildLinuxX64
|
||||
include Builds::BuildMacUniversal
|
||||
|
||||
label "Godot"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,174 @@
|
||||
# Generated pipeline — WarpEngine /build/config (platform: love, name: <%= name %>)
|
||||
steps:
|
||||
- name: version
|
||||
image: alpine
|
||||
commands:
|
||||
- apk add --no-cache git jq
|
||||
- |
|
||||
if [ -f metadata.json ]; then
|
||||
VERSION=$(jq -r '.version' metadata.json)
|
||||
else
|
||||
VERSION=$(git rev-parse --short HEAD)
|
||||
fi
|
||||
BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
if [ "$BRANCH" != "main" ] && [ "$BRANCH" != "master" ]; then
|
||||
VERSION="dev-$VERSION-$BRANCH"
|
||||
fi
|
||||
echo "VERSION is: $VERSION"
|
||||
echo $VERSION > .version
|
||||
|
||||
- name: export
|
||||
image: <%= builder %>
|
||||
pull: true
|
||||
commands:
|
||||
- |
|
||||
VERSION=$(cat .version)
|
||||
mkdir -p dist
|
||||
echo "==> Building .love package"
|
||||
zip -r dist/<%= name %>.love . \
|
||||
--exclude "*.git*" \
|
||||
--exclude "bin/*" \
|
||||
--exclude "dist/*" \
|
||||
--exclude "Makefile" \
|
||||
--exclude ".version" \
|
||||
--exclude "metadata.json" \
|
||||
--exclude "*.zip"
|
||||
mkdir -p dist/web
|
||||
# The love-builder CI image pre-fetches love.js here; local builds
|
||||
# fall back to GitHub.
|
||||
if [ -f /opt/lovejs.zip ]; then
|
||||
echo "==> Using cached love.js (/opt/lovejs.zip)"
|
||||
cp /opt/lovejs.zip dist/lovejs.zip
|
||||
else
|
||||
echo "==> Downloading love.js (2dengine)"
|
||||
curl -sSL https://github.com/2dengine/love.js/archive/refs/heads/master.zip -o dist/lovejs.zip
|
||||
fi
|
||||
unzip -o dist/lovejs.zip -d dist/lovejs-src
|
||||
rm -f dist/lovejs.zip
|
||||
echo "==> Assembling web bundle"
|
||||
cp -r dist/lovejs-src/*/. dist/web/
|
||||
rm -rf dist/lovejs-src
|
||||
cp dist/<%= name %>.love dist/web/<%= name %>.love
|
||||
echo "==> Patching player.js"
|
||||
sed -i.bak "s|uri = 'nogame\.love'|uri = '<%= name %>.love'|g" dist/web/player.js && rm dist/web/player.js.bak
|
||||
echo "==> Patching index.html"
|
||||
sed -i.bak 's|<base href="/play/">|<base href="/file/<%= name %>-'"$VERSION"'/">|g' dist/web/index.html && rm dist/web/index.html.bak
|
||||
echo "==> Web build ready in dist/web"
|
||||
echo "==> Packaging Love2D for $VERSION"
|
||||
zip -r <%= name %>-$VERSION.love.zip dist/<%= name %>.love
|
||||
echo "==> Packaging web build for $VERSION"
|
||||
(cd dist/web && zip -r ../../<%= name %>-$VERSION.html.zip .)
|
||||
echo "==> Cleaning temporary files"
|
||||
rm -f dist/<%= name %>.love
|
||||
rm -rf dist/web
|
||||
|
||||
- name: binaries
|
||||
image: <%= builder %>
|
||||
pull: true
|
||||
commands:
|
||||
- |
|
||||
VERSION=$(cat .version)
|
||||
# The export step deleted the .love, rebuild it here (in make the
|
||||
# binary-* targets' love prerequisite did the same).
|
||||
mkdir -p dist
|
||||
zip -r dist/<%= name %>.love . \
|
||||
--exclude "*.git*" \
|
||||
--exclude "bin/*" \
|
||||
--exclude "dist/*" \
|
||||
--exclude "Makefile" \
|
||||
--exclude ".version" \
|
||||
--exclude "metadata.json" \
|
||||
--exclude "*.zip"
|
||||
# The love-builder CI image pre-fetches the dist files to
|
||||
# /opt/love-dist; local builds fall back to GitHub.
|
||||
fetch_love() {
|
||||
if [ -f "/opt/love-dist/$1" ]; then
|
||||
echo "==> Using cached $1"
|
||||
cp "/opt/love-dist/$1" "dist/$1"
|
||||
elif [ ! -f "dist/$1" ]; then
|
||||
echo "==> Downloading $1"
|
||||
curl -sSL "https://github.com/love2d/love/releases/download/11.5/$1" -o "dist/$1"
|
||||
fi
|
||||
}
|
||||
echo "==> Fusing windows binary"
|
||||
fetch_love love-11.5-win64.zip
|
||||
PKG_DIR="<%= name %>-$VERSION-win-x64"
|
||||
rm -rf "$PKG_DIR" "$PKG_DIR.zip" dist/win64
|
||||
unzip -q dist/love-11.5-win64.zip -d dist/win64
|
||||
SRC=$(dirname $(find dist/win64 -name love.exe | head -n 1))
|
||||
mkdir -p "$PKG_DIR"
|
||||
cat "$SRC/love.exe" dist/<%= name %>.love > "$PKG_DIR/<%= name %>.exe"
|
||||
cp "$SRC"/*.dll "$PKG_DIR/"
|
||||
cp "$SRC/license.txt" "$PKG_DIR/" 2>/dev/null || true
|
||||
zip -qr "$PKG_DIR.zip" "$PKG_DIR"
|
||||
rm -rf "$PKG_DIR" dist/win64
|
||||
echo "==> $PKG_DIR.zip kesz"
|
||||
echo "==> Fusing macOS app bundle"
|
||||
fetch_love love-11.5-macos.zip
|
||||
PKG_DIR="<%= name %>-$VERSION-mac-universal"
|
||||
rm -rf "$PKG_DIR" "$PKG_DIR.zip" dist/macos
|
||||
unzip -q dist/love-11.5-macos.zip -d dist/macos
|
||||
mkdir -p "$PKG_DIR"
|
||||
mv dist/macos/love.app "$PKG_DIR/<%= name %>.app"
|
||||
cp dist/<%= name %>.love "$PKG_DIR/<%= name %>.app/Contents/Resources/"
|
||||
PLIST="$PKG_DIR/<%= name %>.app/Contents/Info.plist"
|
||||
sed -i.bak "s|<string>LÖVE</string>|<string><%= name %></string>|g" "$PLIST" && rm "$PLIST.bak"
|
||||
sed -i.bak "s|org\.love2d\.love|org.teletypegames.<%= name %>|g" "$PLIST" && rm "$PLIST.bak"
|
||||
zip -qry "$PKG_DIR.zip" "$PKG_DIR"
|
||||
rm -rf "$PKG_DIR" dist/macos
|
||||
echo "==> $PKG_DIR.zip kesz"
|
||||
# The AppImage runtime is glibc-dynamic and cannot run on alpine
|
||||
# (musl), so we do not run the runtime: the offset is computed from
|
||||
# readelf (shoff + shentsize*shnum) and the squashfs is extracted
|
||||
# with unsquashfs -o.
|
||||
echo "==> Fusing linux AppImage"
|
||||
fetch_love love-11.5-x86_64.AppImage
|
||||
PKG_DIR="<%= name %>-$VERSION-linux-x64"
|
||||
APPIMAGE="dist/love-11.5-x86_64.AppImage"
|
||||
rm -rf "$PKG_DIR" "$PKG_DIR.zip" squashfs-root dist/game.squashfs dist/runtime
|
||||
OFFSET=$(readelf -h "$APPIMAGE" | awk '/Start of section headers/{o=$5} /Size of section headers/{s=$5} /Number of section headers/{n=$5} END{print o+s*n}')
|
||||
unsquashfs -q -o $OFFSET -d squashfs-root "$APPIMAGE" >/dev/null
|
||||
cat squashfs-root/bin/love dist/<%= name %>.love > squashfs-root/bin/love.fused
|
||||
mv squashfs-root/bin/love.fused squashfs-root/bin/love
|
||||
chmod +x squashfs-root/bin/love
|
||||
mksquashfs squashfs-root dist/game.squashfs -root-owned -noappend -quiet -comp gzip
|
||||
head -c $OFFSET "$APPIMAGE" > dist/runtime
|
||||
mkdir -p "$PKG_DIR"
|
||||
cat dist/runtime dist/game.squashfs > "$PKG_DIR/<%= name %>.AppImage"
|
||||
chmod +x "$PKG_DIR/<%= name %>.AppImage"
|
||||
zip -qr "$PKG_DIR.zip" "$PKG_DIR"
|
||||
rm -rf "$PKG_DIR" squashfs-root dist/game.squashfs dist/runtime
|
||||
echo "==> $PKG_DIR.zip kesz"
|
||||
|
||||
- name: upload
|
||||
image: alpine
|
||||
environment:
|
||||
UPDATE_SERVER: <%= update_server %>
|
||||
UPDATE_SECRET:
|
||||
from_secret: application_token
|
||||
commands:
|
||||
- apk add --no-cache curl
|
||||
- |
|
||||
VERSION=$(cat .version)
|
||||
cp metadata.json "<%= name %>-$VERSION.metadata.json"
|
||||
BINS=""
|
||||
for slug in win-x64 mac-universal linux-x64; do
|
||||
[ -f "<%= name %>-$VERSION-$slug.zip" ] && BINS="$BINS <%= name %>-$VERSION-$slug.zip"
|
||||
done
|
||||
for f in "<%= name %>-$VERSION.love.zip" "<%= name %>-$VERSION.html.zip" "<%= name %>-$VERSION.metadata.json" $BINS; do
|
||||
curl -fsS -H "X-Update-Secret: $UPDATE_SECRET" \
|
||||
-F "file=@$f" \
|
||||
"$UPDATE_SERVER/build/upload?name=<%= name %>&version=$VERSION" || exit 1
|
||||
done
|
||||
|
||||
- name: publish
|
||||
image: alpine
|
||||
environment:
|
||||
UPDATE_SERVER: <%= update_server %>
|
||||
UPDATE_SECRET:
|
||||
from_secret: application_token
|
||||
commands:
|
||||
- apk add --no-cache curl
|
||||
- |
|
||||
VERSION=$(cat .version)
|
||||
curl -fsS -X POST -H "X-Update-Secret: $UPDATE_SECRET" "$UPDATE_SERVER/build/publish?name=<%= name %>&platform=love&version=$VERSION"
|
||||
@@ -0,0 +1,15 @@
|
||||
module WarpEngine
|
||||
module Platforms
|
||||
module Love
|
||||
class Service
|
||||
include Updatable
|
||||
include Builds::BuildWeb
|
||||
include Builds::BuildWinX64
|
||||
include Builds::BuildLinuxX64
|
||||
include Builds::BuildMacUniversal
|
||||
|
||||
label "LÖVE"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,69 @@
|
||||
# Generated pipeline — WarpEngine /build/config (platform: phaser, name: <%= name %>)
|
||||
steps:
|
||||
- name: version
|
||||
image: alpine
|
||||
commands:
|
||||
- apk add --no-cache git jq
|
||||
- |
|
||||
if [ -f metadata.json ]; then
|
||||
VERSION=$(jq -r '.version' metadata.json)
|
||||
else
|
||||
VERSION=$(git rev-parse --short HEAD)
|
||||
fi
|
||||
BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
if [ "$BRANCH" != "main" ] && [ "$BRANCH" != "master" ]; then
|
||||
VERSION="dev-$VERSION-$BRANCH"
|
||||
fi
|
||||
echo "VERSION is: $VERSION"
|
||||
echo $VERSION > .version
|
||||
|
||||
- name: build
|
||||
image: <%= builder %>
|
||||
commands:
|
||||
- |
|
||||
VERSION=$(cat .version)
|
||||
echo "==> Checking JS syntax"
|
||||
for f in src/*.js; do node --check $f; done
|
||||
mkdir -p dist/web
|
||||
echo "==> Downloading Phaser 3.90.0"
|
||||
curl -sSL https://cdn.jsdelivr.net/npm/phaser@3.90.0/dist/phaser.min.js -o dist/web/phaser.min.js
|
||||
echo "==> Downloading index.html"
|
||||
curl -sSL https://git.teletypegames.org/tools/phaser-tools/raw/branch/master/web/index.html -o dist/web/index.html
|
||||
echo "==> Bundling game sources"
|
||||
cat src/*.js > dist/web/game.js
|
||||
echo "==> Packaging web build for $VERSION"
|
||||
(cd dist/web && zip -r "../../<%= name %>-$VERSION.html.zip" .)
|
||||
echo "==> Cleaning temporary files"
|
||||
rm -rf dist/web
|
||||
|
||||
- name: upload
|
||||
image: alpine
|
||||
environment:
|
||||
UPDATE_SERVER: <%= update_server %>
|
||||
UPDATE_SECRET:
|
||||
from_secret: application_token
|
||||
commands:
|
||||
- apk add --no-cache curl
|
||||
- |
|
||||
VERSION=$(cat .version)
|
||||
FILE="<%= name %>-$VERSION.html.zip"
|
||||
META_SRC="metadata.json"
|
||||
META_DST="<%= name %>-$VERSION.metadata.json"
|
||||
cp $META_SRC $META_DST
|
||||
for f in $FILE $META_DST; do
|
||||
curl -fsS -H "X-Update-Secret: $UPDATE_SECRET" \
|
||||
-F "file=@$f" \
|
||||
"$UPDATE_SERVER/build/upload?name=<%= name %>&version=$VERSION" || exit 1
|
||||
done
|
||||
|
||||
- name: publish
|
||||
image: alpine
|
||||
environment:
|
||||
UPDATE_SERVER: <%= update_server %>
|
||||
UPDATE_SECRET:
|
||||
from_secret: application_token
|
||||
commands:
|
||||
- apk add --no-cache curl
|
||||
- |
|
||||
VERSION=$(cat .version)
|
||||
curl -fsS -X POST -H "X-Update-Secret: $UPDATE_SECRET" "$UPDATE_SERVER/build/publish?name=<%= name %>&platform=phaser&version=$VERSION"
|
||||
@@ -0,0 +1,12 @@
|
||||
module WarpEngine
|
||||
module Platforms
|
||||
module Phaser
|
||||
class Service
|
||||
include Updatable
|
||||
include Builds::BuildWeb
|
||||
|
||||
label "Phaser"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,199 @@
|
||||
# Generated pipeline — WarpEngine /build/config (platform: tic80, name: <%= name %>)
|
||||
# The version comes from the source (inc/meta/meta.header.lua "-- version:"
|
||||
# comment) — WarpEngine parses tic80 metadata from the Lua header too, hence
|
||||
# no metadata.json.
|
||||
steps:
|
||||
- name: version
|
||||
image: alpine
|
||||
commands:
|
||||
- |
|
||||
VERSION=$(sed -n "s/^-- version: //p" inc/meta/meta.header.lua | head -n 1 | tr -d "[:space:]")
|
||||
BRANCH=${CI_COMMIT_BRANCH:-${WOODPECKER_BRANCH}}
|
||||
BRANCH=$(echo "$BRANCH" | tr '/' '-')
|
||||
if [ "$BRANCH" != "main" ] && [ "$BRANCH" != "master" ] && [ -n "$BRANCH" ]; then
|
||||
VERSION=dev-$VERSION-$BRANCH
|
||||
fi
|
||||
echo "VERSION is: $VERSION"
|
||||
echo $VERSION > .version
|
||||
|
||||
- name: lint
|
||||
image: alpine
|
||||
commands:
|
||||
- apk add --no-cache lua5.4 lua5.4-dev luarocks gcc musl-dev
|
||||
- ln -sf /usr/bin/lua5.4 /usr/bin/lua
|
||||
- ln -sf /usr/bin/luarocks-5.4 /usr/bin/luarocks
|
||||
- luarocks install luacheck
|
||||
- |
|
||||
echo "==> Merging..."
|
||||
rm -f /tmp/_lint_combined.lua /tmp/_lint_map.txt
|
||||
touch /tmp/_lint_combined.lua
|
||||
line=1
|
||||
while IFS= read -r f || [ -n "$f" ]; do
|
||||
f=$(printf '%s' "$f" | tr -d '\r')
|
||||
[ -z "$f" ] && continue
|
||||
before=$(wc -l < /tmp/_lint_combined.lua)
|
||||
cat "inc/$f" >> /tmp/_lint_combined.lua
|
||||
printf '\n' >> /tmp/_lint_combined.lua
|
||||
after=$(wc -l < /tmp/_lint_combined.lua)
|
||||
linecount=$((after - before))
|
||||
echo "$line $linecount inc/$f" >> /tmp/_lint_map.txt
|
||||
line=$((line + linecount))
|
||||
done < <%= name %>.inc
|
||||
echo "==> luacheck..."
|
||||
LINT_OUTPUT=$(luacheck --no-max-line-length /tmp/_lint_combined.lua 2>&1 | awk -v map=/tmp/_lint_map.txt '
|
||||
BEGIN {
|
||||
NR_map = 0;
|
||||
while ((getline line < map) > 0) {
|
||||
n = split(line, a, " ");
|
||||
start[NR_map] = a[1]+0;
|
||||
count[NR_map] = a[2]+0;
|
||||
fname[NR_map] = a[3];
|
||||
NR_map++;
|
||||
}
|
||||
}
|
||||
/^[^:]+:[0-9]+:[0-9]+:/ {
|
||||
colon1 = index($0, ":");
|
||||
rest1 = substr($0, colon1+1);
|
||||
colon2 = index(rest1, ":");
|
||||
absline = substr(rest1, 1, colon2-1) + 0;
|
||||
rest2 = substr(rest1, colon2+1);
|
||||
colon3 = index(rest2, ":");
|
||||
col = substr(rest2, 1, colon3-1);
|
||||
rest = substr(rest2, colon3);
|
||||
found = 0;
|
||||
for (i = 0; i < NR_map; i++) {
|
||||
end_line = start[i] + count[i] -1;
|
||||
if (absline >= start[i] && absline <= end_line) {
|
||||
relline = absline - start[i] + 1;
|
||||
print fname[i] ":" relline ":" col ":" rest;
|
||||
found = 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) print $0;
|
||||
next;
|
||||
}
|
||||
{ print }
|
||||
')
|
||||
echo "$LINT_OUTPUT"
|
||||
NUM_ISSUES=$(echo "$LINT_OUTPUT" | grep -cE "^[^:]+:[0-9]+:[0-9]+:" || true)
|
||||
if [ "$NUM_ISSUES" -gt 0 ]; then
|
||||
echo "Total: $NUM_ISSUES issue(s) found, commit aborted."
|
||||
exit 1
|
||||
else
|
||||
echo "Checking /tmp/_lint_combined.lua OK"
|
||||
echo "Total: 0 warnings / 0 errors in 1 file"
|
||||
fi
|
||||
rm -f /tmp/_lint_combined.lua /tmp/_lint_map.txt
|
||||
|
||||
- name: minify
|
||||
image: alpine
|
||||
commands:
|
||||
- apk add --no-cache lua5.4 curl
|
||||
- ln -sf /usr/bin/lua5.4 /usr/bin/lua
|
||||
- |
|
||||
rm -f <%= name %>.lua
|
||||
sed 's/\r$//' <%= name %>.inc | while read f; do
|
||||
cat "inc/$f" >> <%= name %>.lua
|
||||
echo "" >> <%= name %>.lua
|
||||
done
|
||||
test -f minify.lua || { echo "==> Downloading minify.lua"; curl -fsSL https://raw.githubusercontent.com/ztimar31/lua-minify-tic80/refs/heads/master/minify.lua -o minify.lua; }
|
||||
echo "==> Minifying <%= name %>.lua"
|
||||
cp <%= name %>.lua <%= name %>.original.lua
|
||||
lua minify.lua minify <%= name %>.original.lua > <%= name %>.lua
|
||||
|
||||
- name: docs
|
||||
image: alpine
|
||||
commands:
|
||||
- apk add --no-cache lua5.4 lua5.4-dev luarocks gcc musl-dev zip
|
||||
- ln -sf /usr/bin/lua5.4 /usr/bin/lua
|
||||
- ln -sf /usr/bin/luarocks-5.4 /usr/bin/luarocks
|
||||
- luarocks install ldoc
|
||||
- |
|
||||
VERSION=$(cat .version)
|
||||
echo "==> Generating docs from <%= name %>.original.lua"
|
||||
ldoc <%= name %>.original.lua -d docs
|
||||
echo "==> Zipping docs for version $VERSION"
|
||||
(cd docs && zip -r ../<%= name %>-$VERSION-docs.zip .)
|
||||
cp <%= name %>-$VERSION-docs.zip <%= name %>-docs.zip
|
||||
echo "==> Docs zip created"
|
||||
|
||||
- name: export
|
||||
image: <%= builder %>
|
||||
environment:
|
||||
XDG_RUNTIME_DIR: /tmp
|
||||
commands:
|
||||
- |
|
||||
VERSION=$(cat .version)
|
||||
echo "==> Exporting HTML for version $VERSION"
|
||||
tic80 --cli --skip --fs=. \
|
||||
--cmd="load <%= name %>.lua & save <%= name %>-$VERSION & export html <%= name %>-$VERSION.html & exit"
|
||||
if [ -f "<%= name %>-$VERSION.tic" ]; then
|
||||
cp <%= name %>-$VERSION.tic <%= name %>.tic
|
||||
fi
|
||||
if [ -f "<%= name %>-$VERSION.html.zip" ]; then
|
||||
cp <%= name %>-$VERSION.html.zip <%= name %>.html.zip
|
||||
fi
|
||||
echo "==> Generated files:"
|
||||
ls -lh <%= name %>-$VERSION.* <%= name %>.tic <%= name %>.html.zip 2>/dev/null || true
|
||||
|
||||
- name: binaries
|
||||
image: <%= builder %>
|
||||
environment:
|
||||
XDG_RUNTIME_DIR: /tmp
|
||||
commands:
|
||||
- |
|
||||
VERSION=$(cat .version)
|
||||
echo "==> Exporting native players for version $VERSION"
|
||||
tic80 --cli --skip --fs=. \
|
||||
--cmd="load <%= name %>.lua & export win <%= name %>-win & export linux <%= name %>-linux & export mac <%= name %>-mac & exit"
|
||||
# unix zip preserves the executable bit
|
||||
pack_binary() {
|
||||
SLUG="$1"; SRC_FILE="$2"; DST_FILE="$3"
|
||||
PKG_DIR="<%= name %>-$VERSION-$SLUG"
|
||||
rm -rf "$PKG_DIR" "$PKG_DIR.zip"
|
||||
mkdir -p "$PKG_DIR"
|
||||
mv "$SRC_FILE" "$PKG_DIR/$DST_FILE"
|
||||
chmod +x "$PKG_DIR/$DST_FILE"
|
||||
zip -r "$PKG_DIR.zip" "$PKG_DIR" >/dev/null
|
||||
rm -rf "$PKG_DIR"
|
||||
echo "==> $PKG_DIR.zip kesz"
|
||||
}
|
||||
pack_binary win-x64 <%= name %>-win.exe <%= name %>.exe
|
||||
pack_binary linux-x64 <%= name %>-linux <%= name %>
|
||||
pack_binary mac-x64 <%= name %>-mac <%= name %>
|
||||
|
||||
- name: upload
|
||||
image: alpine
|
||||
environment:
|
||||
UPDATE_SERVER: <%= update_server %>
|
||||
UPDATE_SECRET:
|
||||
from_secret: application_token
|
||||
commands:
|
||||
- apk add --no-cache curl
|
||||
- |
|
||||
VERSION=$(cat .version)
|
||||
echo "==> Uploading artifacts for version $VERSION"
|
||||
cp <%= name %>.lua <%= name %>-$VERSION.lua
|
||||
BINS=""
|
||||
for slug in win-x64 linux-x64 mac-x64; do
|
||||
[ -f "<%= name %>-$VERSION-$slug.zip" ] && BINS="$BINS <%= name %>-$VERSION-$slug.zip"
|
||||
done
|
||||
for f in <%= name %>-$VERSION.lua <%= name %>-$VERSION.tic <%= name %>-$VERSION.html.zip <%= name %>-$VERSION-docs.zip $BINS; do
|
||||
curl -fsS -H "X-Update-Secret: $UPDATE_SECRET" \
|
||||
-F "file=@$f" \
|
||||
"$UPDATE_SERVER/build/upload?name=<%= name %>&version=$VERSION" || exit 1
|
||||
done
|
||||
|
||||
- name: publish
|
||||
image: alpine
|
||||
environment:
|
||||
UPDATE_SERVER: <%= update_server %>
|
||||
UPDATE_SECRET:
|
||||
from_secret: application_token
|
||||
commands:
|
||||
- apk add --no-cache curl
|
||||
- |
|
||||
VERSION=$(cat .version)
|
||||
echo "==> Publishing version $VERSION"
|
||||
curl -fsS -X POST -H "X-Update-Secret: $UPDATE_SECRET" "$UPDATE_SERVER/build/publish?name=<%= name %>&platform=tic80&version=$VERSION"
|
||||
@@ -0,0 +1,40 @@
|
||||
module WarpEngine
|
||||
module Platforms
|
||||
module Tic80
|
||||
class Service
|
||||
include Updatable
|
||||
include Builds::BuildCartridge
|
||||
include Builds::BuildSource
|
||||
include Builds::BuildWeb
|
||||
include Builds::BuildDocs
|
||||
include Builds::BuildWinX64
|
||||
include Builds::BuildLinuxX64
|
||||
include Builds::BuildMacX64
|
||||
|
||||
label "TIC-80"
|
||||
|
||||
def cartridge_ext = ".tic"
|
||||
def source_ext = ".lua"
|
||||
|
||||
private
|
||||
|
||||
def parse_metadata(versioned)
|
||||
parse_lua_metadata(full_path("#{versioned}.lua"))
|
||||
end
|
||||
|
||||
def parse_lua_metadata(source_path)
|
||||
metadata = {}
|
||||
File.foreach(source_path) do |line|
|
||||
break unless line.start_with?("--")
|
||||
parts = line[2..].split(":", 2)
|
||||
next if parts.length != 2
|
||||
key = parts[0].strip.downcase.to_sym
|
||||
value = parts[1].strip
|
||||
metadata[key] = value
|
||||
end
|
||||
metadata.slice(*MetadataParsing::METADATA_KEYS)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
+3
-3
@@ -1,11 +1,11 @@
|
||||
module WarpEngine
|
||||
class UpdateService
|
||||
def update(input)
|
||||
class PublishService
|
||||
def publish(input)
|
||||
unless WarpEngine::PlatformLink::SUPPORTED_PLATFORMS.include?(input.platform)
|
||||
raise ArgumentError, "Unsupported platform: #{input.platform}"
|
||||
end
|
||||
|
||||
"WarpEngine::SoftwareUpdater::#{input.platform.camelize}Service".constantize.new.update(input.name, input.version)
|
||||
"WarpEngine::Platforms::#{input.platform.camelize}::Service".constantize.new.update(input.name, input.version)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -2,8 +2,9 @@ module WarpEngine
|
||||
class SoftwareService
|
||||
include SoftwareResponseBuilder
|
||||
|
||||
def index
|
||||
def index(owner_id: nil)
|
||||
softwares = WarpEngine::Software.includes(releases: [ :release_assets ]).includes(:external_links, :software_images).all
|
||||
softwares = softwares.where(owner_id: owner_id) if owner_id.present?
|
||||
counts = download_counts_for(softwares.flat_map { |sw| sw.releases.map(&:id) })
|
||||
{ softwares: softwares.map { |sw| build_response(sw, sw.releases.to_a, counts) } }
|
||||
end
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
module WarpEngine
|
||||
module SoftwareUpdater
|
||||
class BevyService
|
||||
include Updatable
|
||||
include Builds::BuildWeb
|
||||
include Builds::BuildWinX64
|
||||
include Builds::BuildLinuxX64
|
||||
|
||||
label "Bevy"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,12 +0,0 @@
|
||||
module WarpEngine
|
||||
module SoftwareUpdater
|
||||
class C64Service
|
||||
include Updatable
|
||||
include Builds::BuildCartridge
|
||||
|
||||
label "C64"
|
||||
|
||||
def cartridge_ext = ".prg"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,15 +0,0 @@
|
||||
module WarpEngine
|
||||
module SoftwareUpdater
|
||||
class EbitengineService
|
||||
include Updatable
|
||||
include Builds::BuildWeb
|
||||
include Builds::BuildWinX86
|
||||
include Builds::BuildWinX64
|
||||
include Builds::BuildLinuxX64
|
||||
include Builds::BuildMacX64
|
||||
include Builds::BuildMacArm64
|
||||
|
||||
label "Ebitengine"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,14 +0,0 @@
|
||||
module WarpEngine
|
||||
module SoftwareUpdater
|
||||
class GodotService
|
||||
include Updatable
|
||||
include Builds::BuildWeb
|
||||
include Builds::BuildWinX86
|
||||
include Builds::BuildWinX64
|
||||
include Builds::BuildLinuxX64
|
||||
include Builds::BuildMacUniversal
|
||||
|
||||
label "Godot"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,13 +0,0 @@
|
||||
module WarpEngine
|
||||
module SoftwareUpdater
|
||||
class LoveService
|
||||
include Updatable
|
||||
include Builds::BuildWeb
|
||||
include Builds::BuildWinX64
|
||||
include Builds::BuildLinuxX64
|
||||
include Builds::BuildMacUniversal
|
||||
|
||||
label "LÖVE"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,10 +0,0 @@
|
||||
module WarpEngine
|
||||
module SoftwareUpdater
|
||||
class PhaserService
|
||||
include Updatable
|
||||
include Builds::BuildWeb
|
||||
|
||||
label "Phaser"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,38 +0,0 @@
|
||||
module WarpEngine
|
||||
module SoftwareUpdater
|
||||
class Tic80Service
|
||||
include Updatable
|
||||
include Builds::BuildCartridge
|
||||
include Builds::BuildSource
|
||||
include Builds::BuildWeb
|
||||
include Builds::BuildDocs
|
||||
include Builds::BuildWinX64
|
||||
include Builds::BuildLinuxX64
|
||||
include Builds::BuildMacX64
|
||||
|
||||
label "TIC-80"
|
||||
|
||||
def cartridge_ext = ".tic"
|
||||
def source_ext = ".lua"
|
||||
|
||||
private
|
||||
|
||||
def parse_metadata(versioned)
|
||||
parse_lua_metadata(full_path("#{versioned}.lua"))
|
||||
end
|
||||
|
||||
def parse_lua_metadata(source_path)
|
||||
metadata = {}
|
||||
File.foreach(source_path) do |line|
|
||||
break unless line.start_with?("--")
|
||||
parts = line[2..].split(":", 2)
|
||||
next if parts.length != 2
|
||||
key = parts[0].strip.downcase.to_sym
|
||||
value = parts[1].strip
|
||||
metadata[key] = value
|
||||
end
|
||||
metadata.slice(*MetadataParsing::METADATA_KEYS)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,160 @@
|
||||
require "net/http"
|
||||
require "json"
|
||||
require "uri"
|
||||
|
||||
module WarpEngine
|
||||
class WoodpeckerClient
|
||||
class ApiError < StandardError
|
||||
attr_reader :status, :body
|
||||
|
||||
def initialize(message, status:, body: nil)
|
||||
super(message)
|
||||
@status = status
|
||||
@body = body
|
||||
end
|
||||
end
|
||||
|
||||
class ConnectionError < StandardError; end
|
||||
|
||||
def initialize(base_url: nil, token: nil)
|
||||
@base_url = (base_url || WarpEngine.config.woodpecker_url).to_s.chomp("/")
|
||||
@token = token || WarpEngine.config.woodpecker_api_token
|
||||
end
|
||||
|
||||
# --- Repos ---
|
||||
|
||||
def list_repos
|
||||
get("/api/repos")
|
||||
end
|
||||
|
||||
def get_repo(repo_id)
|
||||
get("/api/repos/#{repo_id}")
|
||||
end
|
||||
|
||||
def activate_repo(repo_id)
|
||||
post("/api/repos", body: { id: repo_id })
|
||||
end
|
||||
|
||||
def deactivate_repo(repo_id)
|
||||
delete("/api/repos/#{repo_id}")
|
||||
end
|
||||
|
||||
# --- Secrets ---
|
||||
|
||||
def list_secrets(repo_id)
|
||||
get("/api/repos/#{repo_id}/secrets")
|
||||
end
|
||||
|
||||
def create_secret(repo_id, name:, value:, events: %w[push tag deployment])
|
||||
post("/api/repos/#{repo_id}/secrets",
|
||||
body: { name: name, value: value, events: events })
|
||||
end
|
||||
|
||||
def update_secret(repo_id, secret_name, value:)
|
||||
patch("/api/repos/#{repo_id}/secrets/#{secret_name}",
|
||||
body: { value: value })
|
||||
end
|
||||
|
||||
def delete_secret(repo_id, secret_name)
|
||||
delete("/api/repos/#{repo_id}/secrets/#{secret_name}")
|
||||
end
|
||||
|
||||
# --- Pipelines ---
|
||||
|
||||
def list_pipelines(repo_id, page: 1, per_page: 25)
|
||||
get("/api/repos/#{repo_id}/pipelines",
|
||||
params: { page: page, perPage: per_page })
|
||||
end
|
||||
|
||||
def latest_pipeline(repo_id)
|
||||
get("/api/repos/#{repo_id}/pipelines/latest")
|
||||
end
|
||||
|
||||
def get_pipeline(repo_id, number)
|
||||
get("/api/repos/#{repo_id}/pipelines/#{number}")
|
||||
end
|
||||
|
||||
def trigger_pipeline(repo_id, branch: "main")
|
||||
post("/api/repos/#{repo_id}/pipelines",
|
||||
body: { branch: branch })
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def get(path, params: {})
|
||||
uri = build_uri(path, params)
|
||||
request = Net::HTTP::Get.new(uri)
|
||||
execute(uri, request)
|
||||
end
|
||||
|
||||
def post(path, body: {})
|
||||
uri = build_uri(path)
|
||||
request = Net::HTTP::Post.new(uri)
|
||||
request.body = body.to_json
|
||||
request.content_type = "application/json"
|
||||
execute(uri, request)
|
||||
end
|
||||
|
||||
def patch(path, body: {})
|
||||
uri = build_uri(path)
|
||||
request = Net::HTTP::Patch.new(uri)
|
||||
request.body = body.to_json
|
||||
request.content_type = "application/json"
|
||||
execute(uri, request)
|
||||
end
|
||||
|
||||
def delete(path)
|
||||
uri = build_uri(path)
|
||||
request = Net::HTTP::Delete.new(uri)
|
||||
execute(uri, request)
|
||||
end
|
||||
|
||||
def build_uri(path, params = {})
|
||||
uri = URI.parse("#{@base_url}#{path}")
|
||||
uri.query = URI.encode_www_form(params) if params.any?
|
||||
uri
|
||||
end
|
||||
|
||||
def execute(uri, request)
|
||||
request["Authorization"] = "Bearer #{@token}"
|
||||
request["Accept"] = "application/json"
|
||||
|
||||
response = Net::HTTP.start(uri.hostname, uri.port,
|
||||
use_ssl: uri.scheme == "https",
|
||||
open_timeout: 10,
|
||||
read_timeout: 30) do |http|
|
||||
http.request(request)
|
||||
end
|
||||
|
||||
handle_response(uri, response)
|
||||
rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Net::OpenTimeout,
|
||||
Net::ReadTimeout, SocketError => e
|
||||
raise ConnectionError, "Cannot reach Woodpecker at #{@base_url}: #{e.message}"
|
||||
end
|
||||
|
||||
def handle_response(uri, response)
|
||||
case response
|
||||
when Net::HTTPSuccess, Net::HTTPNoContent
|
||||
return nil if response.body.blank?
|
||||
begin
|
||||
JSON.parse(response.body)
|
||||
rescue JSON::ParserError
|
||||
# A wrong path falls through to the Woodpecker SPA, which answers
|
||||
# 200 with index.html — surface that as an API error, not a parse one.
|
||||
raise ApiError.new(
|
||||
"Expected JSON from #{uri.path} but got: #{response.body.truncate(80)}",
|
||||
status: response.code.to_i, body: response.body
|
||||
)
|
||||
end
|
||||
when Net::HTTPNotFound
|
||||
raise ApiError.new("Not found: #{uri.path}", status: 404, body: response.body)
|
||||
else
|
||||
raise ApiError.new(
|
||||
"Woodpecker API error #{response.code}: #{response.body&.truncate(200)}",
|
||||
status: response.code.to_i,
|
||||
body: response.body
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -6,8 +6,16 @@ WarpEngine::Engine.routes.draw do
|
||||
get "download", to: "downloads#show"
|
||||
get "builds", to: "builds#index"
|
||||
get "softwares/:name/builds", to: "software_builds#show"
|
||||
|
||||
get "ci/repos", to: "ci#repos"
|
||||
get "ci/repos/:id/status", to: "ci#status"
|
||||
post "ci/repos/:id/trigger", to: "ci#trigger"
|
||||
end
|
||||
|
||||
get "update", to: "update#update"
|
||||
post "build/upload", to: "build/uploads#create"
|
||||
post "build/publish", to: "build/publish#create"
|
||||
get "build/config", to: "build/configs#show"
|
||||
post "build/config", to: "build/configs#create"
|
||||
|
||||
get "file/*path", to: "files#show", format: false
|
||||
end
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
class CreateApplicationTokens < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
create_table :application_tokens, id: { type: :bigint, unsigned: true },
|
||||
charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t|
|
||||
t.string :name, limit: 128, null: false
|
||||
# The owner class comes from the host (WarpEngine.config.application_token_owner_class),
|
||||
# so no FK.
|
||||
t.string :owner_type, limit: 128, null: false
|
||||
t.bigint :owner_id, null: false, unsigned: true
|
||||
t.string :token_digest, limit: 64, null: false
|
||||
t.string :token_prefix, limit: 12, null: false
|
||||
t.json :scopes
|
||||
t.datetime :expires_at, precision: 3
|
||||
t.datetime :last_used_at, precision: 3
|
||||
t.datetime :deleted_at, precision: 3
|
||||
t.timestamps precision: 3, null: true
|
||||
t.index :token_digest, name: "idx_application_tokens_token_digest", unique: true
|
||||
t.index [ :owner_type, :owner_id ], name: "idx_application_tokens_owner"
|
||||
t.index :deleted_at, name: "idx_application_tokens_deleted_at"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,12 @@
|
||||
class AddBuildOwnership < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
# Owner of the publishing token; nil = internal / pre-backfill software.
|
||||
# The owner class comes from the host (application_token_owner_class), so no FK.
|
||||
add_column :softwares, :owner_type, :string, limit: 128
|
||||
add_column :softwares, :owner_id, :bigint, unsigned: true
|
||||
add_index :softwares, [ :owner_type, :owner_id ], name: "idx_softwares_owner"
|
||||
|
||||
# unrestricted = internal token: exempt from enforce_software_ownership.
|
||||
add_column :application_tokens, :unrestricted, :boolean, default: false, null: false
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,27 @@
|
||||
class CreateCiRepositories < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
create_table :ci_repositories, id: { type: :bigint, unsigned: true },
|
||||
charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t|
|
||||
t.bigint :woodpecker_repo_id, null: false, unsigned: true
|
||||
t.string :repo_owner, limit: 128, null: false
|
||||
t.string :repo_name, limit: 128, null: false
|
||||
t.string :platform, limit: 32, null: false
|
||||
t.bigint :software_id, unsigned: true
|
||||
t.boolean :active, default: true, null: false
|
||||
t.string :last_pipeline_status, limit: 32
|
||||
t.datetime :last_pipeline_at, precision: 3
|
||||
t.datetime :deleted_at, precision: 3
|
||||
t.timestamps precision: 3, null: true
|
||||
|
||||
t.index :woodpecker_repo_id, unique: true, name: "idx_ci_repos_wp_id"
|
||||
t.index :software_id, name: "idx_ci_repos_software"
|
||||
t.index [ :repo_owner, :repo_name ], unique: true, name: "idx_ci_repos_owner_name"
|
||||
t.index :deleted_at, name: "idx_ci_repos_deleted"
|
||||
end
|
||||
|
||||
add_foreign_key :ci_repositories, :softwares,
|
||||
column: :software_id,
|
||||
name: "fk_ci_repos_software",
|
||||
on_delete: :nullify
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,26 @@
|
||||
# Copy to .env and adjust. Every value except the OAuth pair has a working
|
||||
# default for a throwaway local demo, so the quickstart runs without edits.
|
||||
|
||||
MYSQL_ROOT_PASSWORD=warpengine
|
||||
|
||||
# Shared secret for the /build/* endpoints (X-Update-Secret header).
|
||||
UPDATE_SECRET=example-update-secret
|
||||
|
||||
# Published ports.
|
||||
APP_PORT=8080
|
||||
GITEA_SSH_PORT=2223
|
||||
|
||||
# --- profile "ci" only -------------------------------------------------------
|
||||
|
||||
WOODPECKER_AGENT_SECRET=example-agent-secret
|
||||
|
||||
# OAuth2 application created in gitea (Settings -> Applications), redirect URI
|
||||
# http://woodpecker:8000/authorize — required before the ci profile starts.
|
||||
WOODPECKER_GITEA_CLIENT=
|
||||
WOODPECKER_GITEA_SECRET=
|
||||
|
||||
# Woodpecker management (repo sync, secret provisioning, pipeline control).
|
||||
# Generate a PAT in Woodpecker: profile → Personal Token.
|
||||
WOODPECKER_URL=http://woodpecker:8000
|
||||
WOODPECKER_API_TOKEN=
|
||||
WOODPECKER_REPO_OWNER=
|
||||
@@ -0,0 +1,114 @@
|
||||
# Example stack: everything WarpEngine needs to come alive, end to end.
|
||||
#
|
||||
# mysql the catalog database
|
||||
# app a minimal Rails host with the engine mounted from this
|
||||
# repo checkout (headless: API + updater, no ActiveAdmin)
|
||||
# gitea (profile "ci") the git forge
|
||||
# woodpecker (profile "ci") CI server + agent, wired to gitea
|
||||
#
|
||||
# Quickstart (catalog only):
|
||||
# cp .env.example .env
|
||||
# docker compose up --build
|
||||
#
|
||||
# Full loop with forge + CI:
|
||||
# docker compose --profile ci up --build
|
||||
#
|
||||
# See ../../README.md ("Example stack") or the WarpEngine wiki page for the
|
||||
# full walkthrough, including the one-time gitea/woodpecker OAuth wiring.
|
||||
|
||||
services:
|
||||
mysql:
|
||||
image: mysql:8
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
||||
timeout: 20s
|
||||
retries: 10
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-warpengine}
|
||||
MYSQL_DATABASE: warp_engine_example
|
||||
volumes:
|
||||
- mysql-data:/var/lib/mysql
|
||||
|
||||
app:
|
||||
build: ./host_app
|
||||
ports:
|
||||
- "${APP_PORT:-8080}:3000"
|
||||
environment:
|
||||
RAILS_ENV: development
|
||||
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-warpengine}
|
||||
UPDATE_SECRET: ${UPDATE_SECRET:-example-update-secret}
|
||||
FILE_CONTAINER_PATH: /softwares
|
||||
IMAGE_CONTAINER_PATH: /images
|
||||
WOODPECKER_URL: ${WOODPECKER_URL:-}
|
||||
WOODPECKER_API_TOKEN: ${WOODPECKER_API_TOKEN:-}
|
||||
WOODPECKER_REPO_OWNER: ${WOODPECKER_REPO_OWNER:-}
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./host_app:/app
|
||||
- ../..:/warp_engine # the engine itself, consumed as a path gem
|
||||
- bundle:/usr/local/bundle
|
||||
- softwares:/softwares
|
||||
- images:/images
|
||||
|
||||
# --- profile "ci": the forge + CI producing releases for the catalog ------
|
||||
#
|
||||
# gitea and woodpecker refer to each other by their service names, so your
|
||||
# browser needs to resolve those names too:
|
||||
# echo "127.0.0.1 gitea woodpecker" | sudo tee -a /etc/hosts
|
||||
# gitea: http://gitea:3000 woodpecker: http://woodpecker:8000
|
||||
|
||||
gitea:
|
||||
image: gitea/gitea:1.27.0
|
||||
profiles: ["ci"]
|
||||
environment:
|
||||
DISABLE_REGISTRATION: "true"
|
||||
ROOT_URL: "http://gitea:3000"
|
||||
ports:
|
||||
- "3000:3000"
|
||||
- "${GITEA_SSH_PORT:-2223}:22"
|
||||
volumes:
|
||||
- gitea-data:/data
|
||||
|
||||
woodpecker:
|
||||
image: woodpeckerci/woodpecker-server:v3.16.0
|
||||
profiles: ["ci"]
|
||||
environment:
|
||||
WOODPECKER_HOST: "http://woodpecker:8000"
|
||||
WOODPECKER_OPEN: "true"
|
||||
WOODPECKER_GITEA: "true"
|
||||
WOODPECKER_GITEA_URL: "http://gitea:3000"
|
||||
# Create an OAuth2 app in gitea first — see the README walkthrough.
|
||||
WOODPECKER_GITEA_CLIENT: ${WOODPECKER_GITEA_CLIENT:-}
|
||||
WOODPECKER_GITEA_SECRET: ${WOODPECKER_GITEA_SECRET:-}
|
||||
WOODPECKER_SERVER_ADDR: ":8000"
|
||||
WOODPECKER_AGENT_SECRET: ${WOODPECKER_AGENT_SECRET:-example-agent-secret}
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- woodpecker-data:/var/lib/woodpecker
|
||||
|
||||
woodpecker-agent:
|
||||
image: woodpeckerci/woodpecker-agent:v3.16.0
|
||||
profiles: ["ci"]
|
||||
environment:
|
||||
WOODPECKER_SERVER: "woodpecker:9000"
|
||||
WOODPECKER_AGENT_SECRET: ${WOODPECKER_AGENT_SECRET:-example-agent-secret}
|
||||
# Attach pipeline containers to the stack network so steps can reach
|
||||
# gitea and the app by service name.
|
||||
WOODPECKER_BACKEND_DOCKER_NETWORK: warp-example
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
|
||||
networks:
|
||||
default:
|
||||
name: warp-example
|
||||
|
||||
volumes:
|
||||
mysql-data:
|
||||
bundle:
|
||||
softwares:
|
||||
images:
|
||||
gitea-data:
|
||||
woodpecker-data:
|
||||
@@ -0,0 +1,6 @@
|
||||
# Generated on first boot by the entrypoint (install generator + db:prepare).
|
||||
db/migrate/
|
||||
db/schema.rb
|
||||
log/
|
||||
tmp/
|
||||
Gemfile.lock
|
||||
@@ -0,0 +1,20 @@
|
||||
FROM ruby:3.3-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
default-libmysqlclient-dev \
|
||||
git \
|
||||
tzdata \
|
||||
libyaml-dev \
|
||||
pkg-config \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# The app source, the engine checkout (/warp_engine) and the bundle are all
|
||||
# mounted at runtime by the compose file; the entrypoint bundles on first boot.
|
||||
ENTRYPOINT ["./bin/docker-entrypoint"]
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["bin/rails", "server", "-b", "0.0.0.0", "-p", "3000"]
|
||||
@@ -0,0 +1,10 @@
|
||||
source "https://rubygems.org"
|
||||
|
||||
gem "rails", "~> 8.0.0"
|
||||
gem "mysql2", "~> 0.5"
|
||||
gem "puma"
|
||||
|
||||
# In this example stack the engine comes straight from the repo checkout the
|
||||
# compose file mounts at /warp_engine. A real host would use the git source or
|
||||
# the Forgejo rubygems registry instead — see the engine README.
|
||||
gem "warp_engine", path: ENV.fetch("WARP_ENGINE_PATH", "/warp_engine")
|
||||
@@ -0,0 +1,3 @@
|
||||
require_relative "config/application"
|
||||
|
||||
Rails.application.load_tasks
|
||||
@@ -0,0 +1,16 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
bundle check || bundle install
|
||||
|
||||
# First boot: run the install generator the way a real host would. It creates
|
||||
# the create_warp_engine_tables migration; --skip leaves our initializer alone.
|
||||
if ! ls db/migrate/*create_warp_engine_tables* >/dev/null 2>&1; then
|
||||
bin/rails generate warp_engine:install --skip
|
||||
fi
|
||||
|
||||
bin/rails db:prepare
|
||||
|
||||
rm -f tmp/pids/server.pid
|
||||
|
||||
exec "$@"
|
||||
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env ruby
|
||||
APP_PATH = File.expand_path("../config/application", __dir__)
|
||||
require_relative "../config/boot"
|
||||
require "rails/commands"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user