From 1c7498ca4a8cef9e8e5cf0021d51c3c0d2f656a1 Mon Sep 17 00:00:00 2001 From: Zsolt Tasnadi Date: Thu, 6 Aug 2026 01:14:22 +0200 Subject: [PATCH] Serve Woodpecker pipeline configs from the engine (/build/config) --- README.md | 27 +++ .../warp_engine/build/configs_controller.rb | 83 ++++++++ app/services/warp_engine/ci_config_service.rb | 40 ++++ .../warp_engine/ci_signature_verifier.rb | 90 +++++++++ config/routes.rb | 2 + .../install/templates/initializer.rb | 11 + lib/warp_engine/ci_templates/bevy.yaml.erb | 103 ++++++++++ lib/warp_engine/ci_templates/c64.yaml.erb | 59 ++++++ .../ci_templates/ebitengine.yaml.erb | 95 +++++++++ lib/warp_engine/ci_templates/godot.yaml.erb | 100 ++++++++++ lib/warp_engine/ci_templates/love.yaml.erb | 174 ++++++++++++++++ lib/warp_engine/ci_templates/phaser.yaml.erb | 69 +++++++ lib/warp_engine/ci_templates/tic80.yaml.erb | 188 ++++++++++++++++++ lib/warp_engine/configuration.rb | 15 ++ .../requests/build_configs_controller_spec.rb | 176 ++++++++++++++++ 15 files changed, 1232 insertions(+) create mode 100644 app/controllers/warp_engine/build/configs_controller.rb create mode 100644 app/services/warp_engine/ci_config_service.rb create mode 100644 app/services/warp_engine/ci_signature_verifier.rb create mode 100644 lib/warp_engine/ci_templates/bevy.yaml.erb create mode 100644 lib/warp_engine/ci_templates/c64.yaml.erb create mode 100644 lib/warp_engine/ci_templates/ebitengine.yaml.erb create mode 100644 lib/warp_engine/ci_templates/godot.yaml.erb create mode 100644 lib/warp_engine/ci_templates/love.yaml.erb create mode 100644 lib/warp_engine/ci_templates/phaser.yaml.erb create mode 100644 lib/warp_engine/ci_templates/tic80.yaml.erb create mode 100644 spec/requests/build_configs_controller_spec.rb diff --git a/README.md b/README.md index 51cce57..bc1138d 100644 --- a/README.md +++ b/README.md @@ -258,6 +258,33 @@ accepts both: When switching to `:database`, create the tokens and move your pipelines to them first — the flip invalidates the shared secret immediately. +## CI pipeline configs (Woodpecker) + +WarpEngine can act as a [Woodpecker configuration extension](https://woodpecker-ci.org/docs/usage/extensions/configuration-extension): +instead of a copy-pasted `.woodpecker.yaml` in every game repo, the repo holds a +one-line marker and the engine serves the full per-platform pipeline +(version → build → upload → publish, calling `/build/upload` + `/build/publish` +with the `application_token` Woodpecker secret): + +```yaml +# .woodpecker.yaml in a game repo +platform: godot +``` + +- `POST /build/config` — the extension endpoint Woodpecker calls on every + pipeline start (httpsig/ed25519-signed request, verified against + `ci_extension_public_key(_url)`). Non-marker configs get a `204` so the + repo's own YAML keeps running — opt-in migration, and putting a full + pipeline back into the repo is the opt-out. +- `GET /build/config?platform=godot` — renders the same pipeline as a preview. + +Configuration: `ci_platforms` maps platform names to builder images +(`{ "godot" => { builder: "..." }, "tic80" => { builder: ..., exporter: ... } }`); +an empty map (default) disables the feature. Set the Woodpecker side with +`WOODPECKER_CONFIG_EXTENSION_ENDPOINT=https://your-host/build/config` (or +per-repo in Settings → Extensions). Templates live in +`lib/warp_engine/ci_templates/*.yaml.erb`. + ## Public API | Endpoint | Purpose | diff --git a/app/controllers/warp_engine/build/configs_controller.rb b/app/controllers/warp_engine/build/configs_controller.rb new file mode 100644 index 0000000..76e8821 --- /dev/null +++ b/app/controllers/warp_engine/build/configs_controller.rb @@ -0,0 +1,83 @@ +module WarpEngine + module Build + # Woodpecker configuration-extension endpoint: a CI-szerver pipeline-indításkor + # POST-olja a repo marker-fájlját, és a platformhoz tartozó teljes pipeline + # YAML-t kapja vissza. A GET ugyanazt rendereli previewként. + class ConfigsController < ApiController + resource_description do + short "Woodpecker CI pipeline configs" + end + + api :GET, "/build/config", "Preview the generated pipeline config for a platform" + param :platform, String, required: true, desc: "Platform (a configured ci_platforms key, e.g. tic80)" + param :name, String, required: false, desc: "Software name substituted into the pipeline (default: example)" + returns code: 200, desc: "Pipeline YAML (text/yaml)" + error code: 404, desc: "Unknown platform" + def show + yaml = render_config(platform: params[:platform], name: params[:name].presence || "example") + return render json: { error: "Unknown platform" }, status: :not_found if yaml.nil? + + render plain: yaml, content_type: "text/yaml" + end + + api :POST, "/build/config", "Woodpecker configuration extension endpoint" + description <<~DESC + Called by the Woodpecker server on every pipeline start (httpsig-signed request). + If the repo's .woodpecker.yaml is a marker (has a `platform:` key), responds with + the generated pipeline; otherwise responds 204 so the repo's own config runs. + DESC + returns code: 200, desc: %(JSON: {"configs": [{"name": ..., "data": ""}]}) + returns code: 204, desc: "Not a marker config — keep the repo's own configuration" + error code: 403, desc: "Missing or invalid request signature" + error code: 422, desc: "Marker requests an unknown platform" + def create + unless WarpEngine::CiSignatureVerifier.new(request).valid? + return render json: { error: "Invalid signature" }, status: :forbidden + end + + marker = find_marker + return head :no_content if marker.nil? + + platform = marker["platform"].to_s + name = marker["name"].presence || repo_name + yaml = render_config(platform: platform, name: name) + if yaml.nil? + return render json: { error: "Unknown platform: #{platform}" }, status: :unprocessable_entity + end + + render json: { configs: [ { name: platform, data: yaml } ] } + end + + private + + def render_config(platform:, name:) + WarpEngine::CiConfigService.new.render( + platform: platform, + name: name, + update_server: WarpEngine.config.ci_update_server.presence || request.base_url + ) + end + + # Az első beküldött config, ami markernek parse-olható (Hash `platform` kulccsal). + # A doksi szerint a kulcs "configuration", az example-config-service "configs"-ot + # használ — mindkettőt elfogadjuk. + def find_marker + configs = params[:configuration].presence || params[:configs].presence || [] + configs.each do |config| + data = config[:data].to_s + parsed = begin + YAML.safe_load(data) + rescue Psych::Exception + nil + end + return parsed if parsed.is_a?(Hash) && parsed.key?("platform") + end + nil + end + + def repo_name + params.dig(:repo, :name).to_s + end + end + end +end diff --git a/app/services/warp_engine/ci_config_service.rb b/app/services/warp_engine/ci_config_service.rb new file mode 100644 index 0000000..44a1cbe --- /dev/null +++ b/app/services/warp_engine/ci_config_service.rb @@ -0,0 +1,40 @@ +require "erb" + +module WarpEngine + # A /build/config platform-template-jeinek renderelése: a pipeline-logika + # a lib/warp_engine/ci_templates/.yaml.erb fájlokban él, a + # platformonkénti builder image-eket a WarpEngine.config.ci_platforms adja. + class CiConfigService + PLATFORM_FORMAT = /\A[a-z0-9_-]+\z/ + + # A renderelt pipeline YAML, vagy nil, ha a platform nem kiszolgált. + def render(platform:, name:, update_server:) + platform = platform.to_s + return nil unless platform.match?(PLATFORM_FORMAT) + + spec = platform_spec(platform) + return nil if spec.nil? + + path = templates_dir.join("#{platform}.yaml.erb") + return nil unless path.exist? + + ERB.new(path.read, trim_mode: "-").result_with_hash( + name: name.to_s, + update_server: update_server.to_s, + builder: spec[:builder], + exporter: spec[:exporter] + ) + end + + private + + def platform_spec(platform) + spec = WarpEngine.config.ci_platforms.stringify_keys[platform] + spec&.symbolize_keys + end + + def templates_dir + WarpEngine::Engine.root.join("lib", "warp_engine", "ci_templates") + end + end +end diff --git a/app/services/warp_engine/ci_signature_verifier.rb b/app/services/warp_engine/ci_signature_verifier.rb new file mode 100644 index 0000000..0b45ba3 --- /dev/null +++ b/app/services/warp_engine/ci_signature_verifier.rb @@ -0,0 +1,90 @@ +require "openssl" +require "base64" +require "net/http" + +module WarpEngine + # A Woodpecker configuration-extension kéréseinek httpsig-ellenőrzése + # (draft-cavage http-signatures, ed25519). A szerver az aláírt headerek + # listáját a Signature headerben küldi — tipikusan "(request-target) date". + class CiSignatureVerifier + SIGNATURE_PARAM = /(\w+)="([^"]*)"/ + + @key_cache = {} + @key_mutex = Mutex.new + + class << self + # A letöltött kulcs process-szinten cache-elt (URL-enként). + def fetch_public_key(url) + @key_mutex.synchronize do + @key_cache[url] ||= Net::HTTP.get(URI.parse(url)) + end + end + + def reset_key_cache! + @key_mutex.synchronize { @key_cache = {} } + end + end + + def initialize(request) + @request = request + end + + def valid? + pem = public_key_pem + if pem.blank? + Rails.logger.error("[CiSignatureVerifier] nincs ci_extension_public_key(_url) konfigurálva — kérés elutasítva") + 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) + rescue OpenSSL::PKey::PKeyError, ArgumentError => e + Rails.logger.error("[CiSignatureVerifier] #{e.class}: #{e.message}") + false + end + + private + + def public_key_pem + config = WarpEngine.config + return config.ci_extension_public_key if config.ci_extension_public_key.present? + return nil if config.ci_extension_public_key_url.blank? + + self.class.fetch_public_key(config.ci_extension_public_key_url) + rescue StandardError => e + Rails.logger.error("[CiSignatureVerifier] kulcs-letöltés sikertelen: #{e.class}: #{e.message}") + nil + end + + # A Signature header (vagy az "Authorization: Signature ..." forma) paraméterei. + def signature_params + header = @request.headers["Signature"].presence + if header.nil? + auth = @request.headers["Authorization"].to_s + header = auth.delete_prefix("Signature ") if auth.start_with?("Signature ") + end + return nil if header.blank? + + header.scan(SIGNATURE_PARAM).to_h + end + + def build_signing_string(headers_list) + lines = headers_list.split(" ").map do |name| + if name == "(request-target)" + "(request-target): #{@request.request_method.downcase} #{@request.fullpath}" + else + value = @request.headers[name] + return nil if value.nil? + "#{name.downcase}: #{value}" + end + end + lines.join("\n") + end + end +end diff --git a/config/routes.rb b/config/routes.rb index ab90223..acac28b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -10,6 +10,8 @@ WarpEngine::Engine.routes.draw do post "build/upload", to: "build/uploads#create" post "build/publish", to: "build/publish#create" + get "build/config", to: "build/configs#show" + post "build/config", to: "build/configs#create" get "file/*path", to: "files#show", format: false end diff --git a/lib/generators/warp_engine/install/templates/initializer.rb b/lib/generators/warp_engine/install/templates/initializer.rb index 3f740a1..7f8c469 100644 --- a/lib/generators/warp_engine/install/templates/initializer.rb +++ b/lib/generators/warp_engine/install/templates/initializer.rb @@ -17,6 +17,17 @@ Rails.application.config.to_prepare do # c.application_token_source = :database # c.application_token_owner_class = "AdminUser" + # Woodpecker configuration extension (/build/config): a kiszolgált + # platformok builder image-ei és a CI-szerver aláíró kulcsa. Üres + # ci_platforms (default) = a feature inaktív. + # c.ci_platforms = { + # "godot" => { builder: "registry.example/godot-builder:4.6" }, + # "tic80" => { builder: "registry.example/tic80-builder:1.0", + # exporter: "registry.example/tic80pro:1.0" } + # } + # c.ci_extension_public_key_url = "https://ci.example.org/api/signature/public-key" + # c.ci_update_server = nil # nil: a kérés base_url-je + # A /build/upload (és az admin file manager) méretplafonja bájtban (default: 500MB). # c.max_upload_size = 500 * 1024 * 1024 diff --git a/lib/warp_engine/ci_templates/bevy.yaml.erb b/lib/warp_engine/ci_templates/bevy.yaml.erb new file mode 100644 index 0000000..7b9be21 --- /dev/null +++ b/lib/warp_engine/ci_templates/bevy.yaml.erb @@ -0,0 +1,103 @@ +# Generált pipeline — WarpEngine /build/config (platform: bevy, name: <%= name %>) +steps: + - name: version + image: alpine + commands: + - apk add --no-cache git jq + - | + if [ -f metadata.json ]; then + VERSION=$(jq -r '.version' metadata.json) + else + VERSION=$(git rev-parse --short HEAD) + fi + BRANCH=$(git rev-parse --abbrev-ref HEAD) + if [ "$BRANCH" != "main" ] && [ "$BRANCH" != "master" ]; then + VERSION="dev-$VERSION-$BRANCH" + fi + echo "VERSION is: $VERSION" + echo $VERSION > .version + + - name: build + image: <%= builder %> + pull: true + commands: + - | + VERSION=$(cat .version) + mkdir -p dist + cargo build --release --target wasm32-unknown-unknown + wasm-bindgen --target web --no-typescript \ + --out-dir dist --out-name game target/wasm32-unknown-unknown/release/<%= name %>.wasm + echo "==> Downloading index.html" + curl -sSL https://git.teletypegames.org/tools/bevy-tools/raw/branch/master/web/index.html -o dist/index.html + echo "==> Packaging HTML/WASM for $VERSION" + zip -r "<%= name %>-$VERSION.html.zip" -j dist/game_bg.wasm dist/game.js dist/index.html + echo "==> Cleaning temporary files" + rm -f dist/game_bg.wasm dist/game.js dist/index.html + + - name: binaries + image: <%= builder %> + pull: true + commands: + - | + VERSION=$(cat .version) + # Native binaries. linux-x64: glibc build in the debian-based builder + # image; win-x64: mingw-w64 cross-compile (x86_64-pc-windows-gnu). + # Mac needs osxcross, it is not built here. + # The zip gets the assets/ dir too if the project has one — bevy loads + # it at runtime, it is not embedded in the binary. + set -e + pack_binary() { + P_SLUG="$1"; P_BIN="$2"; P_NAME="$3" + PKG_DIR="<%= name %>-$VERSION-$P_SLUG" + rm -rf "$PKG_DIR" "$PKG_DIR.zip" + mkdir -p "$PKG_DIR" + cp "$P_BIN" "$PKG_DIR/$P_NAME" + chmod +x "$PKG_DIR/$P_NAME" + if [ -d assets ]; then cp -r assets "$PKG_DIR/assets"; fi + zip -qr "$PKG_DIR.zip" "$PKG_DIR" + rm -rf "$PKG_DIR" + echo "==> $PKG_DIR.zip kesz" + } + echo "==> Building linux-x64 binary" + cargo build --release + pack_binary "linux-x64" "target/release/<%= name %>" "<%= name %>" + echo "==> Building win-x64 binary" + CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER=x86_64-w64-mingw32-gcc \ + cargo build --release --target x86_64-pc-windows-gnu + pack_binary "win-x64" "target/x86_64-pc-windows-gnu/release/<%= name %>.exe" "<%= name %>.exe" + + - name: upload + image: alpine + environment: + UPDATE_SERVER: <%= update_server %> + UPDATE_SECRET: + from_secret: application_token + commands: + - apk add --no-cache curl + - | + VERSION=$(cat .version) + FILE="<%= name %>-$VERSION.html.zip" + META_SRC="metadata.json" + META_DST="<%= name %>-$VERSION.metadata.json" + cp $META_SRC $META_DST + BINS="" + for slug in win-x64 linux-x64; do + [ -f "<%= name %>-$VERSION-$slug.zip" ] && BINS="$BINS <%= name %>-$VERSION-$slug.zip" + done + for f in $FILE $META_DST $BINS; do + curl -fsS -H "X-Update-Secret: $UPDATE_SECRET" \ + -F "file=@$f" \ + "$UPDATE_SERVER/build/upload?name=<%= name %>&version=$VERSION" || exit 1 + done + + - name: publish + image: alpine + environment: + UPDATE_SERVER: <%= update_server %> + UPDATE_SECRET: + from_secret: application_token + commands: + - apk add --no-cache curl + - | + VERSION=$(cat .version) + curl -fsS -X POST -H "X-Update-Secret: $UPDATE_SECRET" "$UPDATE_SERVER/build/publish?name=<%= name %>&platform=bevy&version=$VERSION" diff --git a/lib/warp_engine/ci_templates/c64.yaml.erb b/lib/warp_engine/ci_templates/c64.yaml.erb new file mode 100644 index 0000000..5a3b910 --- /dev/null +++ b/lib/warp_engine/ci_templates/c64.yaml.erb @@ -0,0 +1,59 @@ +# Generált pipeline — WarpEngine /build/config (platform: c64, name: <%= name %>) +steps: + - name: version + image: alpine + commands: + - | + VERSION=$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' metadata.json | head -n 1) + if [ -z "$VERSION" ]; then + echo "ERROR: no \"version\" field in metadata.json!" + exit 1 + fi + BRANCH=${CI_COMMIT_BRANCH:-${WOODPECKER_BRANCH}} + BRANCH=$(echo "$BRANCH" | tr '/' '-') + if [ "$BRANCH" != "main" ] && [ "$BRANCH" != "master" ] && [ -n "$BRANCH" ]; then + VERSION="dev-$VERSION-$BRANCH" + fi + echo "VERSION is: $VERSION" + echo $VERSION > .version + + - name: build + image: <%= builder %> + commands: + - | + VERSION=$(cat .version) + acme -f cbm -o <%= name %>.prg main.asm + echo "==> Creating versioned files for $VERSION" + cp <%= name %>.prg <%= name %>-$VERSION.prg + cp metadata.json <%= name %>-$VERSION.metadata.json + ls -lh <%= name %>-$VERSION.* + + - name: artifact + image: alpine + environment: + UPDATE_SERVER: <%= update_server %> + UPDATE_SECRET: + from_secret: application_token + commands: + - apk add --no-cache curl + - | + VERSION=$(cat .version) + echo "==> Uploading artifacts for version $VERSION" + for f in <%= name %>-$VERSION.prg <%= name %>-$VERSION.metadata.json; do + curl -fsS -H "X-Update-Secret: $UPDATE_SECRET" \ + -F "file=@$f" \ + "$UPDATE_SERVER/build/upload?name=<%= name %>&version=$VERSION" || exit 1 + done + + - name: publish + image: alpine + environment: + UPDATE_SERVER: <%= update_server %> + UPDATE_SECRET: + from_secret: application_token + commands: + - apk add --no-cache curl + - | + VERSION=$(cat .version) + echo "==> Publishing version $VERSION" + curl -fsS -X POST -H "X-Update-Secret: $UPDATE_SECRET" "$UPDATE_SERVER/build/publish?name=<%= name %>&platform=c64&version=$VERSION" diff --git a/lib/warp_engine/ci_templates/ebitengine.yaml.erb b/lib/warp_engine/ci_templates/ebitengine.yaml.erb new file mode 100644 index 0000000..d29ba62 --- /dev/null +++ b/lib/warp_engine/ci_templates/ebitengine.yaml.erb @@ -0,0 +1,95 @@ +# Generált pipeline — WarpEngine /build/config (platform: ebitengine, name: <%= name %>) +steps: + - name: version + image: alpine + commands: + - apk add --no-cache git jq + - | + if [ -f metadata.json ]; then + VERSION=$(jq -r '.version' metadata.json) + else + VERSION=$(git rev-parse --short HEAD) + fi + BRANCH=$(git rev-parse --abbrev-ref HEAD) + if [ "$BRANCH" != "main" ] && [ "$BRANCH" != "master" ]; then + VERSION="dev-$VERSION-$BRANCH" + fi + echo "VERSION is: $VERSION" + echo $VERSION > .version + + - name: build + image: <%= builder %> + pull: true + commands: + - | + VERSION=$(cat .version) + mkdir -p dist + GOOS=js GOARCH=wasm go build -o dist/game.wasm . + cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" dist/wasm_exec.js + echo "==> Downloading index.html" + curl -sSL https://git.teletypegames.org/tools/ebitengine-tools/raw/branch/master/web/index.html -o dist/index.html + echo "==> Packaging HTML/WASM for $VERSION" + zip -r "<%= name %>-$VERSION.html.zip" -j dist/game.wasm dist/wasm_exec.js dist/index.html + echo "==> Cleaning temporary files" + rm -f dist/game.wasm dist/wasm_exec.js dist/index.html + + - name: binaries + image: <%= builder %> + pull: true + commands: + - | + VERSION=$(cat .version) + # win-x86 / win-x64: pure Go cross-compile (Windowson nem kell cgo) + # linux-x64: cgo build, linux/amd64 hoston fut (builder image, X11/GL dev libekkel) + # belső helper: egy target buildje + zip egyetlen gyökérmappával + # (a zipet unixos zip készíti, így a végrehajtási bit megmarad) + binary_build() { + B_GOOS="$1"; B_GOARCH="$2"; B_CGO="$3"; B_EXT="$4"; B_TARGET="$5" + PKG_DIR="<%= name %>-$VERSION-$B_TARGET" + echo "==> Building $PKG_DIR" + rm -rf "$PKG_DIR" "$PKG_DIR.zip" + mkdir -p "$PKG_DIR" + CGO_ENABLED=$B_CGO GOOS=$B_GOOS GOARCH=$B_GOARCH go build -o "$PKG_DIR/<%= name %>$B_EXT" . + if [ -f LICENSE ]; then cp LICENSE "$PKG_DIR/"; fi + if [ -f README.md ]; then cp README.md "$PKG_DIR/"; fi + zip -r "$PKG_DIR.zip" "$PKG_DIR" >/dev/null + rm -rf "$PKG_DIR" + echo "==> $PKG_DIR.zip kesz" + } + # a CI (linux builder) ezt a hármat építi: + binary_build "windows" "386" "0" ".exe" "win-x86" + binary_build "windows" "amd64" "0" ".exe" "win-x64" + binary_build "linux" "amd64" "1" "" "linux-x64" + + - name: artifact + image: alpine + environment: + UPDATE_SERVER: <%= update_server %> + UPDATE_SECRET: + from_secret: application_token + commands: + - apk add --no-cache curl + - | + VERSION=$(cat .version) + FILE="<%= name %>-$VERSION.html.zip" + META_SRC="metadata.json" + META_DST="<%= name %>-$VERSION.metadata.json" + BINS=$(ls <%= name %>-$VERSION-*.zip 2>/dev/null || true) + cp $META_SRC $META_DST + for f in $FILE $META_DST $BINS; do + curl -fsS -H "X-Update-Secret: $UPDATE_SECRET" \ + -F "file=@$f" \ + "$UPDATE_SERVER/build/upload?name=<%= name %>&version=$VERSION" || exit 1 + done + + - name: publish + image: alpine + environment: + UPDATE_SERVER: <%= update_server %> + UPDATE_SECRET: + from_secret: application_token + commands: + - apk add --no-cache curl + - | + VERSION=$(cat .version) + curl -fsS -X POST -H "X-Update-Secret: $UPDATE_SECRET" "$UPDATE_SERVER/build/publish?name=<%= name %>&platform=ebitengine&version=$VERSION" diff --git a/lib/warp_engine/ci_templates/godot.yaml.erb b/lib/warp_engine/ci_templates/godot.yaml.erb new file mode 100644 index 0000000..7196324 --- /dev/null +++ b/lib/warp_engine/ci_templates/godot.yaml.erb @@ -0,0 +1,100 @@ +# Generált pipeline — WarpEngine /build/config (platform: godot, name: <%= name %>) +steps: + - name: version + image: alpine + commands: + - apk add --no-cache git jq + - | + if [ -f metadata.json ]; then + VERSION=$(jq -r '.version' metadata.json) + else + VERSION=$(git rev-parse --short HEAD) + fi + BRANCH=$(git rev-parse --abbrev-ref HEAD) + if [ "$BRANCH" != "main" ] && [ "$BRANCH" != "master" ]; then + VERSION="dev-$VERSION-$BRANCH" + fi + echo "VERSION is: $VERSION" + echo $VERSION > .version + + - name: build + image: <%= builder %> + commands: + - | + VERSION=$(cat .version) + echo "==> Importing project" + godot --headless --import + echo "==> Exporting web build (Web preset)" + mkdir -p dist/web + godot --headless --export-release "Web" dist/web/index.html + echo "==> Packaging web build for $VERSION" + (cd dist/web && zip -r "../../<%= name %>-$VERSION.html.zip" .) + rm -rf dist/web + - | + VERSION=$(cat .version) + # win/linux target exportja + zip egyetlen gyökérmappával (embed_pck + # miatt az export egyetlen futtatható fájl) + binary_build() { + B_PRESET="$1"; B_EXT="$2"; B_TARGET="$3" + PKG_DIR="<%= name %>-$VERSION-$B_TARGET" + echo "==> Building $PKG_DIR" + rm -rf "$PKG_DIR" "$PKG_DIR.zip" + mkdir -p "$PKG_DIR" + godot --headless --export-release "$B_PRESET" "$(pwd)/$PKG_DIR/<%= name %>$B_EXT" + if [ -f LICENSE ]; then cp LICENSE "$PKG_DIR/"; fi + if [ -f README.md ]; then cp README.md "$PKG_DIR/"; fi + zip -r "$PKG_DIR.zip" "$PKG_DIR" >/dev/null + rm -rf "$PKG_DIR" + echo "==> $PKG_DIR.zip kesz" + } + # mac: a Godot linuxról csak .zip-be tud macOS-t exportálni (benne a + # .app), átcsomagoljuk a gyökérmappás konvencióra (zip -ry: exec bitek + # és symlinkek megmaradnak) + binary_build_mac() { + B_PRESET="$1"; B_TARGET="$2" + PKG_DIR="<%= name %>-$VERSION-$B_TARGET" + echo "==> Building $PKG_DIR" + rm -rf "$PKG_DIR" "$PKG_DIR.zip" + mkdir -p "$PKG_DIR" + godot --headless --export-release "$B_PRESET" "$(pwd)/$PKG_DIR/<%= name %>-mac-tmp.zip" + (cd "$PKG_DIR" && unzip -q "<%= name %>-mac-tmp.zip" && rm "<%= name %>-mac-tmp.zip") + if [ -f LICENSE ]; then cp LICENSE "$PKG_DIR/"; fi + if [ -f README.md ]; then cp README.md "$PKG_DIR/"; fi + zip -ry "$PKG_DIR.zip" "$PKG_DIR" >/dev/null + rm -rf "$PKG_DIR" + echo "==> $PKG_DIR.zip kesz" + } + binary_build "Windows x86" ".exe" "win-x86" + binary_build "Windows x64" ".exe" "win-x64" + binary_build "Linux x64" "" "linux-x64" + binary_build_mac "Mac universal" "mac-universal" + + - name: upload + image: alpine + environment: + UPDATE_SERVER: <%= update_server %> + UPDATE_SECRET: + from_secret: application_token + commands: + - apk add --no-cache curl + - | + VERSION=$(cat .version) + cp metadata.json "<%= name %>-$VERSION.metadata.json" + BINS=$(ls <%= name %>-$VERSION-*.zip 2>/dev/null || true) + for f in "<%= name %>-$VERSION.html.zip" "<%= name %>-$VERSION.metadata.json" $BINS; do + curl -fsS -H "X-Update-Secret: $UPDATE_SECRET" \ + -F "file=@$f" \ + "$UPDATE_SERVER/build/upload?name=<%= name %>&version=$VERSION" || exit 1 + done + + - name: publish + image: alpine + environment: + UPDATE_SERVER: <%= update_server %> + UPDATE_SECRET: + from_secret: application_token + commands: + - apk add --no-cache curl + - | + VERSION=$(cat .version) + curl -fsS -X POST -H "X-Update-Secret: $UPDATE_SECRET" "$UPDATE_SERVER/build/publish?name=<%= name %>&platform=godot&version=$VERSION" diff --git a/lib/warp_engine/ci_templates/love.yaml.erb b/lib/warp_engine/ci_templates/love.yaml.erb new file mode 100644 index 0000000..2c17f5a --- /dev/null +++ b/lib/warp_engine/ci_templates/love.yaml.erb @@ -0,0 +1,174 @@ +# Generált pipeline — WarpEngine /build/config (platform: love, name: <%= name %>) +steps: + - name: version + image: alpine + commands: + - apk add --no-cache git jq + - | + if [ -f metadata.json ]; then + VERSION=$(jq -r '.version' metadata.json) + else + VERSION=$(git rev-parse --short HEAD) + fi + BRANCH=$(git rev-parse --abbrev-ref HEAD) + if [ "$BRANCH" != "main" ] && [ "$BRANCH" != "master" ]; then + VERSION="dev-$VERSION-$BRANCH" + fi + echo "VERSION is: $VERSION" + echo $VERSION > .version + + - name: export + image: <%= builder %> + pull: true + commands: + - | + VERSION=$(cat .version) + mkdir -p dist + echo "==> Building .love package" + zip -r dist/<%= name %>.love . \ + --exclude "*.git*" \ + --exclude "bin/*" \ + --exclude "dist/*" \ + --exclude "Makefile" \ + --exclude ".version" \ + --exclude "metadata.json" \ + --exclude "*.zip" + mkdir -p dist/web + # A love-builder CI image ide előre letölti a love.js-t; lokális + # buildnél GitHubról jön. + if [ -f /opt/lovejs.zip ]; then + echo "==> Using cached love.js (/opt/lovejs.zip)" + cp /opt/lovejs.zip dist/lovejs.zip + else + echo "==> Downloading love.js (2dengine)" + curl -sSL https://github.com/2dengine/love.js/archive/refs/heads/master.zip -o dist/lovejs.zip + fi + unzip -o dist/lovejs.zip -d dist/lovejs-src + rm -f dist/lovejs.zip + echo "==> Assembling web bundle" + cp -r dist/lovejs-src/*/. dist/web/ + rm -rf dist/lovejs-src + cp dist/<%= name %>.love dist/web/<%= name %>.love + echo "==> Patching player.js" + sed -i.bak "s|uri = 'nogame\.love'|uri = '<%= name %>.love'|g" dist/web/player.js && rm dist/web/player.js.bak + echo "==> Patching index.html" + sed -i.bak 's|||g' dist/web/index.html && rm dist/web/index.html.bak + echo "==> Web build ready in dist/web" + echo "==> Packaging Love2D for $VERSION" + zip -r <%= name %>-$VERSION.love.zip dist/<%= name %>.love + echo "==> Packaging web build for $VERSION" + (cd dist/web && zip -r ../../<%= name %>-$VERSION.html.zip .) + echo "==> Cleaning temporary files" + rm -f dist/<%= name %>.love + rm -rf dist/web + + - name: binaries + image: <%= builder %> + pull: true + commands: + - | + VERSION=$(cat .version) + # Az export step törölte a .love-ot, itt újraépítjük (make-ben a + # binary-* targetek love-prerequisite-je tette ugyanezt). + mkdir -p dist + zip -r dist/<%= name %>.love . \ + --exclude "*.git*" \ + --exclude "bin/*" \ + --exclude "dist/*" \ + --exclude "Makefile" \ + --exclude ".version" \ + --exclude "metadata.json" \ + --exclude "*.zip" + # A love-builder CI image a dist-fájlokat /opt/love-dist alá előre + # letölti; lokális buildnél GitHubról jönnek. + fetch_love() { + if [ -f "/opt/love-dist/$1" ]; then + echo "==> Using cached $1" + cp "/opt/love-dist/$1" "dist/$1" + elif [ ! -f "dist/$1" ]; then + echo "==> Downloading $1" + curl -sSL "https://github.com/love2d/love/releases/download/11.5/$1" -o "dist/$1" + fi + } + echo "==> Fusing windows binary" + fetch_love love-11.5-win64.zip + PKG_DIR="<%= name %>-$VERSION-win-x64" + rm -rf "$PKG_DIR" "$PKG_DIR.zip" dist/win64 + unzip -q dist/love-11.5-win64.zip -d dist/win64 + SRC=$(dirname $(find dist/win64 -name love.exe | head -n 1)) + mkdir -p "$PKG_DIR" + cat "$SRC/love.exe" dist/<%= name %>.love > "$PKG_DIR/<%= name %>.exe" + cp "$SRC"/*.dll "$PKG_DIR/" + cp "$SRC/license.txt" "$PKG_DIR/" 2>/dev/null || true + zip -qr "$PKG_DIR.zip" "$PKG_DIR" + rm -rf "$PKG_DIR" dist/win64 + echo "==> $PKG_DIR.zip kesz" + echo "==> Fusing macOS app bundle" + fetch_love love-11.5-macos.zip + PKG_DIR="<%= name %>-$VERSION-mac-universal" + rm -rf "$PKG_DIR" "$PKG_DIR.zip" dist/macos + unzip -q dist/love-11.5-macos.zip -d dist/macos + mkdir -p "$PKG_DIR" + mv dist/macos/love.app "$PKG_DIR/<%= name %>.app" + cp dist/<%= name %>.love "$PKG_DIR/<%= name %>.app/Contents/Resources/" + PLIST="$PKG_DIR/<%= name %>.app/Contents/Info.plist" + sed -i.bak "s|LÖVE|<%= name %>|g" "$PLIST" && rm "$PLIST.bak" + sed -i.bak "s|org\.love2d\.love|org.teletypegames.<%= name %>|g" "$PLIST" && rm "$PLIST.bak" + zip -qry "$PKG_DIR.zip" "$PKG_DIR" + rm -rf "$PKG_DIR" dist/macos + echo "==> $PKG_DIR.zip kesz" + # Az AppImage runtime glibc-dinamikus, alpine (musl) alatt nem + # futtatható, ezért nem a runtime-ot futtatjuk: az offsetet readelf-ből + # számoljuk (shoff + shentsize*shnum), a squashfs-t unsquashfs -o + # bontja ki. + echo "==> Fusing linux AppImage" + fetch_love love-11.5-x86_64.AppImage + PKG_DIR="<%= name %>-$VERSION-linux-x64" + APPIMAGE="dist/love-11.5-x86_64.AppImage" + rm -rf "$PKG_DIR" "$PKG_DIR.zip" squashfs-root dist/game.squashfs dist/runtime + OFFSET=$(readelf -h "$APPIMAGE" | awk '/Start of section headers/{o=$5} /Size of section headers/{s=$5} /Number of section headers/{n=$5} END{print o+s*n}') + unsquashfs -q -o $OFFSET -d squashfs-root "$APPIMAGE" >/dev/null + cat squashfs-root/bin/love dist/<%= name %>.love > squashfs-root/bin/love.fused + mv squashfs-root/bin/love.fused squashfs-root/bin/love + chmod +x squashfs-root/bin/love + mksquashfs squashfs-root dist/game.squashfs -root-owned -noappend -quiet -comp gzip + head -c $OFFSET "$APPIMAGE" > dist/runtime + mkdir -p "$PKG_DIR" + cat dist/runtime dist/game.squashfs > "$PKG_DIR/<%= name %>.AppImage" + chmod +x "$PKG_DIR/<%= name %>.AppImage" + zip -qr "$PKG_DIR.zip" "$PKG_DIR" + rm -rf "$PKG_DIR" squashfs-root dist/game.squashfs dist/runtime + echo "==> $PKG_DIR.zip kesz" + + - name: upload + image: alpine + environment: + UPDATE_SERVER: <%= update_server %> + UPDATE_SECRET: + from_secret: application_token + commands: + - apk add --no-cache curl + - | + VERSION=$(cat .version) + cp metadata.json "<%= name %>-$VERSION.metadata.json" + BINS="" + for slug in win-x64 mac-universal linux-x64; do + [ -f "<%= name %>-$VERSION-$slug.zip" ] && BINS="$BINS <%= name %>-$VERSION-$slug.zip" + done + for f in "<%= name %>-$VERSION.love.zip" "<%= name %>-$VERSION.html.zip" "<%= name %>-$VERSION.metadata.json" $BINS; do + curl -fsS -H "X-Update-Secret: $UPDATE_SECRET" \ + -F "file=@$f" \ + "$UPDATE_SERVER/build/upload?name=<%= name %>&version=$VERSION" || exit 1 + done + + - name: publish + image: alpine + environment: + UPDATE_SERVER: <%= update_server %> + UPDATE_SECRET: + from_secret: application_token + commands: + - apk add --no-cache curl + - | + VERSION=$(cat .version) + curl -fsS -X POST -H "X-Update-Secret: $UPDATE_SECRET" "$UPDATE_SERVER/build/publish?name=<%= name %>&platform=love&version=$VERSION" diff --git a/lib/warp_engine/ci_templates/phaser.yaml.erb b/lib/warp_engine/ci_templates/phaser.yaml.erb new file mode 100644 index 0000000..aa858f9 --- /dev/null +++ b/lib/warp_engine/ci_templates/phaser.yaml.erb @@ -0,0 +1,69 @@ +# Generált pipeline — WarpEngine /build/config (platform: phaser, name: <%= name %>) +steps: + - name: version + image: alpine + commands: + - apk add --no-cache git jq + - | + if [ -f metadata.json ]; then + VERSION=$(jq -r '.version' metadata.json) + else + VERSION=$(git rev-parse --short HEAD) + fi + BRANCH=$(git rev-parse --abbrev-ref HEAD) + if [ "$BRANCH" != "main" ] && [ "$BRANCH" != "master" ]; then + VERSION="dev-$VERSION-$BRANCH" + fi + echo "VERSION is: $VERSION" + echo $VERSION > .version + + - name: build + image: <%= builder %> + commands: + - | + VERSION=$(cat .version) + echo "==> Checking JS syntax" + for f in src/*.js; do node --check $f; done + mkdir -p dist/web + echo "==> Downloading Phaser 3.90.0" + curl -sSL https://cdn.jsdelivr.net/npm/phaser@3.90.0/dist/phaser.min.js -o dist/web/phaser.min.js + echo "==> Downloading index.html" + curl -sSL https://git.teletypegames.org/tools/phaser-tools/raw/branch/master/web/index.html -o dist/web/index.html + echo "==> Bundling game sources" + cat src/*.js > dist/web/game.js + echo "==> Packaging web build for $VERSION" + (cd dist/web && zip -r "../../<%= name %>-$VERSION.html.zip" .) + echo "==> Cleaning temporary files" + rm -rf dist/web + + - name: upload + image: alpine + environment: + UPDATE_SERVER: <%= update_server %> + UPDATE_SECRET: + from_secret: application_token + commands: + - apk add --no-cache curl + - | + VERSION=$(cat .version) + FILE="<%= name %>-$VERSION.html.zip" + META_SRC="metadata.json" + META_DST="<%= name %>-$VERSION.metadata.json" + cp $META_SRC $META_DST + for f in $FILE $META_DST; do + curl -fsS -H "X-Update-Secret: $UPDATE_SECRET" \ + -F "file=@$f" \ + "$UPDATE_SERVER/build/upload?name=<%= name %>&version=$VERSION" || exit 1 + done + + - name: publish + image: alpine + environment: + UPDATE_SERVER: <%= update_server %> + UPDATE_SECRET: + from_secret: application_token + commands: + - apk add --no-cache curl + - | + VERSION=$(cat .version) + curl -fsS -X POST -H "X-Update-Secret: $UPDATE_SECRET" "$UPDATE_SERVER/build/publish?name=<%= name %>&platform=phaser&version=$VERSION" diff --git a/lib/warp_engine/ci_templates/tic80.yaml.erb b/lib/warp_engine/ci_templates/tic80.yaml.erb new file mode 100644 index 0000000..960a10f --- /dev/null +++ b/lib/warp_engine/ci_templates/tic80.yaml.erb @@ -0,0 +1,188 @@ +# Generált pipeline — WarpEngine /build/config (platform: tic80, name: <%= name %>) +# A verzió a forrásból jön (inc/meta/meta.header.lua "-- version:" komment) — a +# WarpEngine is a Lua headerből parsolja a tic80-metadatát, ezért nincs metadata.json. +steps: + - name: version + image: alpine + commands: + - | + VERSION=$(sed -n "s/^-- version: //p" inc/meta/meta.header.lua | head -n 1 | tr -d "[:space:]") + BRANCH=${CI_COMMIT_BRANCH:-${WOODPECKER_BRANCH}} + BRANCH=$(echo "$BRANCH" | tr '/' '-') + if [ "$BRANCH" != "main" ] && [ "$BRANCH" != "master" ] && [ -n "$BRANCH" ]; then + VERSION=dev-$VERSION-$BRANCH + fi + echo "VERSION is: $VERSION" + echo $VERSION > .version + + - name: lint + image: <%= builder %> + commands: + - | + echo "==> Merging..." + rm -f /tmp/_lint_combined.lua /tmp/_lint_map.txt + touch /tmp/_lint_combined.lua + line=1 + while IFS= read -r f || [ -n "$f" ]; do + f=$(printf '%s' "$f" | tr -d '\r') + [ -z "$f" ] && continue + before=$(wc -l < /tmp/_lint_combined.lua) + cat "inc/$f" >> /tmp/_lint_combined.lua + printf '\n' >> /tmp/_lint_combined.lua + after=$(wc -l < /tmp/_lint_combined.lua) + linecount=$((after - before)) + echo "$line $linecount inc/$f" >> /tmp/_lint_map.txt + line=$((line + linecount)) + done < <%= name %>.inc + echo "==> luacheck..." + LINT_OUTPUT=$(luacheck --no-max-line-length /tmp/_lint_combined.lua 2>&1 | awk -v map=/tmp/_lint_map.txt ' + BEGIN { + NR_map = 0; + while ((getline line < map) > 0) { + n = split(line, a, " "); + start[NR_map] = a[1]+0; + count[NR_map] = a[2]+0; + fname[NR_map] = a[3]; + NR_map++; + } + } + /^[^:]+:[0-9]+:[0-9]+:/ { + colon1 = index($0, ":"); + rest1 = substr($0, colon1+1); + colon2 = index(rest1, ":"); + absline = substr(rest1, 1, colon2-1) + 0; + rest2 = substr(rest1, colon2+1); + colon3 = index(rest2, ":"); + col = substr(rest2, 1, colon3-1); + rest = substr(rest2, colon3); + found = 0; + for (i = 0; i < NR_map; i++) { + end_line = start[i] + count[i] -1; + if (absline >= start[i] && absline <= end_line) { + relline = absline - start[i] + 1; + print fname[i] ":" relline ":" col ":" rest; + found = 1; + break; + } + } + if (!found) print $0; + next; + } + { print } + ') + echo "$LINT_OUTPUT" + NUM_ISSUES=$(echo "$LINT_OUTPUT" | grep -cE "^[^:]+:[0-9]+:[0-9]+:" || true) + if [ "$NUM_ISSUES" -gt 0 ]; then + echo "Total: $NUM_ISSUES issue(s) found, commit aborted." + exit 1 + else + echo "Checking /tmp/_lint_combined.lua OK" + echo "Total: 0 warnings / 0 errors in 1 file" + fi + rm -f /tmp/_lint_combined.lua /tmp/_lint_map.txt + + - name: minify + image: <%= builder %> + commands: + - | + rm -f <%= name %>.lua + sed 's/\r$//' <%= name %>.inc | while read f; do + cat "inc/$f" >> <%= name %>.lua + echo "" >> <%= name %>.lua + done + test -f minify.lua || { echo "==> Downloading minify.lua"; curl -fsSL https://raw.githubusercontent.com/ztimar31/lua-minify-tic80/refs/heads/master/minify.lua -o minify.lua; } + echo "==> Minifying <%= name %>.lua" + cp <%= name %>.lua <%= name %>.original.lua + lua minify.lua minify <%= name %>.original.lua > <%= name %>.lua + + - name: docs + image: <%= builder %> + commands: + - | + VERSION=$(cat .version) + echo "==> Generating docs from <%= name %>.original.lua" + ldoc <%= name %>.original.lua -d docs + echo "==> Zipping docs for version $VERSION" + (cd docs && zip -r ../<%= name %>-$VERSION-docs.zip .) + cp <%= name %>-$VERSION-docs.zip <%= name %>-docs.zip + echo "==> Docs zip created" + + - name: export + image: <%= exporter %> + environment: + XDG_RUNTIME_DIR: /tmp + commands: + - | + VERSION=$(cat .version) + echo "==> Exporting HTML for version $VERSION" + tic80 --cli --skip --fs=. \ + --cmd="load <%= name %>.lua & save <%= name %>-$VERSION & export html <%= name %>-$VERSION.html & exit" + if [ -f "<%= name %>-$VERSION.tic" ]; then + cp <%= name %>-$VERSION.tic <%= name %>.tic + fi + if [ -f "<%= name %>-$VERSION.html.zip" ]; then + cp <%= name %>-$VERSION.html.zip <%= name %>.html.zip + fi + echo "==> Generated files:" + ls -lh <%= name %>-$VERSION.* <%= name %>.tic <%= name %>.html.zip 2>/dev/null || true + + - name: binaries + image: <%= exporter %> + environment: + XDG_RUNTIME_DIR: /tmp + commands: + - | + VERSION=$(cat .version) + echo "==> Exporting native players for version $VERSION" + tic80 --cli --skip --fs=. \ + --cmd="load <%= name %>.lua & export win <%= name %>-win & export linux <%= name %>-linux & export mac <%= name %>-mac & exit" + # unixos zip őrzi a végrehajtási bitet + pack_binary() { + SLUG="$1"; SRC_FILE="$2"; DST_FILE="$3" + PKG_DIR="<%= name %>-$VERSION-$SLUG" + rm -rf "$PKG_DIR" "$PKG_DIR.zip" + mkdir -p "$PKG_DIR" + mv "$SRC_FILE" "$PKG_DIR/$DST_FILE" + chmod +x "$PKG_DIR/$DST_FILE" + zip -r "$PKG_DIR.zip" "$PKG_DIR" >/dev/null + rm -rf "$PKG_DIR" + echo "==> $PKG_DIR.zip kesz" + } + pack_binary win-x64 <%= name %>-win.exe <%= name %>.exe + pack_binary linux-x64 <%= name %>-linux <%= name %> + pack_binary mac-x64 <%= name %>-mac <%= name %> + + - name: upload + image: alpine + environment: + UPDATE_SERVER: <%= update_server %> + UPDATE_SECRET: + from_secret: application_token + commands: + - apk add --no-cache curl + - | + VERSION=$(cat .version) + echo "==> Uploading artifacts for version $VERSION" + cp <%= name %>.lua <%= name %>-$VERSION.lua + BINS="" + for slug in win-x64 linux-x64 mac-x64; do + [ -f "<%= name %>-$VERSION-$slug.zip" ] && BINS="$BINS <%= name %>-$VERSION-$slug.zip" + done + for f in <%= name %>-$VERSION.lua <%= name %>-$VERSION.tic <%= name %>-$VERSION.html.zip <%= name %>-$VERSION-docs.zip $BINS; do + curl -fsS -H "X-Update-Secret: $UPDATE_SECRET" \ + -F "file=@$f" \ + "$UPDATE_SERVER/build/upload?name=<%= name %>&version=$VERSION" || exit 1 + done + + - name: publish + image: alpine + environment: + UPDATE_SERVER: <%= update_server %> + UPDATE_SECRET: + from_secret: application_token + commands: + - apk add --no-cache curl + - | + VERSION=$(cat .version) + echo "==> Publishing version $VERSION" + curl -fsS -X POST -H "X-Update-Secret: $UPDATE_SECRET" "$UPDATE_SERVER/build/publish?name=<%= name %>&platform=tic80&version=$VERSION" diff --git a/lib/warp_engine/configuration.rb b/lib/warp_engine/configuration.rb index aaeed4a..765b29c 100644 --- a/lib/warp_engine/configuration.rb +++ b/lib/warp_engine/configuration.rb @@ -13,6 +13,13 @@ module WarpEngine # enforce_software_ownership: ha true, egy DB-token csak a saját ownerének # szoftvereit uploadolhatja/publisholhatja (unrestricted token kivétel). # Bekapcsolás CSAK backfill után: gazdátlan software-t bármely token elvihet. + # ci_platforms: a /build/config által kiszolgált platformok: + # { "godot" => { builder: "" }, "tic80" => { builder: ..., exporter: ... } } + # Üres map = a feature inaktív (POST → 204, GET → 404). + # ci_extension_public_key(_url): a Woodpecker httpsig ed25519 publikus kulcsa + # PEM-ben, vagy URL, ahonnan letölthető (pl. https://ci.../api/signature/public-key). + # Egyik sincs beállítva → a POST /build/config minden kérést elutasít. + # ci_update_server: az upload/publish stepekbe írt szerver-URL; nil → a kérés base_url-je. attr_accessor :file_container_path, :image_container_path, :update_secret, @@ -20,6 +27,10 @@ module WarpEngine :application_token_owner_class, :max_upload_size, :enforce_software_ownership, + :ci_platforms, + :ci_extension_public_key, + :ci_extension_public_key_url, + :ci_update_server, :image_owners def initialize @@ -30,6 +41,10 @@ module WarpEngine @application_token_owner_class = nil @max_upload_size = 500 * 1024 * 1024 @enforce_software_ownership = false + @ci_platforms = {} + @ci_extension_public_key = nil + @ci_extension_public_key_url = nil + @ci_update_server = nil @image_owners = [] end end diff --git a/spec/requests/build_configs_controller_spec.rb b/spec/requests/build_configs_controller_spec.rb new file mode 100644 index 0000000..c8c8d9e --- /dev/null +++ b/spec/requests/build_configs_controller_spec.rb @@ -0,0 +1,176 @@ +require "rails_helper" + +RSpec.describe "Build configs endpoint", type: :request do + let(:signing_key) { OpenSSL::PKey.generate_key("ed25519") } + let(:ci_platforms) do + { + "godot" => { builder: "registry.example/godot-builder:4.6" }, + "tic80" => { builder: "registry.example/tic80-builder:1.0", + exporter: "registry.example/tic80pro:1.0" } + } + end + + before do + allow(WarpEngine.config).to receive(:ci_platforms).and_return(ci_platforms) + 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") + date = Time.now.httpdate + signing_string = "(request-target): #{method} #{path}\ndate: #{date}" + signature = Base64.strict_encode64(signing_key.sign(nil, signing_string)) + { + "Date" => date, + "Signature" => %(keyId="woodpecker-ci-plugins",algorithm="ed25519",headers="(request-target) date",signature="#{signature}"), + "Content-Type" => "application/json" + } + end + + def extension_payload(marker_yaml, repo_name: "mygame") + { + repo: { name: repo_name }, + pipeline: { branch: "master" }, + configuration: [ { name: ".woodpecker.yaml", data: marker_yaml } ] + }.to_json + end + + describe "GET /build/config" do + it "renders the pipeline for a configured platform" do + get "/build/config", params: { platform: "godot", name: "mygame" } + + expect(response).to have_http_status(:ok) + pipeline = YAML.safe_load(response.body) + expect(pipeline["steps"]).to be_present + expect(response.body).to include("registry.example/godot-builder:4.6") + expect(response.body).to include("mygame") + end + + it "returns 404 for an unknown platform" do + get "/build/config", params: { platform: "nope" } + + expect(response).to have_http_status(:not_found) + end + + it "returns 404 when the feature is not configured" do + allow(WarpEngine.config).to receive(:ci_platforms).and_return({}) + + get "/build/config", params: { platform: "godot" } + + expect(response).to have_http_status(:not_found) + end + + it "rejects path traversal in the platform param" do + get "/build/config", params: { platform: "../secrets" } + + expect(response).to have_http_status(:not_found) + end + end + + 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 + + expect(response).to have_http_status(:ok) + configs = response.parsed_body["configs"] + expect(configs.length).to eq(1) + expect(configs.first["name"]).to eq("godot") + pipeline = YAML.safe_load(configs.first["data"]) + expect(pipeline["steps"].map { |s| s["name"] }).to include("version", "publish") + expect(configs.first["data"]).to include("mygame") + 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 + + expect(response.parsed_body["configs"].first["data"]).to include("othername") + expect(response.parsed_body["configs"].first["data"]).not_to include("mygame") + end + + it "accepts the configs key used by older Woodpecker payloads" do + payload = { repo: { name: "mygame" }, + configs: [ { name: ".woodpecker.yaml", data: "platform: godot\n" } ] }.to_json + + post "/build/config", params: payload, headers: 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 + + 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 + + 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 + + expect(response).to have_http_status(:unprocessable_entity) + end + + it "rejects a request with an invalid signature" do + headers = signed_headers + 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 + + expect(response).to have_http_status(:forbidden) + end + + it "rejects a request without a signature header" do + post "/build/config", params: extension_payload("platform: godot\n"), + headers: { "Content-Type" => "application/json" } + + expect(response).to have_http_status(:forbidden) + end + + it "rejects every request when no public key is configured" 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 + + expect(response).to have_http_status(:forbidden) + end + end + + 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")] + expect(templates).not_to be_empty + + templates.each do |path| + platform = File.basename(path, ".yaml.erb") + allow(WarpEngine.config).to receive(:ci_platforms).and_return( + platform => { builder: "registry.example/builder:1", exporter: "registry.example/exporter:1" } + ) + + yaml = WarpEngine::CiConfigService.new.render( + platform: platform, name: "example", update_server: "https://games.example" + ) + + expect(yaml).to be_present, "#{platform}: no template rendered" + pipeline = YAML.safe_load(yaml) + expect(pipeline["steps"]).to be_present, "#{platform}: no steps" + pipeline["steps"].each do |step| + expect(step["image"]).to be_present, "#{platform}/#{step['name']}: missing image" + expect(step["commands"]).to be_present, "#{platform}/#{step['name']}: missing commands" + end + end + end + end +end