woodpecker integration

This commit is contained in:
2026-08-06 13:58:58 +02:00
parent 5f8502a2ed
commit 867f71f7c0
25 changed files with 1249 additions and 2 deletions
+1
View File
@@ -8,5 +8,6 @@ group :development, :test do
gem "rspec-rails", "~> 7.0" gem "rspec-rails", "~> 7.0"
gem "factory_bot_rails" gem "factory_bot_rails"
gem "shoulda-matchers", "~> 6.0" gem "shoulda-matchers", "~> 6.0"
gem "webmock", "~> 3.0"
gem "debug", platforms: %i[mri windows] gem "debug", platforms: %i[mri windows]
end end
+36 -2
View File
@@ -52,6 +52,25 @@ ActiveAdmin.register WarpEngine::ApplicationToken, as: "Application Token" do
f.actions f.actions
end 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 show do
if (plain = controller.instance_variable_get(:@plain_token)) if (plain = controller.instance_variable_get(:@plain_token))
panel "⚠️ Token — shown only once, copy it now!" do 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| create! do |success, _failure|
success.html do success.html do
session[:warp_engine_plain_token] = resource.plain_token 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 redirect_to resource_path(resource) and return
end end
end end
@@ -90,10 +122,12 @@ ActiveAdmin.register WarpEngine::ApplicationToken, as: "Application Token" do
show! show!
end end
# Revoke = soft delete, audit-nyommal.
def destroy def destroy
if WarpEngine.woodpecker_configured?
WarpEngine::CiSecretSyncService.new.deprovision(resource)
end
resource.revoke! resource.revoke!
redirect_to collection_path, notice: "Token revoked." redirect_to collection_path, notice: "Token revoked and Woodpecker secrets cleaned up."
end end
end end
end end
+49
View File
@@ -0,0 +1,49 @@
ActiveAdmin.register_page "CI Dashboard" do
menu 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
+118
View File
@@ -0,0 +1,118 @@
ActiveAdmin.register WarpEngine::CiRepository, as: "CI Repository" do
actions :index, :show, :edit, :update
menu 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
@@ -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
+8
View File
@@ -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
+9
View File
@@ -0,0 +1,9 @@
module WarpEngine
class CiRepoSyncJob < ApplicationJob
queue_as :default
def perform
CiRepoSyncService.new.sync_all
end
end
end
+32
View File
@@ -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
+1
View File
@@ -11,6 +11,7 @@ module WarpEngine
has_many :releases, foreign_key: :software_id has_many :releases, foreign_key: :software_id
has_many :downloads, through: :releases has_many :downloads, through: :releases
has_many :external_links, foreign_key: :software_id 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 :software_images, allow_destroy: true
accepts_nested_attributes_for :external_links, allow_destroy: true accepts_nested_attributes_for :external_links, allow_destroy: true
@@ -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.repo_owner, repo.repo_name)
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.repo_owner, repo.repo_name, branch: branch)
end
def list_pipelines(repo, page: 1)
@client.list_pipelines(repo.repo_owner, repo.repo_name, page: page)
end
def pipeline_detail(repo, number)
@client.get_pipeline(repo.repo_owner, repo.repo_name, 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,151 @@
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(owner, repo_name, page: 1, per_page: 25)
get("/api/repos/#{owner}/#{repo_name}/pipelines",
params: { page: page, perPage: per_page })
end
def latest_pipeline(owner, repo_name)
get("/api/repos/#{owner}/#{repo_name}/pipelines/latest")
end
def get_pipeline(owner, repo_name, number)
get("/api/repos/#{owner}/#{repo_name}/pipelines/#{number}")
end
def trigger_pipeline(owner, repo_name, branch: "main")
post("/api/repos/#{owner}/#{repo_name}/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?
JSON.parse(response.body)
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
+4
View File
@@ -6,6 +6,10 @@ WarpEngine::Engine.routes.draw do
get "download", to: "downloads#show" get "download", to: "downloads#show"
get "builds", to: "builds#index" get "builds", to: "builds#index"
get "softwares/:name/builds", to: "software_builds#show" 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 end
post "build/upload", to: "build/uploads#create" 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
@@ -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_extension_public_key_url = "https://ci.example.org/api/signature/public-key"
# c.ci_update_server = nil # nil: the request base_url # 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). # Size cap in bytes for /build/upload (and the admin file manager, default: 500MB).
# c.max_upload_size = 500 * 1024 * 1024 # c.max_upload_size = 500 * 1024 * 1024
+4
View File
@@ -19,6 +19,10 @@ module WarpEngine
def self.configure def self.configure
yield(config) yield(config)
end end
def self.woodpecker_configured?
config.woodpecker_url.present? && config.woodpecker_api_token.present?
end
end end
require "warp_engine/engine" require "warp_engine/engine"
+9
View File
@@ -20,6 +20,9 @@ module WarpEngine
# PEM, or a URL to fetch it from (e.g. https://ci.../api/signature/public-key). # 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. # 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. # 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, attr_accessor :file_container_path,
:image_container_path, :image_container_path,
:update_secret, :update_secret,
@@ -31,6 +34,9 @@ module WarpEngine
:ci_extension_public_key, :ci_extension_public_key,
:ci_extension_public_key_url, :ci_extension_public_key_url,
:ci_update_server, :ci_update_server,
:woodpecker_url,
:woodpecker_api_token,
:woodpecker_repo_owner,
:image_owners :image_owners
def initialize def initialize
@@ -45,6 +51,9 @@ module WarpEngine
@ci_extension_public_key = nil @ci_extension_public_key = nil
@ci_extension_public_key_url = nil @ci_extension_public_key_url = nil
@ci_update_server = 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 = [] @image_owners = []
end end
end end
+17
View File
@@ -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
+57
View File
@@ -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
+71
View File
@@ -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
+53
View File
@@ -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("org", "game").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("org", "game", 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("org", "game", 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
+119
View File
@@ -0,0 +1,119 @@
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/org/game/pipelines?page=1&perPage=25", body: pipelines)
expect(client.list_pipelines("org", "game")).to eq(pipelines)
end
it "gets latest pipeline" do
pipeline = { "number" => 5, "status" => "running" }
stub_wp(:get, "/api/repos/org/game/pipelines/latest", body: pipeline)
expect(client.latest_pipeline("org", "game")).to eq(pipeline)
end
it "triggers a pipeline" do
pipeline = { "number" => 6, "status" => "pending" }
stub_wp(:post, "/api/repos/org/game/pipelines", body: pipeline)
expect(client.trigger_pipeline("org", "game", 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
end
end