Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
546201c886 | ||
|
|
c853cfadbc | ||
|
|
b1139a43bc | ||
|
|
bae6fc06a6 | ||
|
|
731b267aa1 | ||
|
|
508e869080 | ||
|
|
435d22b71d | ||
|
|
f499b7f2af | ||
|
|
267a13b600 | ||
|
|
b530dcd50d | ||
|
|
068c4db3f8 | ||
|
|
565c086a0d | ||
|
|
dd69dd79ab | ||
|
|
fd0b64850f | ||
|
|
61ad1a87f2 | ||
|
|
7c9c8ff510 |
@@ -10,20 +10,27 @@ Rails.application.config.to_prepare do
|
||||
|
||||
# Woodpecker configuration extension (/build/config): the served platforms
|
||||
# and their builder images. An image bump is one line here, rolled out to
|
||||
# every repo by the deploy. tic80 can be added once the tic80-builder image
|
||||
# (lua+luacheck+ldoc toolchain) exists in the tic80-tools repo.
|
||||
# every repo by the deploy.
|
||||
c.ci_platforms = {
|
||||
"godot" => { builder: "git.teletypegames.org/internal/godot-builder:4.6" },
|
||||
"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/tic80-builder:latest",
|
||||
# exporter: "git.teletypegames.org/internal/tic80pro: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",
|
||||
|
||||
+20
-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_05_000003) 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
|
||||
@@ -45,6 +45,24 @@ ActiveRecord::Schema[8.1].define(version: 2026_08_05_000003) do
|
||||
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
|
||||
@@ -264,6 +282,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_08_05_000003) do
|
||||
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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -283,7 +283,7 @@ Configuration: `ci_platforms` maps platform names to builder images
|
||||
an empty map (default) disables the feature. Set the Woodpecker side with
|
||||
`WOODPECKER_CONFIG_EXTENSION_ENDPOINT=https://your-host/build/config` (or
|
||||
per-repo in Settings → Extensions). Templates live in
|
||||
`lib/warp_engine/ci_templates/*.yaml.erb`.
|
||||
`app/services/warp_engine/platforms/<platform>/pipeline.yaml.erb`.
|
||||
|
||||
## Public API
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ ActiveAdmin.register WarpEngine::ApplicationToken, as: "Application Token" do
|
||||
actions :index, :show, :new, :create, :edit, :update, :destroy
|
||||
permit_params :name, :owner_id, :expires_at, :scopes_string, :unrestricted
|
||||
|
||||
menu priority: 9, label: "🎟️ App Tokens"
|
||||
menu parent: "🌀 WarpEngine", priority: 9, label: "🎟️ App Tokens"
|
||||
|
||||
config.sort_order = "created_at_desc"
|
||||
config.batch_actions = false
|
||||
@@ -52,6 +52,25 @@ ActiveAdmin.register WarpEngine::ApplicationToken, as: "Application Token" do
|
||||
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
|
||||
@@ -80,6 +99,19 @@ ActiveAdmin.register WarpEngine::ApplicationToken, as: "Application Token" do
|
||||
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
|
||||
@@ -90,10 +122,12 @@ ActiveAdmin.register WarpEngine::ApplicationToken, as: "Application Token" do
|
||||
show!
|
||||
end
|
||||
|
||||
# Revoke = soft delete, audit-nyommal.
|
||||
def destroy
|
||||
if WarpEngine.woodpecker_configured?
|
||||
WarpEngine::CiSecretSyncService.new.deprovision(resource)
|
||||
end
|
||||
resource.revoke!
|
||||
redirect_to collection_path, notice: "Token revoked."
|
||||
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,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,13 +29,13 @@ module WarpEngine
|
||||
return render json: { error: "Forbidden" }, status: :forbidden
|
||||
end
|
||||
|
||||
input = WarpEngine::UpdateInputDto.new(
|
||||
input = WarpEngine::PublishInputDto.new(
|
||||
platform: params[:platform],
|
||||
name: params[:name],
|
||||
version: params[:version]
|
||||
)
|
||||
|
||||
WarpEngine::UpdateService.new.update(input)
|
||||
WarpEngine::PublishService.new.publish(input)
|
||||
claim_software_ownership(params[:name])
|
||||
|
||||
render json: { published: true, name: params[:name], platform: params[:platform], version: params[:version] }
|
||||
|
||||
+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,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
|
||||
@@ -11,6 +11,7 @@ module WarpEngine
|
||||
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
|
||||
|
||||
+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)
|
||||
|
||||
@@ -2,8 +2,8 @@ require "erb"
|
||||
|
||||
module WarpEngine
|
||||
# Renders the /build/config platform templates: the pipeline logic lives in
|
||||
# lib/warp_engine/ci_templates/<platform>.yaml.erb, the per-platform builder
|
||||
# images come from WarpEngine.config.ci_platforms.
|
||||
# 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/
|
||||
|
||||
@@ -15,7 +15,7 @@ module WarpEngine
|
||||
spec = platform_spec(platform)
|
||||
return nil if spec.nil?
|
||||
|
||||
path = templates_dir.join("#{platform}.yaml.erb")
|
||||
path = templates_dir.join(platform, "pipeline.yaml.erb")
|
||||
return nil unless path.exist?
|
||||
|
||||
ERB.new(path.read, trim_mode: "-").result_with_hash(
|
||||
@@ -34,7 +34,7 @@ module WarpEngine
|
||||
end
|
||||
|
||||
def templates_dir
|
||||
WarpEngine::Engine.root.join("lib", "warp_engine", "ci_templates")
|
||||
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
|
||||
@@ -1,13 +1,17 @@
|
||||
require "openssl"
|
||||
require "base64"
|
||||
require "net/http"
|
||||
require "digest"
|
||||
|
||||
module WarpEngine
|
||||
# Verifies the httpsig signature of Woodpecker configuration-extension
|
||||
# requests (draft-cavage http-signatures, ed25519). The server sends the
|
||||
# signed header list in the Signature header — typically "(request-target) date".
|
||||
# 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
|
||||
SIGNATURE_PARAM = /(\w+)="([^"]*)"/
|
||||
CAVAGE_PARAM = /(\w+)="([^"]*)"/
|
||||
|
||||
@key_cache = {}
|
||||
@key_mutex = Mutex.new
|
||||
@@ -36,14 +40,12 @@ module WarpEngine
|
||||
return false
|
||||
end
|
||||
|
||||
params = signature_params
|
||||
return false if params.nil? || params["signature"].blank?
|
||||
|
||||
signing_string = build_signing_string(params.fetch("headers", "date"))
|
||||
return false if signing_string.nil?
|
||||
|
||||
key = OpenSSL::PKey.read(pem)
|
||||
key.verify(nil, Base64.decode64(params["signature"]), signing_string)
|
||||
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
|
||||
@@ -62,8 +64,70 @@ module WarpEngine
|
||||
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 signature_params
|
||||
def cavage_params
|
||||
header = @request.headers["Signature"].presence
|
||||
if header.nil?
|
||||
auth = @request.headers["Authorization"].to_s
|
||||
@@ -71,10 +135,10 @@ module WarpEngine
|
||||
end
|
||||
return nil if header.blank?
|
||||
|
||||
header.scan(SIGNATURE_PARAM).to_h
|
||||
header.scan(CAVAGE_PARAM).to_h
|
||||
end
|
||||
|
||||
def build_signing_string(headers_list)
|
||||
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}"
|
||||
|
||||
@@ -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,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,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,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,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,12 @@
|
||||
module WarpEngine
|
||||
module Platforms
|
||||
module Phaser
|
||||
class Service
|
||||
include Updatable
|
||||
include Builds::BuildWeb
|
||||
|
||||
label "Phaser"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
+15
-5
@@ -17,8 +17,12 @@ steps:
|
||||
echo $VERSION > .version
|
||||
|
||||
- name: lint
|
||||
image: <%= builder %>
|
||||
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
|
||||
@@ -83,8 +87,10 @@ steps:
|
||||
rm -f /tmp/_lint_combined.lua /tmp/_lint_map.txt
|
||||
|
||||
- name: minify
|
||||
image: <%= builder %>
|
||||
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
|
||||
@@ -97,8 +103,12 @@ steps:
|
||||
lua minify.lua minify <%= name %>.original.lua > <%= name %>.lua
|
||||
|
||||
- name: docs
|
||||
image: <%= builder %>
|
||||
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"
|
||||
@@ -109,7 +119,7 @@ steps:
|
||||
echo "==> Docs zip created"
|
||||
|
||||
- name: export
|
||||
image: <%= exporter %>
|
||||
image: <%= builder %>
|
||||
environment:
|
||||
XDG_RUNTIME_DIR: /tmp
|
||||
commands:
|
||||
@@ -128,7 +138,7 @@ steps:
|
||||
ls -lh <%= name %>-$VERSION.* <%= name %>.tic <%= name %>.html.zip 2>/dev/null || true
|
||||
|
||||
- name: binaries
|
||||
image: <%= exporter %>
|
||||
image: <%= builder %>
|
||||
environment:
|
||||
XDG_RUNTIME_DIR: /tmp
|
||||
commands:
|
||||
@@ -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
|
||||
@@ -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,6 +6,10 @@ 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
|
||||
|
||||
post "build/upload", to: "build/uploads#create"
|
||||
|
||||
@@ -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
|
||||
@@ -18,3 +18,9 @@ WOODPECKER_AGENT_SECRET=example-agent-secret
|
||||
# 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=
|
||||
|
||||
@@ -39,6 +39,9 @@ services:
|
||||
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
|
||||
|
||||
@@ -29,6 +29,12 @@ Rails.application.config.to_prepare do
|
||||
# c.ci_extension_public_key_url = "https://ci.example.org/api/signature/public-key"
|
||||
# c.ci_update_server = nil # nil: the request base_url
|
||||
|
||||
# Woodpecker CI management — repo sync, secret provisioning, pipeline control.
|
||||
# All three must be set for the management features to activate.
|
||||
# c.woodpecker_url = ENV["WOODPECKER_URL"] # e.g. "https://ci.example.org"
|
||||
# c.woodpecker_api_token = ENV["WOODPECKER_API_TOKEN"] # Woodpecker PAT with admin access
|
||||
# c.woodpecker_repo_owner = ENV["WOODPECKER_REPO_OWNER"] # forge org/user (e.g. "games")
|
||||
|
||||
# Size cap in bytes for /build/upload (and the admin file manager, default: 500MB).
|
||||
# c.max_upload_size = 500 * 1024 * 1024
|
||||
|
||||
|
||||
@@ -19,6 +19,10 @@ module WarpEngine
|
||||
def self.configure
|
||||
yield(config)
|
||||
end
|
||||
|
||||
def self.woodpecker_configured?
|
||||
config.woodpecker_url.present? && config.woodpecker_api_token.present?
|
||||
end
|
||||
end
|
||||
|
||||
require "warp_engine/engine"
|
||||
|
||||
@@ -20,6 +20,9 @@ module WarpEngine
|
||||
# PEM, or a URL to fetch it from (e.g. https://ci.../api/signature/public-key).
|
||||
# With neither set, POST /build/config rejects every request.
|
||||
# ci_update_server: server URL written into the upload/publish steps; nil → the request's base_url.
|
||||
# woodpecker_url / woodpecker_api_token / woodpecker_repo_owner:
|
||||
# Woodpecker CI management (repo sync, secret provisioning, pipeline control).
|
||||
# All nil → the management features are inactive.
|
||||
attr_accessor :file_container_path,
|
||||
:image_container_path,
|
||||
:update_secret,
|
||||
@@ -31,6 +34,9 @@ module WarpEngine
|
||||
:ci_extension_public_key,
|
||||
:ci_extension_public_key_url,
|
||||
:ci_update_server,
|
||||
:woodpecker_url,
|
||||
:woodpecker_api_token,
|
||||
:woodpecker_repo_owner,
|
||||
:image_owners
|
||||
|
||||
def initialize
|
||||
@@ -45,6 +51,9 @@ module WarpEngine
|
||||
@ci_extension_public_key = nil
|
||||
@ci_extension_public_key_url = nil
|
||||
@ci_update_server = nil
|
||||
@woodpecker_url = ENV["WOODPECKER_URL"]
|
||||
@woodpecker_api_token = ENV["WOODPECKER_API_TOKEN"]
|
||||
@woodpecker_repo_owner = ENV["WOODPECKER_REPO_OWNER"]
|
||||
@image_owners = []
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
FactoryBot.define do
|
||||
factory :ci_repository, class: "WarpEngine::CiRepository" do
|
||||
sequence(:woodpecker_repo_id) { |n| n }
|
||||
repo_owner { "testorg" }
|
||||
sequence(:repo_name) { |n| "game-#{n}" }
|
||||
platform { "tic80" }
|
||||
active { true }
|
||||
|
||||
trait :with_software do
|
||||
association :software
|
||||
end
|
||||
|
||||
trait :inactive do
|
||||
active { false }
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,57 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe WarpEngine::CiRepository do
|
||||
describe "validations" do
|
||||
subject { build(:ci_repository) }
|
||||
|
||||
it { is_expected.to validate_presence_of(:woodpecker_repo_id) }
|
||||
it { is_expected.to validate_uniqueness_of(:woodpecker_repo_id) }
|
||||
it { is_expected.to validate_presence_of(:repo_owner) }
|
||||
it { is_expected.to validate_presence_of(:repo_name) }
|
||||
it { is_expected.to validate_presence_of(:platform) }
|
||||
|
||||
it "rejects unsupported platforms" do
|
||||
repo = build(:ci_repository, platform: "amiga")
|
||||
expect(repo).not_to be_valid
|
||||
expect(repo.errors[:platform]).to be_present
|
||||
end
|
||||
|
||||
WarpEngine::PlatformLink::SUPPORTED_PLATFORMS.each do |p|
|
||||
it "accepts #{p}" do
|
||||
repo = build(:ci_repository, platform: p)
|
||||
expect(repo).to be_valid
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "#full_name" do
|
||||
it "returns owner/name" do
|
||||
repo = build(:ci_repository, repo_owner: "games", repo_name: "mygame")
|
||||
expect(repo.full_name).to eq("games/mygame")
|
||||
end
|
||||
end
|
||||
|
||||
describe "default_scope" do
|
||||
it "excludes soft-deleted records" do
|
||||
repo = create(:ci_repository)
|
||||
repo.update_column(:deleted_at, Time.current)
|
||||
|
||||
expect(described_class.all).not_to include(repo)
|
||||
expect(described_class.unscoped).to include(repo)
|
||||
end
|
||||
end
|
||||
|
||||
describe ".active" do
|
||||
it "returns only active repos" do
|
||||
active = create(:ci_repository, active: true)
|
||||
inactive = create(:ci_repository, active: false)
|
||||
|
||||
expect(described_class.active).to include(active)
|
||||
expect(described_class.active).not_to include(inactive)
|
||||
end
|
||||
end
|
||||
|
||||
describe "associations" do
|
||||
it { is_expected.to belong_to(:software).optional }
|
||||
end
|
||||
end
|
||||
@@ -15,7 +15,26 @@ RSpec.describe "Build configs endpoint", type: :request do
|
||||
allow(WarpEngine.config).to receive(:ci_extension_public_key).and_return(signing_key.public_to_pem)
|
||||
end
|
||||
|
||||
def signed_headers(method: "post", path: "/build/config")
|
||||
# Woodpecker 3.x-style RFC 9421 signature over @request-target + content-digest.
|
||||
def signed_headers(body, path: "/build/config", digest_body: nil)
|
||||
digest = "sha-256=:#{Digest::SHA256.base64digest(digest_body || body)}:"
|
||||
inner = %{("@request-target" "content-digest");created=#{Time.now.to_i};alg="ed25519"}
|
||||
base = [
|
||||
%("@request-target": #{path}),
|
||||
%("content-digest": #{digest}),
|
||||
%("@signature-params": #{inner})
|
||||
].join("\n")
|
||||
signature = Base64.strict_encode64(signing_key.sign(nil, base))
|
||||
{
|
||||
"Content-Digest" => digest,
|
||||
"Signature-Input" => "woodpecker-ci-extensions=#{inner}",
|
||||
"Signature" => "woodpecker-ci-extensions=:#{signature}:",
|
||||
"Content-Type" => "application/json"
|
||||
}
|
||||
end
|
||||
|
||||
# Legacy draft-cavage signature (single Signature header).
|
||||
def cavage_signed_headers(method: "post", path: "/build/config")
|
||||
date = Time.now.httpdate
|
||||
signing_string = "(request-target): #{method} #{path}\ndate: #{date}"
|
||||
signature = Base64.strict_encode64(signing_key.sign(nil, signing_string))
|
||||
@@ -68,8 +87,8 @@ RSpec.describe "Build configs endpoint", type: :request do
|
||||
|
||||
describe "POST /build/config" do
|
||||
it "returns the rendered pipeline for a marker config" do
|
||||
post "/build/config", params: extension_payload("platform: godot\n"),
|
||||
headers: signed_headers
|
||||
payload = extension_payload("platform: godot\n")
|
||||
post "/build/config", params: payload, headers: signed_headers(payload)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
configs = response.parsed_body["configs"]
|
||||
@@ -81,8 +100,8 @@ RSpec.describe "Build configs endpoint", type: :request do
|
||||
end
|
||||
|
||||
it "uses the marker's name override instead of the repo name" do
|
||||
post "/build/config", params: extension_payload("platform: godot\nname: othername\n"),
|
||||
headers: signed_headers
|
||||
payload = extension_payload("platform: godot\nname: othername\n")
|
||||
post "/build/config", params: payload, headers: signed_headers(payload)
|
||||
|
||||
expect(response.parsed_body["configs"].first["data"]).to include("othername")
|
||||
expect(response.parsed_body["configs"].first["data"]).not_to include("mygame")
|
||||
@@ -92,40 +111,55 @@ RSpec.describe "Build configs endpoint", type: :request do
|
||||
payload = { repo: { name: "mygame" },
|
||||
configs: [ { name: ".woodpecker.yaml", data: "platform: godot\n" } ] }.to_json
|
||||
|
||||
post "/build/config", params: payload, headers: signed_headers
|
||||
post "/build/config", params: payload, headers: signed_headers(payload)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
|
||||
it "accepts a legacy draft-cavage signed request" do
|
||||
payload = extension_payload("platform: godot\n")
|
||||
post "/build/config", params: payload, headers: cavage_signed_headers
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
|
||||
it "returns 204 for a non-marker config" do
|
||||
full_pipeline = "steps:\n - name: build\n image: alpine\n"
|
||||
|
||||
post "/build/config", params: extension_payload(full_pipeline),
|
||||
headers: signed_headers
|
||||
payload = extension_payload("steps:\n - name: build\n image: alpine\n")
|
||||
post "/build/config", params: payload, headers: signed_headers(payload)
|
||||
|
||||
expect(response).to have_http_status(:no_content)
|
||||
end
|
||||
|
||||
it "returns 204 when no configuration is sent" do
|
||||
post "/build/config", params: { repo: { name: "mygame" } }.to_json,
|
||||
headers: signed_headers
|
||||
payload = { repo: { name: "mygame" } }.to_json
|
||||
post "/build/config", params: payload, headers: signed_headers(payload)
|
||||
|
||||
expect(response).to have_http_status(:no_content)
|
||||
end
|
||||
|
||||
it "returns 422 for a marker with an unknown platform" do
|
||||
post "/build/config", params: extension_payload("platform: amiga\n"),
|
||||
headers: signed_headers
|
||||
payload = extension_payload("platform: amiga\n")
|
||||
post "/build/config", params: payload, headers: signed_headers(payload)
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
|
||||
it "rejects a request with an invalid signature" do
|
||||
headers = signed_headers
|
||||
payload = extension_payload("platform: godot\n")
|
||||
headers = signed_headers(payload)
|
||||
other_key = OpenSSL::PKey.generate_key("ed25519")
|
||||
allow(WarpEngine.config).to receive(:ci_extension_public_key).and_return(other_key.public_to_pem)
|
||||
|
||||
post "/build/config", params: extension_payload("platform: godot\n"), headers: headers
|
||||
post "/build/config", params: payload, headers: headers
|
||||
|
||||
expect(response).to have_http_status(:forbidden)
|
||||
end
|
||||
|
||||
it "rejects a request whose body does not match the signed content-digest" do
|
||||
payload = extension_payload("platform: godot\n")
|
||||
tampered = signed_headers(payload, digest_body: "something else")
|
||||
|
||||
post "/build/config", params: payload, headers: tampered
|
||||
|
||||
expect(response).to have_http_status(:forbidden)
|
||||
end
|
||||
@@ -141,8 +175,8 @@ RSpec.describe "Build configs endpoint", type: :request do
|
||||
allow(WarpEngine.config).to receive(:ci_extension_public_key).and_return(nil)
|
||||
allow(WarpEngine.config).to receive(:ci_extension_public_key_url).and_return(nil)
|
||||
|
||||
post "/build/config", params: extension_payload("platform: godot\n"),
|
||||
headers: signed_headers
|
||||
payload = extension_payload("platform: godot\n")
|
||||
post "/build/config", params: payload, headers: signed_headers(payload)
|
||||
|
||||
expect(response).to have_http_status(:forbidden)
|
||||
end
|
||||
@@ -150,11 +184,11 @@ RSpec.describe "Build configs endpoint", type: :request do
|
||||
|
||||
describe "shipped templates" do
|
||||
it "renders every template to valid YAML with non-empty steps" do
|
||||
templates = Dir[WarpEngine::Engine.root.join("lib/warp_engine/ci_templates/*.yaml.erb")]
|
||||
templates = Dir[WarpEngine::Engine.root.join("app/services/warp_engine/platforms/*/pipeline.yaml.erb")]
|
||||
expect(templates).not_to be_empty
|
||||
|
||||
templates.each do |path|
|
||||
platform = File.basename(path, ".yaml.erb")
|
||||
platform = File.basename(File.dirname(path))
|
||||
allow(WarpEngine.config).to receive(:ci_platforms).and_return(
|
||||
platform => { builder: "registry.example/builder:1", exporter: "registry.example/exporter:1" }
|
||||
)
|
||||
|
||||
@@ -6,8 +6,8 @@ RSpec.describe "POST /build/publish", type: :request do
|
||||
end
|
||||
|
||||
def stub_updater
|
||||
updater = instance_double(WarpEngine::SoftwareUpdater::Tic80Service)
|
||||
allow(WarpEngine::SoftwareUpdater::Tic80Service).to receive(:new).and_return(updater)
|
||||
updater = instance_double(WarpEngine::Platforms::Tic80::Service)
|
||||
allow(WarpEngine::Platforms::Tic80::Service).to receive(:new).and_return(updater)
|
||||
allow(updater).to receive(:update)
|
||||
updater
|
||||
end
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe "CI API", type: :request do
|
||||
before do
|
||||
allow(WarpEngine.config).to receive(:woodpecker_url).and_return("https://ci.test")
|
||||
allow(WarpEngine.config).to receive(:woodpecker_api_token).and_return("wp-token")
|
||||
allow(WarpEngine.config).to receive(:update_secret).and_return("s3cret")
|
||||
end
|
||||
|
||||
describe "GET /api/ci/repos" do
|
||||
it "returns active repos" do
|
||||
repo = create(:ci_repository, repo_name: "mygame", platform: "tic80")
|
||||
|
||||
get "/api/ci/repos"
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
json = JSON.parse(response.body)
|
||||
expect(json.size).to eq(1)
|
||||
expect(json.first["repo_name"]).to eq("mygame")
|
||||
end
|
||||
|
||||
it "returns 503 when woodpecker not configured" do
|
||||
allow(WarpEngine.config).to receive(:woodpecker_url).and_return(nil)
|
||||
|
||||
get "/api/ci/repos"
|
||||
|
||||
expect(response).to have_http_status(:service_unavailable)
|
||||
end
|
||||
end
|
||||
|
||||
describe "GET /api/ci/repos/:id/status" do
|
||||
it "returns repo with pipeline status" do
|
||||
repo = create(:ci_repository, repo_owner: "org", repo_name: "game")
|
||||
client = instance_double(WarpEngine::WoodpeckerClient)
|
||||
allow(WarpEngine::WoodpeckerClient).to receive(:new).and_return(client)
|
||||
allow(client).to receive(:get_pipeline)
|
||||
.and_return({ "number" => 1, "status" => "success" })
|
||||
|
||||
get "/api/ci/repos/#{repo.id}/status"
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
json = JSON.parse(response.body)
|
||||
expect(json["repo"]["repo_name"]).to eq("game")
|
||||
end
|
||||
end
|
||||
|
||||
describe "POST /api/ci/repos/:id/trigger" do
|
||||
it "requires authentication" do
|
||||
repo = create(:ci_repository)
|
||||
|
||||
post "/api/ci/repos/#{repo.id}/trigger"
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
|
||||
it "triggers a pipeline with valid secret" do
|
||||
repo = create(:ci_repository, repo_owner: "org", repo_name: "game")
|
||||
client = instance_double(WarpEngine::WoodpeckerClient)
|
||||
allow(WarpEngine::WoodpeckerClient).to receive(:new).and_return(client)
|
||||
allow(client).to receive(:trigger_pipeline)
|
||||
.and_return({ "number" => 7, "status" => "pending" })
|
||||
|
||||
post "/api/ci/repos/#{repo.id}/trigger",
|
||||
headers: { "X-Update-Secret" => "s3cret" }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
json = JSON.parse(response.body)
|
||||
expect(json["triggered"]).to be true
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,53 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe WarpEngine::CiPipelineService do
|
||||
let(:client) { instance_double(WarpEngine::WoodpeckerClient) }
|
||||
let(:service) { described_class.new(client: client) }
|
||||
|
||||
describe "#dashboard" do
|
||||
it "returns latest pipeline for each active repo" do
|
||||
repo = create(:ci_repository, repo_owner: "org", repo_name: "game")
|
||||
pipeline = { "number" => 5, "status" => "success", "created_at" => "2026-08-06T12:00:00Z" }
|
||||
allow(client).to receive(:latest_pipeline).with(repo.woodpecker_repo_id).and_return(pipeline)
|
||||
|
||||
entries = service.dashboard
|
||||
|
||||
expect(entries.size).to eq(1)
|
||||
expect(entries.first[:pipeline]["status"]).to eq("success")
|
||||
expect(repo.reload.last_pipeline_status).to eq("success")
|
||||
end
|
||||
|
||||
it "handles API errors gracefully per repo" do
|
||||
create(:ci_repository, repo_owner: "org", repo_name: "broken")
|
||||
allow(client).to receive(:latest_pipeline)
|
||||
.and_raise(WarpEngine::WoodpeckerClient::ApiError.new("fail", status: 500))
|
||||
|
||||
entries = service.dashboard
|
||||
|
||||
expect(entries.size).to eq(1)
|
||||
expect(entries.first[:pipeline]).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "#trigger" do
|
||||
it "delegates to client" do
|
||||
repo = build(:ci_repository, repo_owner: "org", repo_name: "game")
|
||||
allow(client).to receive(:trigger_pipeline).and_return({ "number" => 6 })
|
||||
|
||||
result = service.trigger(repo, branch: "main")
|
||||
|
||||
expect(result["number"]).to eq(6)
|
||||
expect(client).to have_received(:trigger_pipeline).with(repo.woodpecker_repo_id, branch: "main")
|
||||
end
|
||||
end
|
||||
|
||||
describe "#list_pipelines" do
|
||||
it "returns paginated pipelines" do
|
||||
repo = build(:ci_repository, repo_owner: "org", repo_name: "game")
|
||||
pipelines = [{ "number" => 1 }, { "number" => 2 }]
|
||||
allow(client).to receive(:list_pipelines).with(repo.woodpecker_repo_id, page: 1).and_return(pipelines)
|
||||
|
||||
expect(service.list_pipelines(repo)).to eq(pipelines)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,81 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe WarpEngine::CiRepoSyncService do
|
||||
let(:client) { instance_double(WarpEngine::WoodpeckerClient) }
|
||||
let(:service) { described_class.new(client: client) }
|
||||
|
||||
describe "#sync_all" do
|
||||
it "creates new CiRepository records from Woodpecker" do
|
||||
allow(client).to receive(:list_repos).and_return([
|
||||
{ "id" => 1, "name" => "mygame", "owner" => "org", "active" => true }
|
||||
])
|
||||
|
||||
result = service.sync_all
|
||||
|
||||
expect(result[:created].size).to eq(1)
|
||||
repo = result[:created].first
|
||||
expect(repo.repo_name).to eq("mygame")
|
||||
expect(repo.repo_owner).to eq("org")
|
||||
expect(repo.active).to be true
|
||||
end
|
||||
|
||||
it "updates existing records" do
|
||||
existing = create(:ci_repository, woodpecker_repo_id: 1, repo_name: "old", platform: "tic80")
|
||||
allow(client).to receive(:list_repos).and_return([
|
||||
{ "id" => 1, "name" => "newname", "owner" => "org", "active" => true }
|
||||
])
|
||||
|
||||
result = service.sync_all
|
||||
|
||||
expect(result[:updated].size).to eq(1)
|
||||
expect(existing.reload.repo_name).to eq("newname")
|
||||
end
|
||||
|
||||
it "deactivates repos missing from Woodpecker" do
|
||||
orphan = create(:ci_repository, woodpecker_repo_id: 99, active: true)
|
||||
allow(client).to receive(:list_repos).and_return([])
|
||||
|
||||
result = service.sync_all
|
||||
|
||||
expect(result[:deactivated]).to include(orphan)
|
||||
expect(orphan.reload.active).to be false
|
||||
end
|
||||
|
||||
it "auto-detects platform from matching Software" do
|
||||
create(:software, name: "mygame", platform: "godot")
|
||||
allow(client).to receive(:list_repos).and_return([
|
||||
{ "id" => 1, "name" => "mygame", "owner" => "org", "active" => true }
|
||||
])
|
||||
|
||||
result = service.sync_all
|
||||
|
||||
expect(result[:created].first.platform).to eq("godot")
|
||||
expect(result[:created].first.software).to be_present
|
||||
end
|
||||
end
|
||||
|
||||
describe "#activate" do
|
||||
it "calls client and syncs the repo" do
|
||||
allow(client).to receive(:activate_repo).with(42)
|
||||
allow(client).to receive(:get_repo).with(42).and_return(
|
||||
{ "id" => 42, "name" => "game", "owner" => "org", "active" => true }
|
||||
)
|
||||
|
||||
repo = service.activate(42)
|
||||
|
||||
expect(repo.woodpecker_repo_id).to eq(42)
|
||||
expect(repo.active).to be true
|
||||
end
|
||||
end
|
||||
|
||||
describe "#deactivate" do
|
||||
it "calls client and marks repo inactive" do
|
||||
repo = create(:ci_repository, woodpecker_repo_id: 42, active: true)
|
||||
allow(client).to receive(:deactivate_repo).with(42)
|
||||
|
||||
service.deactivate(42)
|
||||
|
||||
expect(repo.reload.active).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,131 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe WarpEngine::CiSecretSyncService do
|
||||
let(:client) { instance_double(WarpEngine::WoodpeckerClient) }
|
||||
let(:service) { described_class.new(client: client) }
|
||||
|
||||
before do
|
||||
allow(WarpEngine.config).to receive(:application_token_source).and_return(:database)
|
||||
allow(WarpEngine.config).to receive(:application_token_owner_class).and_return("TestOwner")
|
||||
end
|
||||
|
||||
describe "#provision" do
|
||||
it "creates secrets on repos that don't have one" do
|
||||
repo = create(:ci_repository)
|
||||
allow(client).to receive(:list_secrets).with(repo.woodpecker_repo_id).and_return([])
|
||||
allow(client).to receive(:create_secret)
|
||||
|
||||
result = service.provision("plaintoken", repos: [ repo ])
|
||||
|
||||
expect(result[:synced]).to eq([ repo ])
|
||||
expect(client).to have_received(:create_secret).with(
|
||||
repo.woodpecker_repo_id, name: "application_token", value: "plaintoken"
|
||||
)
|
||||
end
|
||||
|
||||
it "updates secrets on repos that already have one" do
|
||||
repo = create(:ci_repository)
|
||||
allow(client).to receive(:list_secrets).with(repo.woodpecker_repo_id)
|
||||
.and_return([ { "name" => "application_token" } ])
|
||||
allow(client).to receive(:update_secret)
|
||||
|
||||
result = service.provision("newtoken", repos: [ repo ])
|
||||
|
||||
expect(result[:synced]).to eq([ repo ])
|
||||
expect(client).to have_received(:update_secret).with(
|
||||
repo.woodpecker_repo_id, "application_token", value: "newtoken"
|
||||
)
|
||||
end
|
||||
|
||||
it "records failed repos without raising" do
|
||||
repo = create(:ci_repository)
|
||||
allow(client).to receive(:list_secrets).and_raise(
|
||||
WarpEngine::WoodpeckerClient::ConnectionError, "unreachable"
|
||||
)
|
||||
|
||||
result = service.provision("tok", repos: [ repo ])
|
||||
|
||||
expect(result[:synced]).to be_empty
|
||||
expect(result[:failed].size).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
describe "#deprovision" do
|
||||
it "deletes secrets from all relevant repos" do
|
||||
token = create(:application_token)
|
||||
sw = create(:software, name: "game1", owner: token.owner)
|
||||
repo = create(:ci_repository, :with_software, software: sw)
|
||||
|
||||
allow(client).to receive(:delete_secret)
|
||||
|
||||
service.deprovision(token)
|
||||
|
||||
expect(client).to have_received(:delete_secret).with(repo.woodpecker_repo_id, "application_token")
|
||||
end
|
||||
end
|
||||
|
||||
describe "#rotate" do
|
||||
it "creates new token, provisions, revokes old" do
|
||||
token = create(:application_token)
|
||||
sw = create(:software, name: "game1", owner: token.owner)
|
||||
repo = create(:ci_repository, :with_software, software: sw)
|
||||
|
||||
allow(client).to receive(:list_secrets).and_return([])
|
||||
allow(client).to receive(:create_secret)
|
||||
|
||||
result = service.rotate(token)
|
||||
|
||||
expect(result[:rotated]).to be true
|
||||
expect(result[:new_token]).to be_a(WarpEngine::ApplicationToken)
|
||||
expect(token.reload.deleted_at).to be_present
|
||||
end
|
||||
|
||||
it "rolls back if all repos fail" do
|
||||
token = create(:application_token)
|
||||
sw = create(:software, name: "game1", owner: token.owner)
|
||||
create(:ci_repository, :with_software, software: sw)
|
||||
|
||||
allow(client).to receive(:list_secrets).and_raise(
|
||||
WarpEngine::WoodpeckerClient::ConnectionError, "down"
|
||||
)
|
||||
|
||||
result = service.rotate(token)
|
||||
|
||||
expect(result[:rotated]).to be false
|
||||
expect(token.reload.deleted_at).to be_nil
|
||||
end
|
||||
|
||||
it "returns no-op when no repos exist" do
|
||||
token = create(:application_token)
|
||||
|
||||
result = service.rotate(token)
|
||||
|
||||
expect(result[:rotated]).to be false
|
||||
expect(result[:reason]).to eq("no repos")
|
||||
end
|
||||
end
|
||||
|
||||
describe "#repos_for_token" do
|
||||
it "returns all active repos for unrestricted tokens" do
|
||||
token = create(:application_token, :unrestricted)
|
||||
repo1 = create(:ci_repository)
|
||||
create(:ci_repository, :inactive)
|
||||
|
||||
repos = service.repos_for_token(token)
|
||||
|
||||
expect(repos).to eq([ repo1 ])
|
||||
end
|
||||
|
||||
it "returns only owner's repos for scoped tokens" do
|
||||
token = create(:application_token)
|
||||
own_sw = create(:software, name: "mine", owner: token.owner)
|
||||
other_sw = create(:software, name: "theirs", owner: create(:test_owner))
|
||||
own_repo = create(:ci_repository, :with_software, software: own_sw)
|
||||
create(:ci_repository, :with_software, software: other_sw)
|
||||
|
||||
repos = service.repos_for_token(token)
|
||||
|
||||
expect(repos).to eq([ own_repo ])
|
||||
end
|
||||
end
|
||||
end
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
require "rails_helper"
|
||||
require "zip"
|
||||
|
||||
RSpec.describe WarpEngine::SoftwareUpdater::Tic80Service do
|
||||
RSpec.describe WarpEngine::Platforms::Tic80::Service do
|
||||
let(:tmpdir) { Dir.mktmpdir }
|
||||
let(:name) { "spectic" }
|
||||
let(:version) { "9.9" }
|
||||
@@ -0,0 +1,59 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe WarpEngine::PublishService do
|
||||
describe "#publish" do
|
||||
it "raises ArgumentError for unsupported platform" do
|
||||
input = WarpEngine::PublishInputDto.new(platform: "unknown", name: "game", version: "1.0")
|
||||
|
||||
expect { described_class.new.publish(input) }.to raise_error(ArgumentError, /Unsupported platform/)
|
||||
end
|
||||
|
||||
it "routes to correct platform service" do
|
||||
input = WarpEngine::PublishInputDto.new(platform: "tic80", name: "game", version: "1.0")
|
||||
mock_service = instance_double(WarpEngine::Platforms::Tic80::Service)
|
||||
|
||||
allow(WarpEngine::Platforms::Tic80::Service).to receive(:new).and_return(mock_service)
|
||||
allow(mock_service).to receive(:update)
|
||||
|
||||
described_class.new.publish(input)
|
||||
|
||||
expect(mock_service).to have_received(:update).with("game", "1.0")
|
||||
end
|
||||
|
||||
it "routes godot platform to Godot::Service" do
|
||||
input = WarpEngine::PublishInputDto.new(platform: "godot", name: "game", version: "1.0")
|
||||
mock_service = instance_double(WarpEngine::Platforms::Godot::Service)
|
||||
|
||||
allow(WarpEngine::Platforms::Godot::Service).to receive(:new).and_return(mock_service)
|
||||
allow(mock_service).to receive(:update)
|
||||
|
||||
described_class.new.publish(input)
|
||||
|
||||
expect(mock_service).to have_received(:update).with("game", "1.0")
|
||||
end
|
||||
|
||||
it "routes bevy platform to Bevy::Service" do
|
||||
input = WarpEngine::PublishInputDto.new(platform: "bevy", name: "game", version: "1.0")
|
||||
mock_service = instance_double(WarpEngine::Platforms::Bevy::Service)
|
||||
|
||||
allow(WarpEngine::Platforms::Bevy::Service).to receive(:new).and_return(mock_service)
|
||||
allow(mock_service).to receive(:update)
|
||||
|
||||
described_class.new.publish(input)
|
||||
|
||||
expect(mock_service).to have_received(:update).with("game", "1.0")
|
||||
end
|
||||
|
||||
it "routes phaser platform to Phaser::Service" do
|
||||
input = WarpEngine::PublishInputDto.new(platform: "phaser", name: "game", version: "1.0")
|
||||
mock_service = instance_double(WarpEngine::Platforms::Phaser::Service)
|
||||
|
||||
allow(WarpEngine::Platforms::Phaser::Service).to receive(:new).and_return(mock_service)
|
||||
allow(mock_service).to receive(:update)
|
||||
|
||||
described_class.new.publish(input)
|
||||
|
||||
expect(mock_service).to have_received(:update).with("game", "1.0")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,59 +0,0 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe WarpEngine::UpdateService do
|
||||
describe "#update" do
|
||||
it "raises ArgumentError for unsupported platform" do
|
||||
input = WarpEngine::UpdateInputDto.new(platform: "unknown", name: "game", version: "1.0")
|
||||
|
||||
expect { described_class.new.update(input) }.to raise_error(ArgumentError, /Unsupported platform/)
|
||||
end
|
||||
|
||||
it "routes to correct platform service" do
|
||||
input = WarpEngine::UpdateInputDto.new(platform: "tic80", name: "game", version: "1.0")
|
||||
mock_service = instance_double(WarpEngine::SoftwareUpdater::Tic80Service)
|
||||
|
||||
allow(WarpEngine::SoftwareUpdater::Tic80Service).to receive(:new).and_return(mock_service)
|
||||
allow(mock_service).to receive(:update)
|
||||
|
||||
described_class.new.update(input)
|
||||
|
||||
expect(mock_service).to have_received(:update).with("game", "1.0")
|
||||
end
|
||||
|
||||
it "routes godot platform to GodotService" do
|
||||
input = WarpEngine::UpdateInputDto.new(platform: "godot", name: "game", version: "1.0")
|
||||
mock_service = instance_double(WarpEngine::SoftwareUpdater::GodotService)
|
||||
|
||||
allow(WarpEngine::SoftwareUpdater::GodotService).to receive(:new).and_return(mock_service)
|
||||
allow(mock_service).to receive(:update)
|
||||
|
||||
described_class.new.update(input)
|
||||
|
||||
expect(mock_service).to have_received(:update).with("game", "1.0")
|
||||
end
|
||||
|
||||
it "routes bevy platform to BevyService" do
|
||||
input = WarpEngine::UpdateInputDto.new(platform: "bevy", name: "game", version: "1.0")
|
||||
mock_service = instance_double(WarpEngine::SoftwareUpdater::BevyService)
|
||||
|
||||
allow(WarpEngine::SoftwareUpdater::BevyService).to receive(:new).and_return(mock_service)
|
||||
allow(mock_service).to receive(:update)
|
||||
|
||||
described_class.new.update(input)
|
||||
|
||||
expect(mock_service).to have_received(:update).with("game", "1.0")
|
||||
end
|
||||
|
||||
it "routes phaser platform to PhaserService" do
|
||||
input = WarpEngine::UpdateInputDto.new(platform: "phaser", name: "game", version: "1.0")
|
||||
mock_service = instance_double(WarpEngine::SoftwareUpdater::PhaserService)
|
||||
|
||||
allow(WarpEngine::SoftwareUpdater::PhaserService).to receive(:new).and_return(mock_service)
|
||||
allow(mock_service).to receive(:update)
|
||||
|
||||
described_class.new.update(input)
|
||||
|
||||
expect(mock_service).to have_received(:update).with("game", "1.0")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,127 @@
|
||||
require "rails_helper"
|
||||
require "webmock/rspec"
|
||||
|
||||
RSpec.describe WarpEngine::WoodpeckerClient do
|
||||
let(:base_url) { "https://ci.example.test" }
|
||||
let(:token) { "wp-test-token" }
|
||||
let(:client) { described_class.new(base_url: base_url, token: token) }
|
||||
|
||||
def stub_wp(method, path, status: 200, body: nil, request_body: nil)
|
||||
stub = stub_request(method, "#{base_url}#{path}")
|
||||
.with(headers: { "Authorization" => "Bearer #{token}", "Accept" => "application/json" })
|
||||
stub = stub.with(body: request_body) if request_body
|
||||
stub.to_return(status: status, body: body&.to_json, headers: { "Content-Type" => "application/json" })
|
||||
end
|
||||
|
||||
describe "repos" do
|
||||
it "lists repos" do
|
||||
repos = [{ "id" => 1, "name" => "game1" }]
|
||||
stub_wp(:get, "/api/repos", body: repos)
|
||||
|
||||
expect(client.list_repos).to eq(repos)
|
||||
end
|
||||
|
||||
it "gets a repo" do
|
||||
repo = { "id" => 42, "name" => "mygame" }
|
||||
stub_wp(:get, "/api/repos/42", body: repo)
|
||||
|
||||
expect(client.get_repo(42)).to eq(repo)
|
||||
end
|
||||
|
||||
it "deactivates a repo" do
|
||||
stub_wp(:delete, "/api/repos/42", status: 204)
|
||||
|
||||
expect(client.deactivate_repo(42)).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "secrets" do
|
||||
it "lists secrets" do
|
||||
secrets = [{ "name" => "application_token" }]
|
||||
stub_wp(:get, "/api/repos/1/secrets", body: secrets)
|
||||
|
||||
expect(client.list_secrets(1)).to eq(secrets)
|
||||
end
|
||||
|
||||
it "creates a secret" do
|
||||
stub_wp(:post, "/api/repos/1/secrets", status: 200, body: { "name" => "application_token" })
|
||||
|
||||
result = client.create_secret(1, name: "application_token", value: "secret123")
|
||||
expect(result["name"]).to eq("application_token")
|
||||
end
|
||||
|
||||
it "updates a secret" do
|
||||
stub_wp(:patch, "/api/repos/1/secrets/application_token", status: 200, body: { "name" => "application_token" })
|
||||
|
||||
result = client.update_secret(1, "application_token", value: "newsecret")
|
||||
expect(result["name"]).to eq("application_token")
|
||||
end
|
||||
|
||||
it "deletes a secret" do
|
||||
stub_wp(:delete, "/api/repos/1/secrets/application_token", status: 204)
|
||||
|
||||
expect(client.delete_secret(1, "application_token")).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "pipelines" do
|
||||
it "lists pipelines" do
|
||||
pipelines = [{ "number" => 1, "status" => "success" }]
|
||||
stub_wp(:get, "/api/repos/42/pipelines?page=1&perPage=25", body: pipelines)
|
||||
|
||||
expect(client.list_pipelines(42)).to eq(pipelines)
|
||||
end
|
||||
|
||||
it "gets latest pipeline" do
|
||||
pipeline = { "number" => 5, "status" => "running" }
|
||||
stub_wp(:get, "/api/repos/42/pipelines/latest", body: pipeline)
|
||||
|
||||
expect(client.latest_pipeline(42)).to eq(pipeline)
|
||||
end
|
||||
|
||||
it "triggers a pipeline" do
|
||||
pipeline = { "number" => 6, "status" => "pending" }
|
||||
stub_wp(:post, "/api/repos/42/pipelines", body: pipeline)
|
||||
|
||||
expect(client.trigger_pipeline(42, branch: "main")).to eq(pipeline)
|
||||
end
|
||||
end
|
||||
|
||||
describe "error handling" do
|
||||
it "raises ApiError on 404" do
|
||||
stub_wp(:get, "/api/repos/999", status: 404, body: { "error" => "not found" })
|
||||
|
||||
expect { client.get_repo(999) }.to raise_error(WarpEngine::WoodpeckerClient::ApiError) { |e|
|
||||
expect(e.status).to eq(404)
|
||||
}
|
||||
end
|
||||
|
||||
it "raises ApiError on 500" do
|
||||
stub_wp(:get, "/api/repos", status: 500, body: { "error" => "internal" })
|
||||
|
||||
expect { client.list_repos }.to raise_error(WarpEngine::WoodpeckerClient::ApiError) { |e|
|
||||
expect(e.status).to eq(500)
|
||||
}
|
||||
end
|
||||
|
||||
it "raises ConnectionError on connection refused" do
|
||||
stub_request(:get, "#{base_url}/api/repos").to_raise(Errno::ECONNREFUSED)
|
||||
|
||||
expect { client.list_repos }.to raise_error(WarpEngine::WoodpeckerClient::ConnectionError)
|
||||
end
|
||||
|
||||
it "raises ConnectionError on timeout" do
|
||||
stub_request(:get, "#{base_url}/api/repos").to_timeout
|
||||
|
||||
expect { client.list_repos }.to raise_error(WarpEngine::WoodpeckerClient::ConnectionError)
|
||||
end
|
||||
|
||||
it "raises ApiError when a 200 response is not JSON" do
|
||||
stub_request(:get, "#{base_url}/api/repos")
|
||||
.to_return(status: 200, body: "<!doctype html><html></html>",
|
||||
headers: { "Content-Type" => "text/html" })
|
||||
|
||||
expect { client.list_repos }.to raise_error(WarpEngine::WoodpeckerClient::ApiError, /Expected JSON/)
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user