From c80260552546720a31dfd7dcc416281074bd2da2 Mon Sep 17 00:00:00 2001 From: Zsolt Tasnadi Date: Mon, 24 Aug 2026 13:46:15 +0200 Subject: [PATCH] gitlab --- lib/warp_engine/ci.rb | 8 + lib/warp_engine/ci/gitlab.rb | 4 + lib/warp_engine/ci/gitlab/adapter.rb | 144 +++++++++++ lib/warp_engine/ci/gitlab/client.rb | 145 +++++++++++ lib/warp_engine/ci/gitlab/pipeline_config.rb | 59 +++++ .../gitlab/platforms/bevy/pipeline.yaml.erb | 129 ++++++++++ .../ci/gitlab/platforms/c64/pipeline.yaml.erb | 74 ++++++ .../platforms/ebitengine/pipeline.yaml.erb | 118 +++++++++ .../gitlab/platforms/godot/pipeline.yaml.erb | 114 +++++++++ .../gitlab/platforms/love/pipeline.yaml.erb | 191 ++++++++++++++ .../gitlab/platforms/phaser/pipeline.yaml.erb | 106 ++++++++ .../gitlab/platforms/tic80/pipeline.yaml.erb | 232 ++++++++++++++++++ .../ci/gitlab/signature_verifier.rb | 19 ++ spec/ci/built_kinds_spec.rb | 20 +- spec/ci/gitlab_client_spec.rb | 142 +++++++++++ spec/ci/gitlab_signature_verifier_spec.rb | 45 ++++ spec/ci_spec.rb | 112 +++++++++ 17 files changed, 1656 insertions(+), 6 deletions(-) create mode 100644 lib/warp_engine/ci/gitlab.rb create mode 100644 lib/warp_engine/ci/gitlab/adapter.rb create mode 100644 lib/warp_engine/ci/gitlab/client.rb create mode 100644 lib/warp_engine/ci/gitlab/pipeline_config.rb create mode 100644 lib/warp_engine/ci/gitlab/platforms/bevy/pipeline.yaml.erb create mode 100644 lib/warp_engine/ci/gitlab/platforms/c64/pipeline.yaml.erb create mode 100644 lib/warp_engine/ci/gitlab/platforms/ebitengine/pipeline.yaml.erb create mode 100644 lib/warp_engine/ci/gitlab/platforms/godot/pipeline.yaml.erb create mode 100644 lib/warp_engine/ci/gitlab/platforms/love/pipeline.yaml.erb create mode 100644 lib/warp_engine/ci/gitlab/platforms/phaser/pipeline.yaml.erb create mode 100644 lib/warp_engine/ci/gitlab/platforms/tic80/pipeline.yaml.erb create mode 100644 lib/warp_engine/ci/gitlab/signature_verifier.rb create mode 100644 spec/ci/gitlab_client_spec.rb create mode 100644 spec/ci/gitlab_signature_verifier_spec.rb diff --git a/lib/warp_engine/ci.rb b/lib/warp_engine/ci.rb index 108b17a..06a6b32 100644 --- a/lib/warp_engine/ci.rb +++ b/lib/warp_engine/ci.rb @@ -69,23 +69,30 @@ module WarpEngine case configured when nil, :woodpecker, "woodpecker" then woodpecker_adapter + when :gitlab, "gitlab" then gitlab_adapter when :none, "none", :null then null_adapter else configured end end def woodpecker? = adapter.is_a?(Woodpecker::Adapter) + def gitlab? = adapter.is_a?(Gitlab::Adapter) def woodpecker_adapter @woodpecker_adapter ||= Woodpecker::Adapter.new end + def gitlab_adapter + @gitlab_adapter ||= Gitlab::Adapter.new + end + def null_adapter @null_adapter ||= Null.new end def reset! @woodpecker_adapter = nil + @gitlab_adapter = nil @null_adapter = nil end end @@ -93,3 +100,4 @@ module WarpEngine end require "warp_engine/ci/woodpecker" +require "warp_engine/ci/gitlab" diff --git a/lib/warp_engine/ci/gitlab.rb b/lib/warp_engine/ci/gitlab.rb new file mode 100644 index 0000000..c4ab4bd --- /dev/null +++ b/lib/warp_engine/ci/gitlab.rb @@ -0,0 +1,4 @@ +require "warp_engine/ci/gitlab/client" +require "warp_engine/ci/gitlab/signature_verifier" +require "warp_engine/ci/gitlab/pipeline_config" +require "warp_engine/ci/gitlab/adapter" diff --git a/lib/warp_engine/ci/gitlab/adapter.rb b/lib/warp_engine/ci/gitlab/adapter.rb new file mode 100644 index 0000000..156b4e5 --- /dev/null +++ b/lib/warp_engine/ci/gitlab/adapter.rb @@ -0,0 +1,144 @@ +module WarpEngine + module CI + module Gitlab + class Adapter + attr_reader :url, :update_server + + def initialize(url: ENV["GITLAB_URL"], + api_token: ENV["GITLAB_API_TOKEN"], + webhook_secret: ENV["GITLAB_WEBHOOK_SECRET"], + platforms: {}, + update_server: nil) + @url = url.presence + @api_token = api_token.presence + @webhook_secret = webhook_secret.presence + @update_server = update_server.presence + @config = PipelineConfig.new(platforms: platforms || {}) + end + + def name = "GitLab" + + def configured? = @url.present? && @api_token.present? + + def platforms = @config.platforms + + def built_kinds(platform) = @config.built_kinds(platform) + + def client + @client ||= Client.new(url: @url, token: @api_token) + end + + def repos + Array(client.list_repos).map { |remote| to_repo(remote) } + end + + def repo(project_id) + to_repo(client.get_repo(project_id)) + end + + def activate_repo(project_id) + client.activate_repo(project_id) + nil + end + + def deactivate_repo(project_id) + client.deactivate_repo(project_id) + nil + end + + def runs(project_id, page: 1) + Array(client.list_pipelines(project_id, page: page)).map { |run| to_run(run) } + end + + def run(project_id, number) + remote = if number.to_s == "latest" + client.latest_pipeline(project_id) + else + client.get_pipeline(project_id, number) + end + remote && to_run(remote) + end + + def trigger(project_id, branch: "main") + to_run(client.trigger_pipeline(project_id, branch: branch || "main")) + end + + def secret_names(project_id) + Array(client.list_secrets(project_id)).map { |var| var["key"] } + end + + def secret_set(project_id, name:, value:) + if secret_names(project_id).include?(name) + client.update_secret(project_id, name, value: value) + else + client.create_secret(project_id, name: name, value: value) + end + nil + end + + def secret_delete(project_id, name) + client.delete_secret(project_id, name) + nil + end + + def verify_config_request(request) + SignatureVerifier.new(request, webhook_secret: @webhook_secret).valid? + end + + def config_marker(params) + configs = params[:configuration].presence || params[:configs].presence || [] + Array(configs).each do |config| + parsed = parse_marker(config[:data].to_s) + next unless parsed + + return { platform: parsed["platform"].to_s, + name: parsed["name"].presence || params.dig(:repo, :name).to_s } + end + nil + end + + def pipeline_config(platform:, name:, update_server:) + @config.render(platform: platform, name: name, update_server: update_server) + end + + def config_response(platform:, config:) + { configs: [ { name: platform, data: config } ] } + end + + private + + def parse_marker(data) + parsed = begin + YAML.safe_load(data) + rescue Psych::Exception + nil + end + parsed if parsed.is_a?(Hash) && parsed.key?("platform") + end + + def to_repo(remote) + return nil if remote.nil? + + namespace = remote.dig("namespace", "path") || remote.dig("namespace", "name") + Repo.new(id: remote["id"], name: remote["path"] || remote["name"], + owner: namespace, active: true, raw: remote) + end + + def to_run(remote) + return nil if remote.nil? + + created = remote["created_at"] ? Time.zone.parse(remote["created_at"]) : nil + Run.new( + number: remote["id"], + status: remote["status"], + branch: remote["ref"], + message: nil, + created_at: created, + url: remote["web_url"], + raw: remote + ) + end + end + end + end +end diff --git a/lib/warp_engine/ci/gitlab/client.rb b/lib/warp_engine/ci/gitlab/client.rb new file mode 100644 index 0000000..78f6bb6 --- /dev/null +++ b/lib/warp_engine/ci/gitlab/client.rb @@ -0,0 +1,145 @@ +require "net/http" +require "json" +require "uri" + +module WarpEngine + module CI + module Gitlab + class Client + def initialize(url:, token:) + @base_url = url.to_s.chomp("/") + @token = token + end + + def list_repos + get("/api/v4/projects", params: { membership: true, simple: true, per_page: 100 }) + end + + def get_repo(project_id) + get("/api/v4/projects/#{project_id}") + end + + def activate_repo(_project_id) + nil + end + + def deactivate_repo(_project_id) + nil + end + + def list_secrets(project_id) + get("/api/v4/projects/#{project_id}/variables") + end + + def create_secret(project_id, name:, value:) + post("/api/v4/projects/#{project_id}/variables", + body: { key: name, value: value, protected: false, masked: true }) + end + + def update_secret(project_id, secret_name, value:) + put("/api/v4/projects/#{project_id}/variables/#{secret_name}", + body: { value: value }) + end + + def delete_secret(project_id, secret_name) + delete("/api/v4/projects/#{project_id}/variables/#{secret_name}") + end + + def list_pipelines(project_id, page: 1, per_page: 25) + get("/api/v4/projects/#{project_id}/pipelines", + params: { page: page, per_page: per_page }) + end + + def latest_pipeline(project_id) + results = get("/api/v4/projects/#{project_id}/pipelines", + params: { per_page: 1, sort: "desc" }) + Array(results).first + end + + def get_pipeline(project_id, pipeline_id) + get("/api/v4/projects/#{project_id}/pipelines/#{pipeline_id}") + end + + def trigger_pipeline(project_id, branch: "main") + post("/api/v4/projects/#{project_id}/pipeline", + body: { ref: branch }) + end + + private + + def get(path, params: {}) + uri = build_uri(path, params) + execute(uri, Net::HTTP::Get.new(uri)) + end + + def post(path, body: {}) + uri = build_uri(path) + request = Net::HTTP::Post.new(uri) + request.body = body.to_json + request.content_type = "application/json" + execute(uri, request) + end + + def put(path, body: {}) + uri = build_uri(path) + request = Net::HTTP::Put.new(uri) + request.body = body.to_json + request.content_type = "application/json" + execute(uri, request) + end + + def delete(path) + uri = build_uri(path) + execute(uri, Net::HTTP::Delete.new(uri)) + end + + def build_uri(path, params = {}) + uri = URI.parse("#{@base_url}#{path}") + uri.query = URI.encode_www_form(params) if params.any? + uri + end + + def execute(uri, request) + request["PRIVATE-TOKEN"] = @token + request["Accept"] = "application/json" + + response = Net::HTTP.start(uri.hostname, uri.port, + use_ssl: uri.scheme == "https", + open_timeout: 10, + read_timeout: 30) do |http| + http.request(request) + end + + handle_response(uri, response) + rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Net::OpenTimeout, + Net::ReadTimeout, SocketError => e + raise CI::ConnectionError, "Cannot reach GitLab at #{@base_url}: #{e.message}" + end + + def handle_response(uri, response) + case response + when Net::HTTPSuccess, Net::HTTPNoContent + return nil if response.body.blank? + + begin + JSON.parse(response.body) + rescue JSON::ParserError + raise CI::ApiError.new( + "Expected JSON from #{uri.path} but got: #{response.body.truncate(80)}", + status: response.code.to_i, body: response.body + ) + end + when Net::HTTPNotFound + raise CI::ApiError.new("Not found: #{uri.path}", status: 404, body: response.body) + else + raise CI::ApiError.new( + "GitLab API error #{response.code}: #{response.body&.truncate(200)}", + status: response.code.to_i, + body: response.body + ) + end + end + end + end + end +end diff --git a/lib/warp_engine/ci/gitlab/pipeline_config.rb b/lib/warp_engine/ci/gitlab/pipeline_config.rb new file mode 100644 index 0000000..8d143ff --- /dev/null +++ b/lib/warp_engine/ci/gitlab/pipeline_config.rb @@ -0,0 +1,59 @@ +require "erb" +require "pathname" + +module WarpEngine + module CI + module Gitlab + class PipelineConfig + PLATFORM_FORMAT = /\A[a-z0-9_-]+\z/ + + BUILT_KINDS = { + "tic80" => %w[cartridge source html docs win_x64 linux_x64 mac_x64], + "ebitengine" => %w[html win_x86 win_x64 linux_x64 linux_arm64], + "godot" => %w[html win_x86 win_x64 linux_x64 mac_universal], + "love" => %w[html win_x64 linux_x64 mac_universal], + "bevy" => %w[html win_x64 linux_x64 linux_arm64], + "c64" => %w[cartridge], + "phaser" => %w[html] + }.freeze + + def initialize(platforms: {}) + @platforms = platforms.to_h { |key, spec| [ key.to_s, spec.to_h.symbolize_keys ] } + end + + def platforms + @platforms + end + + def render(platform:, name:, update_server:) + platform = platform.to_s + return nil unless platform.match?(PLATFORM_FORMAT) + + spec = @platforms[platform] + return nil if spec.nil? + + path = self.class.templates_dir.join(platform, "pipeline.yaml.erb") + return nil unless path.exist? + + ERB.new(path.read, trim_mode: "-").result_with_hash( + name: name.to_s, + update_server: update_server.to_s, + builder: spec[:builder], + exporter: spec[:exporter] + ) + end + + def built_kinds(platform) + platform = platform.to_s + return nil unless @platforms.key?(platform) + + BUILT_KINDS[platform] + end + + def self.templates_dir + Pathname.new(__dir__).join("platforms") + end + end + end + end +end diff --git a/lib/warp_engine/ci/gitlab/platforms/bevy/pipeline.yaml.erb b/lib/warp_engine/ci/gitlab/platforms/bevy/pipeline.yaml.erb new file mode 100644 index 0000000..7eedfb0 --- /dev/null +++ b/lib/warp_engine/ci/gitlab/platforms/bevy/pipeline.yaml.erb @@ -0,0 +1,129 @@ +# Generated pipeline — WarpEngine /build/config (platform: bevy, name: <%= name %>) +stages: + - version + - build + - binaries + - upload + - publish + +version: + stage: version + image: alpine + script: + - apk add --no-cache git jq + - | + if [ -f metadata.json ]; then + VERSION=$(jq -r '.version' metadata.json) + else + VERSION=$(git rev-parse --short HEAD) + fi + BRANCH=$(git rev-parse --abbrev-ref HEAD) + if [ "$BRANCH" != "main" ] && [ "$BRANCH" != "master" ]; then + VERSION="dev-$VERSION-$BRANCH" + fi + echo "VERSION is: $VERSION" + echo $VERSION > .version + artifacts: + paths: + - .version + - metadata.json + +build: + stage: build + image: <%= builder %> + script: + - | + VERSION=$(cat .version) + mkdir -p dist + cargo build --release --target wasm32-unknown-unknown + wasm-bindgen --target web --no-typescript \ + --out-dir dist --out-name game target/wasm32-unknown-unknown/release/<%= name %>.wasm + echo "==> Downloading index.html" + curl -sSL https://git.teletypegames.org/tools/bevy-tools/raw/branch/master/web/index.html -o dist/index.html + echo "==> Packaging HTML/WASM for $VERSION" + zip -r "<%= name %>-$VERSION.html.zip" -j dist/game_bg.wasm dist/game.js dist/index.html + echo "==> Cleaning temporary files" + rm -f dist/game_bg.wasm dist/game.js dist/index.html + artifacts: + paths: + - "*.zip" + +binaries: + stage: binaries + image: <%= builder %> + script: + - | + VERSION=$(cat .version) + # Native binaries. linux-x64: glibc build in the debian-based builder + # image; win-x64: mingw-w64 cross-compile (x86_64-pc-windows-gnu). + # Mac needs osxcross, it is not built here. + # The zip gets the assets/ dir too if the project has one — bevy loads + # it at runtime, it is not embedded in the binary. + set -e + pack_binary() { + P_SLUG="$1"; P_BIN="$2"; P_NAME="$3" + PKG_DIR="<%= name %>-$VERSION-$P_SLUG" + rm -rf "$PKG_DIR" "$PKG_DIR.zip" + mkdir -p "$PKG_DIR" + cp "$P_BIN" "$PKG_DIR/$P_NAME" + chmod +x "$PKG_DIR/$P_NAME" + if [ -d assets ]; then cp -r assets "$PKG_DIR/assets"; fi + zip -qr "$PKG_DIR.zip" "$PKG_DIR" + rm -rf "$PKG_DIR" + echo "==> $PKG_DIR.zip kesz" + } + echo "==> Building linux-x64 binary" + cargo build --release + pack_binary "linux-x64" "target/release/<%= name %>" "<%= name %>" + echo "==> Building win-x64 binary" + CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER=x86_64-w64-mingw32-gcc \ + cargo build --release --target x86_64-pc-windows-gnu + pack_binary "win-x64" "target/x86_64-pc-windows-gnu/release/<%= name %>.exe" "<%= name %>.exe" + # linux-arm64: Raspberry Pi, Odroid, retro handhelds. pkg-config has to + # be told it may cross, and pointed at the arm64 .pc files, otherwise + # alsa-sys/libudev-sys pick up the host x86_64 libraries. + echo "==> Building linux-arm64 binary" + PKG_CONFIG_ALLOW_CROSS=1 \ + PKG_CONFIG_PATH=/usr/lib/aarch64-linux-gnu/pkgconfig \ + CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc \ + cargo build --release --target aarch64-unknown-linux-gnu + pack_binary "linux-arm64" "target/aarch64-unknown-linux-gnu/release/<%= name %>" "<%= name %>" + artifacts: + paths: + - "*.zip" + +upload: + stage: upload + image: alpine + variables: + UPDATE_SERVER: "<%= update_server %>" + UPDATE_SECRET: "$application_token" + script: + - apk add --no-cache curl + - | + VERSION=$(cat .version) + FILE="<%= name %>-$VERSION.html.zip" + META_SRC="metadata.json" + META_DST="<%= name %>-$VERSION.metadata.json" + cp $META_SRC $META_DST + BINS="" + for slug in win-x64 linux-x64 linux-arm64; do + [ -f "<%= name %>-$VERSION-$slug.zip" ] && BINS="$BINS <%= name %>-$VERSION-$slug.zip" + done + for f in $FILE $META_DST $BINS; do + curl -fsS -H "X-Update-Secret: $UPDATE_SECRET" \ + -F "file=@$f" \ + "$UPDATE_SERVER/build/upload?name=<%= name %>&version=$VERSION" || exit 1 + done + +publish: + stage: publish + image: alpine + variables: + UPDATE_SERVER: "<%= update_server %>" + UPDATE_SECRET: "$application_token" + script: + - apk add --no-cache curl + - | + VERSION=$(cat .version) + curl -fsS -X POST -H "X-Update-Secret: $UPDATE_SECRET" "$UPDATE_SERVER/build/publish?name=<%= name %>&platform=bevy&version=$VERSION" diff --git a/lib/warp_engine/ci/gitlab/platforms/c64/pipeline.yaml.erb b/lib/warp_engine/ci/gitlab/platforms/c64/pipeline.yaml.erb new file mode 100644 index 0000000..c147642 --- /dev/null +++ b/lib/warp_engine/ci/gitlab/platforms/c64/pipeline.yaml.erb @@ -0,0 +1,74 @@ +# Generated pipeline — WarpEngine /build/config (platform: c64, name: <%= name %>) +stages: + - version + - build + - artifact + - publish + +version: + stage: version + image: alpine + script: + - | + VERSION=$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' metadata.json | head -n 1) + if [ -z "$VERSION" ]; then + echo "ERROR: no \"version\" field in metadata.json!" + exit 1 + fi + BRANCH="$CI_COMMIT_BRANCH" + BRANCH=$(echo "$BRANCH" | tr '/' '-') + if [ "$BRANCH" != "main" ] && [ "$BRANCH" != "master" ] && [ -n "$BRANCH" ]; then + VERSION="dev-$VERSION-$BRANCH" + fi + echo "VERSION is: $VERSION" + echo $VERSION > .version + artifacts: + paths: + - .version + - metadata.json + +build: + stage: build + image: <%= builder %> + script: + - | + VERSION=$(cat .version) + acme -f cbm -o <%= name %>.prg main.asm + echo "==> Creating versioned files for $VERSION" + cp <%= name %>.prg <%= name %>-$VERSION.prg + cp metadata.json <%= name %>-$VERSION.metadata.json + ls -lh <%= name %>-$VERSION.* + artifacts: + paths: + - "*.prg" + - "*.metadata.json" + +artifact: + stage: artifact + image: alpine + variables: + UPDATE_SERVER: "<%= update_server %>" + UPDATE_SECRET: "$application_token" + script: + - apk add --no-cache curl + - | + VERSION=$(cat .version) + echo "==> Uploading artifacts for version $VERSION" + for f in <%= name %>-$VERSION.prg <%= name %>-$VERSION.metadata.json; do + curl -fsS -H "X-Update-Secret: $UPDATE_SECRET" \ + -F "file=@$f" \ + "$UPDATE_SERVER/build/upload?name=<%= name %>&version=$VERSION" || exit 1 + done + +publish: + stage: publish + image: alpine + variables: + UPDATE_SERVER: "<%= update_server %>" + UPDATE_SECRET: "$application_token" + script: + - apk add --no-cache curl + - | + VERSION=$(cat .version) + echo "==> Publishing version $VERSION" + curl -fsS -X POST -H "X-Update-Secret: $UPDATE_SECRET" "$UPDATE_SERVER/build/publish?name=<%= name %>&platform=c64&version=$VERSION" diff --git a/lib/warp_engine/ci/gitlab/platforms/ebitengine/pipeline.yaml.erb b/lib/warp_engine/ci/gitlab/platforms/ebitengine/pipeline.yaml.erb new file mode 100644 index 0000000..86edcd9 --- /dev/null +++ b/lib/warp_engine/ci/gitlab/platforms/ebitengine/pipeline.yaml.erb @@ -0,0 +1,118 @@ +# Generated pipeline — WarpEngine /build/config (platform: ebitengine, name: <%= name %>) +stages: + - version + - build + - binaries + - artifact + - publish + +version: + stage: version + image: alpine + script: + - apk add --no-cache git jq + - | + if [ -f metadata.json ]; then + VERSION=$(jq -r '.version' metadata.json) + else + VERSION=$(git rev-parse --short HEAD) + fi + BRANCH=$(git rev-parse --abbrev-ref HEAD) + if [ "$BRANCH" != "main" ] && [ "$BRANCH" != "master" ]; then + VERSION="dev-$VERSION-$BRANCH" + fi + echo "VERSION is: $VERSION" + echo $VERSION > .version + artifacts: + paths: + - .version + - metadata.json + +build: + stage: build + image: <%= builder %> + script: + - | + VERSION=$(cat .version) + mkdir -p dist + GOOS=js GOARCH=wasm go build -o dist/game.wasm . + cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" dist/wasm_exec.js + echo "==> Downloading index.html" + curl -sSL https://git.teletypegames.org/tools/ebitengine-tools/raw/branch/master/web/index.html -o dist/index.html + echo "==> Packaging HTML/WASM for $VERSION" + zip -r "<%= name %>-$VERSION.html.zip" -j dist/game.wasm dist/wasm_exec.js dist/index.html + echo "==> Cleaning temporary files" + rm -f dist/game.wasm dist/wasm_exec.js dist/index.html + artifacts: + paths: + - "*.zip" + +binaries: + stage: binaries + image: <%= builder %> + script: + - | + VERSION=$(cat .version) + # win-x86 / win-x64: pure Go cross-compile (Windowson nem kell cgo) + # linux-x64: cgo build, linux/amd64 hoston fut (builder image, X11/GL dev libekkel) + # helper: builds one target + zips it with a single root folder + # (unix zip keeps the executable bit) + binary_build() { + B_GOOS="$1"; B_GOARCH="$2"; B_CGO="$3"; B_EXT="$4"; B_TARGET="$5"; B_CC="$6" + PKG_DIR="<%= name %>-$VERSION-$B_TARGET" + echo "==> Building $PKG_DIR" + rm -rf "$PKG_DIR" "$PKG_DIR.zip" + mkdir -p "$PKG_DIR" + # cgo cross-compile needs an explicit cross gcc; a native build must + # not see CC at all, otherwise go picks the wrong compiler + if [ -n "$B_CC" ]; then export CC="$B_CC"; else unset CC; fi + CGO_ENABLED=$B_CGO GOOS=$B_GOOS GOARCH=$B_GOARCH go build -o "$PKG_DIR/<%= name %>$B_EXT" . + if [ -f LICENSE ]; then cp LICENSE "$PKG_DIR/"; fi + if [ -f README.md ]; then cp README.md "$PKG_DIR/"; fi + zip -r "$PKG_DIR.zip" "$PKG_DIR" >/dev/null + rm -rf "$PKG_DIR" + echo "==> $PKG_DIR.zip kesz" + } + # CI (linux builder) builds these four: + binary_build "windows" "386" "0" ".exe" "win-x86" + binary_build "windows" "amd64" "0" ".exe" "win-x64" + binary_build "linux" "amd64" "1" "" "linux-x64" + # linux-arm64: Raspberry Pi, Odroid, retro handhelds — cgo cross-build + # against the arm64 X11/GL/ALSA headers in the builder image + binary_build "linux" "arm64" "1" "" "linux-arm64" "aarch64-linux-gnu-gcc" + artifacts: + paths: + - "*.zip" + +artifact: + stage: artifact + image: alpine + variables: + UPDATE_SERVER: "<%= update_server %>" + UPDATE_SECRET: "$application_token" + script: + - apk add --no-cache curl + - | + VERSION=$(cat .version) + FILE="<%= name %>-$VERSION.html.zip" + META_SRC="metadata.json" + META_DST="<%= name %>-$VERSION.metadata.json" + BINS=$(ls <%= name %>-$VERSION-*.zip 2>/dev/null || true) + cp $META_SRC $META_DST + for f in $FILE $META_DST $BINS; do + curl -fsS -H "X-Update-Secret: $UPDATE_SECRET" \ + -F "file=@$f" \ + "$UPDATE_SERVER/build/upload?name=<%= name %>&version=$VERSION" || exit 1 + done + +publish: + stage: publish + image: alpine + variables: + UPDATE_SERVER: "<%= update_server %>" + UPDATE_SECRET: "$application_token" + script: + - apk add --no-cache curl + - | + VERSION=$(cat .version) + curl -fsS -X POST -H "X-Update-Secret: $UPDATE_SECRET" "$UPDATE_SERVER/build/publish?name=<%= name %>&platform=ebitengine&version=$VERSION" diff --git a/lib/warp_engine/ci/gitlab/platforms/godot/pipeline.yaml.erb b/lib/warp_engine/ci/gitlab/platforms/godot/pipeline.yaml.erb new file mode 100644 index 0000000..f1950b2 --- /dev/null +++ b/lib/warp_engine/ci/gitlab/platforms/godot/pipeline.yaml.erb @@ -0,0 +1,114 @@ +# Generated pipeline — WarpEngine /build/config (platform: godot, name: <%= name %>) +stages: + - version + - build + - upload + - publish + +version: + stage: version + image: alpine + script: + - apk add --no-cache git jq + - | + if [ -f metadata.json ]; then + VERSION=$(jq -r '.version' metadata.json) + else + VERSION=$(git rev-parse --short HEAD) + fi + BRANCH=$(git rev-parse --abbrev-ref HEAD) + if [ "$BRANCH" != "main" ] && [ "$BRANCH" != "master" ]; then + VERSION="dev-$VERSION-$BRANCH" + fi + echo "VERSION is: $VERSION" + echo $VERSION > .version + artifacts: + paths: + - .version + - metadata.json + +build: + stage: build + image: <%= builder %> + script: + - | + VERSION=$(cat .version) + echo "==> Importing project" + godot --headless --import + echo "==> Exporting web build (Web preset)" + mkdir -p dist/web + godot --headless --export-release "Web" dist/web/index.html + echo "==> Packaging web build for $VERSION" + (cd dist/web && zip -r "../../<%= name %>-$VERSION.html.zip" .) + rm -rf dist/web + - | + VERSION=$(cat .version) + # exports a win/linux target + zips it with a single root folder + # (embed_pck makes the export a single executable) + binary_build() { + B_PRESET="$1"; B_EXT="$2"; B_TARGET="$3" + PKG_DIR="<%= name %>-$VERSION-$B_TARGET" + echo "==> Building $PKG_DIR" + rm -rf "$PKG_DIR" "$PKG_DIR.zip" + mkdir -p "$PKG_DIR" + godot --headless --export-release "$B_PRESET" "$(pwd)/$PKG_DIR/<%= name %>$B_EXT" + if [ -f LICENSE ]; then cp LICENSE "$PKG_DIR/"; fi + if [ -f README.md ]; then cp README.md "$PKG_DIR/"; fi + zip -r "$PKG_DIR.zip" "$PKG_DIR" >/dev/null + rm -rf "$PKG_DIR" + echo "==> $PKG_DIR.zip kesz" + } + # mac: from linux Godot can only export macOS into a .zip (holding the + # .app); repackage it to the root-folder convention (zip -ry keeps + # exec bits and symlinks) + binary_build_mac() { + B_PRESET="$1"; B_TARGET="$2" + PKG_DIR="<%= name %>-$VERSION-$B_TARGET" + echo "==> Building $PKG_DIR" + rm -rf "$PKG_DIR" "$PKG_DIR.zip" + mkdir -p "$PKG_DIR" + godot --headless --export-release "$B_PRESET" "$(pwd)/$PKG_DIR/<%= name %>-mac-tmp.zip" + (cd "$PKG_DIR" && unzip -q "<%= name %>-mac-tmp.zip" && rm "<%= name %>-mac-tmp.zip") + if [ -f LICENSE ]; then cp LICENSE "$PKG_DIR/"; fi + if [ -f README.md ]; then cp README.md "$PKG_DIR/"; fi + zip -ry "$PKG_DIR.zip" "$PKG_DIR" >/dev/null + rm -rf "$PKG_DIR" + echo "==> $PKG_DIR.zip kesz" + } + binary_build "Windows x86" ".exe" "win-x86" + binary_build "Windows x64" ".exe" "win-x64" + binary_build "Linux x64" "" "linux-x64" + binary_build_mac "Mac universal" "mac-universal" + artifacts: + paths: + - "*.zip" + +upload: + stage: upload + image: alpine + variables: + UPDATE_SERVER: "<%= update_server %>" + UPDATE_SECRET: "$application_token" + script: + - apk add --no-cache curl + - | + VERSION=$(cat .version) + cp metadata.json "<%= name %>-$VERSION.metadata.json" + BINS=$(ls <%= name %>-$VERSION-*.zip 2>/dev/null || true) + for f in "<%= name %>-$VERSION.html.zip" "<%= name %>-$VERSION.metadata.json" $BINS; do + curl -fsS -H "X-Update-Secret: $UPDATE_SECRET" \ + -F "file=@$f" \ + "$UPDATE_SERVER/build/upload?name=<%= name %>&version=$VERSION" || exit 1 + done + +publish: + stage: publish + image: alpine + variables: + UPDATE_SERVER: "<%= update_server %>" + UPDATE_SECRET: "$application_token" + script: + - apk add --no-cache curl + - | + VERSION=$(cat .version) + curl -fsS -X POST -H "X-Update-Secret: $UPDATE_SECRET" "$UPDATE_SERVER/build/publish?name=<%= name %>&platform=godot&version=$VERSION" diff --git a/lib/warp_engine/ci/gitlab/platforms/love/pipeline.yaml.erb b/lib/warp_engine/ci/gitlab/platforms/love/pipeline.yaml.erb new file mode 100644 index 0000000..92f63a0 --- /dev/null +++ b/lib/warp_engine/ci/gitlab/platforms/love/pipeline.yaml.erb @@ -0,0 +1,191 @@ +# Generated pipeline — WarpEngine /build/config (platform: love, name: <%= name %>) +stages: + - version + - export + - binaries + - upload + - publish + +version: + stage: version + image: alpine + script: + - apk add --no-cache git jq + - | + if [ -f metadata.json ]; then + VERSION=$(jq -r '.version' metadata.json) + else + VERSION=$(git rev-parse --short HEAD) + fi + BRANCH=$(git rev-parse --abbrev-ref HEAD) + if [ "$BRANCH" != "main" ] && [ "$BRANCH" != "master" ]; then + VERSION="dev-$VERSION-$BRANCH" + fi + echo "VERSION is: $VERSION" + echo $VERSION > .version + artifacts: + paths: + - .version + - metadata.json + +export: + stage: export + image: <%= builder %> + script: + - | + VERSION=$(cat .version) + mkdir -p dist + echo "==> Building .love package" + zip -r dist/<%= name %>.love . \ + --exclude "*.git*" \ + --exclude "bin/*" \ + --exclude "dist/*" \ + --exclude "Makefile" \ + --exclude ".version" \ + --exclude "metadata.json" \ + --exclude "*.zip" + mkdir -p dist/web + # The love-builder CI image pre-fetches love.js here; local builds + # fall back to GitHub. + if [ -f /opt/lovejs.zip ]; then + echo "==> Using cached love.js (/opt/lovejs.zip)" + cp /opt/lovejs.zip dist/lovejs.zip + else + echo "==> Downloading love.js (2dengine)" + curl -sSL https://github.com/2dengine/love.js/archive/refs/heads/master.zip -o dist/lovejs.zip + fi + unzip -o dist/lovejs.zip -d dist/lovejs-src + rm -f dist/lovejs.zip + echo "==> Assembling web bundle" + cp -r dist/lovejs-src/*/. dist/web/ + rm -rf dist/lovejs-src + cp dist/<%= name %>.love dist/web/<%= name %>.love + echo "==> Patching player.js" + sed -i.bak "s|uri = 'nogame\.love'|uri = '<%= name %>.love'|g" dist/web/player.js && rm dist/web/player.js.bak + echo "==> Patching index.html" + sed -i.bak 's|||g' dist/web/index.html && rm dist/web/index.html.bak + echo "==> Web build ready in dist/web" + echo "==> Packaging Love2D for $VERSION" + zip -r <%= name %>-$VERSION.love.zip dist/<%= name %>.love + echo "==> Packaging web build for $VERSION" + (cd dist/web && zip -r ../../<%= name %>-$VERSION.html.zip .) + echo "==> Cleaning temporary files" + rm -f dist/<%= name %>.love + rm -rf dist/web + artifacts: + paths: + - "*.zip" + +binaries: + stage: binaries + image: <%= builder %> + script: + - | + VERSION=$(cat .version) + # The export step deleted the .love, rebuild it here (in make the + # binary-* targets' love prerequisite did the same). + mkdir -p dist + zip -r dist/<%= name %>.love . \ + --exclude "*.git*" \ + --exclude "bin/*" \ + --exclude "dist/*" \ + --exclude "Makefile" \ + --exclude ".version" \ + --exclude "metadata.json" \ + --exclude "*.zip" + # The love-builder CI image pre-fetches the dist files to + # /opt/love-dist; local builds fall back to GitHub. + fetch_love() { + if [ -f "/opt/love-dist/$1" ]; then + echo "==> Using cached $1" + cp "/opt/love-dist/$1" "dist/$1" + elif [ ! -f "dist/$1" ]; then + echo "==> Downloading $1" + curl -sSL "https://github.com/love2d/love/releases/download/11.5/$1" -o "dist/$1" + fi + } + echo "==> Fusing windows binary" + fetch_love love-11.5-win64.zip + PKG_DIR="<%= name %>-$VERSION-win-x64" + rm -rf "$PKG_DIR" "$PKG_DIR.zip" dist/win64 + unzip -q dist/love-11.5-win64.zip -d dist/win64 + SRC=$(dirname $(find dist/win64 -name love.exe | head -n 1)) + mkdir -p "$PKG_DIR" + cat "$SRC/love.exe" dist/<%= name %>.love > "$PKG_DIR/<%= name %>.exe" + cp "$SRC"/*.dll "$PKG_DIR/" + cp "$SRC/license.txt" "$PKG_DIR/" 2>/dev/null || true + zip -qr "$PKG_DIR.zip" "$PKG_DIR" + rm -rf "$PKG_DIR" dist/win64 + echo "==> $PKG_DIR.zip kesz" + echo "==> Fusing macOS app bundle" + fetch_love love-11.5-macos.zip + PKG_DIR="<%= name %>-$VERSION-mac-universal" + rm -rf "$PKG_DIR" "$PKG_DIR.zip" dist/macos + unzip -q dist/love-11.5-macos.zip -d dist/macos + mkdir -p "$PKG_DIR" + mv dist/macos/love.app "$PKG_DIR/<%= name %>.app" + cp dist/<%= name %>.love "$PKG_DIR/<%= name %>.app/Contents/Resources/" + PLIST="$PKG_DIR/<%= name %>.app/Contents/Info.plist" + sed -i.bak "s|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" + # The AppImage runtime is glibc-dynamic and cannot run on alpine + # (musl), so we do not run the runtime: the offset is computed from + # readelf (shoff + shentsize*shnum) and the squashfs is extracted + # with unsquashfs -o. + echo "==> Fusing linux AppImage" + fetch_love love-11.5-x86_64.AppImage + PKG_DIR="<%= name %>-$VERSION-linux-x64" + APPIMAGE="dist/love-11.5-x86_64.AppImage" + rm -rf "$PKG_DIR" "$PKG_DIR.zip" squashfs-root dist/game.squashfs dist/runtime + OFFSET=$(readelf -h "$APPIMAGE" | awk '/Start of section headers/{o=$5} /Size of section headers/{s=$5} /Number of section headers/{n=$5} END{print o+s*n}') + unsquashfs -q -o $OFFSET -d squashfs-root "$APPIMAGE" >/dev/null + cat squashfs-root/bin/love dist/<%= name %>.love > squashfs-root/bin/love.fused + mv squashfs-root/bin/love.fused squashfs-root/bin/love + chmod +x squashfs-root/bin/love + mksquashfs squashfs-root dist/game.squashfs -root-owned -noappend -quiet -comp gzip + head -c $OFFSET "$APPIMAGE" > dist/runtime + mkdir -p "$PKG_DIR" + cat dist/runtime dist/game.squashfs > "$PKG_DIR/<%= name %>.AppImage" + chmod +x "$PKG_DIR/<%= name %>.AppImage" + zip -qr "$PKG_DIR.zip" "$PKG_DIR" + rm -rf "$PKG_DIR" squashfs-root dist/game.squashfs dist/runtime + echo "==> $PKG_DIR.zip kesz" + artifacts: + paths: + - "*.zip" + +upload: + stage: upload + image: alpine + variables: + UPDATE_SERVER: "<%= update_server %>" + UPDATE_SECRET: "$application_token" + script: + - apk add --no-cache curl + - | + VERSION=$(cat .version) + cp metadata.json "<%= name %>-$VERSION.metadata.json" + BINS="" + for slug in win-x64 mac-universal linux-x64; do + [ -f "<%= name %>-$VERSION-$slug.zip" ] && BINS="$BINS <%= name %>-$VERSION-$slug.zip" + done + for f in "<%= name %>-$VERSION.love.zip" "<%= name %>-$VERSION.html.zip" "<%= name %>-$VERSION.metadata.json" $BINS; do + curl -fsS -H "X-Update-Secret: $UPDATE_SECRET" \ + -F "file=@$f" \ + "$UPDATE_SERVER/build/upload?name=<%= name %>&version=$VERSION" || exit 1 + done + +publish: + stage: publish + image: alpine + variables: + UPDATE_SERVER: "<%= update_server %>" + UPDATE_SECRET: "$application_token" + script: + - apk add --no-cache curl + - | + VERSION=$(cat .version) + curl -fsS -X POST -H "X-Update-Secret: $UPDATE_SECRET" "$UPDATE_SERVER/build/publish?name=<%= name %>&platform=love&version=$VERSION" diff --git a/lib/warp_engine/ci/gitlab/platforms/phaser/pipeline.yaml.erb b/lib/warp_engine/ci/gitlab/platforms/phaser/pipeline.yaml.erb new file mode 100644 index 0000000..a20443f --- /dev/null +++ b/lib/warp_engine/ci/gitlab/platforms/phaser/pipeline.yaml.erb @@ -0,0 +1,106 @@ +# Generated pipeline — WarpEngine /build/config (platform: phaser, name: <%= name %>) +stages: + - version + - build + - upload + - publish + +version: + stage: version + image: alpine + script: + - apk add --no-cache git jq + - | + if [ -f metadata.json ]; then + VERSION=$(jq -r '.version' metadata.json) + else + VERSION=$(git rev-parse --short HEAD) + fi + BRANCH=$(git rev-parse --abbrev-ref HEAD) + if [ "$BRANCH" != "main" ] && [ "$BRANCH" != "master" ]; then + VERSION="dev-$VERSION-$BRANCH" + fi + echo "VERSION is: $VERSION" + echo $VERSION > .version + artifacts: + paths: + - .version + - metadata.json + +build: + stage: build + image: <%= builder %> + script: + - | + VERSION=$(cat .version) + # Ketfele projektforma el egymas mellett: a sima JS (a forrasok + # osszefuzve, a Phaser CDN-rol) es a bundleres (Vite + TypeScript), + # ami maga allitja elo a kesz webes csomagot. + if [ -f package.json ] && grep -q '"build"' package.json; then + echo "==> Bundled project — npm ci && npm run build" + npm ci + npm run build + # A Vite kimenete onmagaban teljes: index.html + a beforgatott + # assetek. A vite.config base-enek relativnak kell lennie, mert a + # jatek a /file/-/ alkonyvtarbol szolgal ki. + if [ ! -f dist/index.html ]; then + echo "ERROR: a build nem hagyott dist/index.html-t" >&2 + exit 1 + fi + echo "==> Packaging web build for $VERSION" + (cd dist && zip -r "../<%= name %>-$VERSION.html.zip" .) + echo "==> Cleaning temporary files" + rm -rf dist + else + echo "==> Checking JS syntax" + # A bundleres projektben nincs src/*.js, es a shell ilyenkor a + # mintat adja tovabb literalkent — a node MODULE_NOT_FOUND-dal + # szall el rajta. + for f in src/*.js; do [ -e "$f" ] || continue; node --check "$f"; done + mkdir -p dist/web + echo "==> Downloading Phaser 3.90.0" + curl -sSL https://cdn.jsdelivr.net/npm/phaser@3.90.0/dist/phaser.min.js -o dist/web/phaser.min.js + echo "==> Downloading index.html" + curl -sSL https://git.teletypegames.org/build/phaser-tools/raw/branch/master/web/index.html -o dist/web/index.html + echo "==> Bundling game sources" + cat src/*.js > dist/web/game.js + echo "==> Packaging web build for $VERSION" + (cd dist/web && zip -r "../../<%= name %>-$VERSION.html.zip" .) + echo "==> Cleaning temporary files" + rm -rf dist/web + fi + artifacts: + paths: + - "*.zip" + +upload: + stage: upload + image: alpine + variables: + UPDATE_SERVER: "<%= update_server %>" + UPDATE_SECRET: "$application_token" + script: + - apk add --no-cache curl + - | + VERSION=$(cat .version) + FILE="<%= name %>-$VERSION.html.zip" + META_SRC="metadata.json" + META_DST="<%= name %>-$VERSION.metadata.json" + cp $META_SRC $META_DST + for f in $FILE $META_DST; do + curl -fsS -H "X-Update-Secret: $UPDATE_SECRET" \ + -F "file=@$f" \ + "$UPDATE_SERVER/build/upload?name=<%= name %>&version=$VERSION" || exit 1 + done + +publish: + stage: publish + image: alpine + variables: + UPDATE_SERVER: "<%= update_server %>" + UPDATE_SECRET: "$application_token" + script: + - apk add --no-cache curl + - | + VERSION=$(cat .version) + curl -fsS -X POST -H "X-Update-Secret: $UPDATE_SECRET" "$UPDATE_SERVER/build/publish?name=<%= name %>&platform=phaser&version=$VERSION" diff --git a/lib/warp_engine/ci/gitlab/platforms/tic80/pipeline.yaml.erb b/lib/warp_engine/ci/gitlab/platforms/tic80/pipeline.yaml.erb new file mode 100644 index 0000000..0f0e4c5 --- /dev/null +++ b/lib/warp_engine/ci/gitlab/platforms/tic80/pipeline.yaml.erb @@ -0,0 +1,232 @@ +# Generated pipeline — WarpEngine /build/config (platform: tic80, name: <%= name %>) +# The version comes from the source (inc/meta/meta.header.lua "-- version:" +# comment) — WarpEngine parses tic80 metadata from the Lua header too, hence +# no metadata.json. +stages: + - version + - lint + - minify + - docs + - export + - binaries + - upload + - publish + +version: + stage: version + image: alpine + script: + - | + VERSION=$(sed -n "s/^-- version: //p" inc/meta/meta.header.lua | head -n 1 | tr -d "[:space:]") + BRANCH="$CI_COMMIT_BRANCH" + BRANCH=$(echo "$BRANCH" | tr '/' '-') + if [ "$BRANCH" != "main" ] && [ "$BRANCH" != "master" ] && [ -n "$BRANCH" ]; then + VERSION=dev-$VERSION-$BRANCH + fi + echo "VERSION is: $VERSION" + echo $VERSION > .version + artifacts: + paths: + - .version + +lint: + stage: lint + image: alpine + script: + - apk add --no-cache lua5.4 lua5.4-dev luarocks gcc musl-dev + - ln -sf /usr/bin/lua5.4 /usr/bin/lua + - ln -sf /usr/bin/luarocks-5.4 /usr/bin/luarocks + - luarocks install luacheck + - | + echo "==> Merging..." + rm -f /tmp/_lint_combined.lua /tmp/_lint_map.txt + touch /tmp/_lint_combined.lua + line=1 + while IFS= read -r f || [ -n "$f" ]; do + f=$(printf '%s' "$f" | tr -d '\r') + [ -z "$f" ] && continue + before=$(wc -l < /tmp/_lint_combined.lua) + cat "inc/$f" >> /tmp/_lint_combined.lua + printf '\n' >> /tmp/_lint_combined.lua + after=$(wc -l < /tmp/_lint_combined.lua) + linecount=$((after - before)) + echo "$line $linecount inc/$f" >> /tmp/_lint_map.txt + line=$((line + linecount)) + done < <%= name %>.inc + echo "==> luacheck..." + LINT_OUTPUT=$(luacheck --no-max-line-length /tmp/_lint_combined.lua 2>&1 | awk -v map=/tmp/_lint_map.txt ' + BEGIN { + NR_map = 0; + while ((getline line < map) > 0) { + n = split(line, a, " "); + start[NR_map] = a[1]+0; + count[NR_map] = a[2]+0; + fname[NR_map] = a[3]; + NR_map++; + } + } + /^[^:]+:[0-9]+:[0-9]+:/ { + colon1 = index($0, ":"); + rest1 = substr($0, colon1+1); + colon2 = index(rest1, ":"); + absline = substr(rest1, 1, colon2-1) + 0; + rest2 = substr(rest1, colon2+1); + colon3 = index(rest2, ":"); + col = substr(rest2, 1, colon3-1); + rest = substr(rest2, colon3); + found = 0; + for (i = 0; i < NR_map; i++) { + end_line = start[i] + count[i] -1; + if (absline >= start[i] && absline <= end_line) { + relline = absline - start[i] + 1; + print fname[i] ":" relline ":" col ":" rest; + found = 1; + break; + } + } + if (!found) print $0; + next; + } + { print } + ') + echo "$LINT_OUTPUT" + NUM_ISSUES=$(echo "$LINT_OUTPUT" | grep -cE "^[^:]+:[0-9]+:[0-9]+:" || true) + if [ "$NUM_ISSUES" -gt 0 ]; then + echo "Total: $NUM_ISSUES issue(s) found, commit aborted." + exit 1 + else + echo "Checking /tmp/_lint_combined.lua OK" + echo "Total: 0 warnings / 0 errors in 1 file" + fi + rm -f /tmp/_lint_combined.lua /tmp/_lint_map.txt + +minify: + stage: minify + image: alpine + script: + - apk add --no-cache lua5.4 curl + - ln -sf /usr/bin/lua5.4 /usr/bin/lua + - | + rm -f <%= name %>.lua + sed 's/\r$//' <%= name %>.inc | while read f; do + cat "inc/$f" >> <%= name %>.lua + echo "" >> <%= name %>.lua + done + test -f minify.lua || { echo "==> Downloading minify.lua"; curl -fsSL https://raw.githubusercontent.com/ztimar31/lua-minify-tic80/refs/heads/master/minify.lua -o minify.lua; } + echo "==> Minifying <%= name %>.lua" + cp <%= name %>.lua <%= name %>.original.lua + lua minify.lua minify <%= name %>.original.lua > <%= name %>.lua + artifacts: + paths: + - "<%= name %>.lua" + - "<%= name %>.original.lua" + +docs: + stage: docs + image: alpine + script: + - apk add --no-cache lua5.4 lua5.4-dev luarocks gcc musl-dev zip + - ln -sf /usr/bin/lua5.4 /usr/bin/lua + - ln -sf /usr/bin/luarocks-5.4 /usr/bin/luarocks + - luarocks install ldoc + - | + VERSION=$(cat .version) + echo "==> Generating docs from <%= name %>.original.lua" + ldoc <%= name %>.original.lua -d docs + echo "==> Zipping docs for version $VERSION" + (cd docs && zip -r ../<%= name %>-$VERSION-docs.zip .) + cp <%= name %>-$VERSION-docs.zip <%= name %>-docs.zip + echo "==> Docs zip created" + artifacts: + paths: + - "*-docs.zip" + +export: + stage: export + image: <%= builder %> + variables: + XDG_RUNTIME_DIR: /tmp + script: + - | + VERSION=$(cat .version) + echo "==> Exporting HTML for version $VERSION" + tic80 --cli --skip --fs=. \ + --cmd="load <%= name %>.lua & save <%= name %>-$VERSION & export html <%= name %>-$VERSION.html & exit" + if [ -f "<%= name %>-$VERSION.tic" ]; then + cp <%= name %>-$VERSION.tic <%= name %>.tic + fi + if [ -f "<%= name %>-$VERSION.html.zip" ]; then + cp <%= name %>-$VERSION.html.zip <%= name %>.html.zip + fi + echo "==> Generated files:" + ls -lh <%= name %>-$VERSION.* <%= name %>.tic <%= name %>.html.zip 2>/dev/null || true + artifacts: + paths: + - "<%= name %>.lua" + - "*.tic" + - "*.html.zip" + +binaries: + stage: binaries + image: <%= builder %> + variables: + XDG_RUNTIME_DIR: /tmp + script: + - | + VERSION=$(cat .version) + echo "==> Exporting native players for version $VERSION" + tic80 --cli --skip --fs=. \ + --cmd="load <%= name %>.lua & export win <%= name %>-win & export linux <%= name %>-linux & export mac <%= name %>-mac & exit" + # unix zip preserves the executable bit + pack_binary() { + SLUG="$1"; SRC_FILE="$2"; DST_FILE="$3" + PKG_DIR="<%= name %>-$VERSION-$SLUG" + rm -rf "$PKG_DIR" "$PKG_DIR.zip" + mkdir -p "$PKG_DIR" + mv "$SRC_FILE" "$PKG_DIR/$DST_FILE" + chmod +x "$PKG_DIR/$DST_FILE" + zip -r "$PKG_DIR.zip" "$PKG_DIR" >/dev/null + rm -rf "$PKG_DIR" + echo "==> $PKG_DIR.zip kesz" + } + pack_binary win-x64 <%= name %>-win.exe <%= name %>.exe + pack_binary linux-x64 <%= name %>-linux <%= name %> + pack_binary mac-x64 <%= name %>-mac <%= name %> + artifacts: + paths: + - "*.zip" + +upload: + stage: upload + image: alpine + variables: + UPDATE_SERVER: "<%= update_server %>" + UPDATE_SECRET: "$application_token" + script: + - apk add --no-cache curl + - | + VERSION=$(cat .version) + echo "==> Uploading artifacts for version $VERSION" + cp <%= name %>.lua <%= name %>-$VERSION.lua + BINS="" + for slug in win-x64 linux-x64 mac-x64; do + [ -f "<%= name %>-$VERSION-$slug.zip" ] && BINS="$BINS <%= name %>-$VERSION-$slug.zip" + done + for f in <%= name %>-$VERSION.lua <%= name %>-$VERSION.tic <%= name %>-$VERSION.html.zip <%= name %>-$VERSION-docs.zip $BINS; do + curl -fsS -H "X-Update-Secret: $UPDATE_SECRET" \ + -F "file=@$f" \ + "$UPDATE_SERVER/build/upload?name=<%= name %>&version=$VERSION" || exit 1 + done + +publish: + stage: publish + image: alpine + variables: + UPDATE_SERVER: "<%= update_server %>" + UPDATE_SECRET: "$application_token" + script: + - apk add --no-cache curl + - | + VERSION=$(cat .version) + echo "==> Publishing version $VERSION" + curl -fsS -X POST -H "X-Update-Secret: $UPDATE_SECRET" "$UPDATE_SERVER/build/publish?name=<%= name %>&platform=tic80&version=$VERSION" diff --git a/lib/warp_engine/ci/gitlab/signature_verifier.rb b/lib/warp_engine/ci/gitlab/signature_verifier.rb new file mode 100644 index 0000000..47eb364 --- /dev/null +++ b/lib/warp_engine/ci/gitlab/signature_verifier.rb @@ -0,0 +1,19 @@ +module WarpEngine + module CI + module Gitlab + class SignatureVerifier + def initialize(request, webhook_secret:) + @request = request + @webhook_secret = webhook_secret + end + + def valid? + token = @request.headers["X-Gitlab-Token"] + return false if token.blank? || @webhook_secret.blank? + + ActiveSupport::SecurityUtils.secure_compare(token, @webhook_secret) + end + end + end + end +end diff --git a/spec/ci/built_kinds_spec.rb b/spec/ci/built_kinds_spec.rb index 7d311f5..85887eb 100644 --- a/spec/ci/built_kinds_spec.rb +++ b/spec/ci/built_kinds_spec.rb @@ -1,7 +1,7 @@ require "rails_helper" -RSpec.describe WarpEngine::CI::Woodpecker::PipelineConfig do - BUILT_KINDS = described_class::BUILT_KINDS +shared_examples "pipeline config built kinds" do |config_class| + built_kinds = config_class::BUILT_KINDS def self.fragment_for(kind) case kind @@ -12,17 +12,17 @@ RSpec.describe WarpEngine::CI::Woodpecker::PipelineConfig do end end - def config_for(platform) - described_class.new(platforms: { platform => { builder: "builder:latest", exporter: "exporter:latest" } }) + define_method(:config_for) do |platform| + config_class.new(platforms: { platform => { builder: "builder:latest", exporter: "exporter:latest" } }) end it "knows every platform in the registry" do - expect(BUILT_KINDS.keys).to match_array(WarpEngine::Platform.names) + expect(built_kinds.keys).to match_array(WarpEngine::Platform.names) end WarpEngine::Platform::NAMES.each do |platform| context platform do - let(:kinds) { BUILT_KINDS.fetch(platform) } + let(:kinds) { built_kinds.fetch(platform) } let(:rendered) do config_for(platform).render(platform: platform, name: "example", update_server: "https://example.test") end @@ -52,3 +52,11 @@ RSpec.describe WarpEngine::CI::Woodpecker::PipelineConfig do end end end + +RSpec.describe WarpEngine::CI::Woodpecker::PipelineConfig do + include_examples "pipeline config built kinds", described_class +end + +RSpec.describe WarpEngine::CI::Gitlab::PipelineConfig do + include_examples "pipeline config built kinds", described_class +end diff --git a/spec/ci/gitlab_client_spec.rb b/spec/ci/gitlab_client_spec.rb new file mode 100644 index 0000000..0c1d3d2 --- /dev/null +++ b/spec/ci/gitlab_client_spec.rb @@ -0,0 +1,142 @@ +require "rails_helper" +require "webmock/rspec" + +RSpec.describe WarpEngine::CI::Gitlab::Client do + let(:base_url) { "https://gitlab.example.test" } + let(:token) { "gl-test-token" } + let(:client) { described_class.new(url: base_url, token: token) } + + def stub_gl(method, path, status: 200, body: nil, request_body: nil) + stub = stub_request(method, "#{base_url}#{path}") + .with(headers: { "PRIVATE-TOKEN" => 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, "path" => "game1" }] + stub_gl(:get, "/api/v4/projects?membership=true&per_page=100&simple=true", body: repos) + + expect(client.list_repos).to eq(repos) + end + + it "gets a repo" do + repo = { "id" => 42, "path" => "mygame" } + stub_gl(:get, "/api/v4/projects/42", body: repo) + + expect(client.get_repo(42)).to eq(repo) + end + + it "activate is a no-op" do + expect(client.activate_repo(42)).to be_nil + end + + it "deactivate is a no-op" do + expect(client.deactivate_repo(42)).to be_nil + end + end + + describe "secrets (variables)" do + it "lists variables" do + vars = [{ "key" => "application_token" }] + stub_gl(:get, "/api/v4/projects/1/variables", body: vars) + + expect(client.list_secrets(1)).to eq(vars) + end + + it "creates a variable" do + stub_gl(:post, "/api/v4/projects/1/variables", status: 201, body: { "key" => "application_token" }) + + result = client.create_secret(1, name: "application_token", value: "secret123") + expect(result["key"]).to eq("application_token") + end + + it "updates a variable" do + stub_gl(:put, "/api/v4/projects/1/variables/application_token", status: 200, body: { "key" => "application_token" }) + + result = client.update_secret(1, "application_token", value: "newsecret") + expect(result["key"]).to eq("application_token") + end + + it "deletes a variable" do + stub_gl(:delete, "/api/v4/projects/1/variables/application_token", status: 204) + + expect(client.delete_secret(1, "application_token")).to be_nil + end + end + + describe "pipelines" do + it "lists pipelines" do + pipelines = [{ "id" => 1, "status" => "success" }] + stub_gl(:get, "/api/v4/projects/42/pipelines?page=1&per_page=25", body: pipelines) + + expect(client.list_pipelines(42)).to eq(pipelines) + end + + it "gets latest pipeline" do + pipeline = { "id" => 5, "status" => "running" } + stub_gl(:get, "/api/v4/projects/42/pipelines?per_page=1&sort=desc", body: [pipeline]) + + expect(client.latest_pipeline(42)).to eq(pipeline) + end + + it "returns nil when no pipelines exist" do + stub_gl(:get, "/api/v4/projects/42/pipelines?per_page=1&sort=desc", body: []) + + expect(client.latest_pipeline(42)).to be_nil + end + + it "gets a specific pipeline" do + pipeline = { "id" => 99, "status" => "success" } + stub_gl(:get, "/api/v4/projects/42/pipelines/99", body: pipeline) + + expect(client.get_pipeline(42, 99)).to eq(pipeline) + end + + it "triggers a pipeline" do + pipeline = { "id" => 6, "status" => "created" } + stub_gl(:post, "/api/v4/projects/42/pipeline", 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_gl(:get, "/api/v4/projects/999", status: 404, body: { "error" => "not found" }) + + expect { client.get_repo(999) }.to raise_error(WarpEngine::CI::ApiError) { |e| + expect(e.status).to eq(404) + } + end + + it "raises ApiError on 500" do + stub_gl(:get, "/api/v4/projects?membership=true&per_page=100&simple=true", status: 500, body: { "error" => "internal" }) + + expect { client.list_repos }.to raise_error(WarpEngine::CI::ApiError) { |e| + expect(e.status).to eq(500) + } + end + + it "raises ConnectionError on connection refused" do + stub_request(:get, "#{base_url}/api/v4/projects?membership=true&per_page=100&simple=true").to_raise(Errno::ECONNREFUSED) + + expect { client.list_repos }.to raise_error(WarpEngine::CI::ConnectionError) + end + + it "raises ConnectionError on timeout" do + stub_request(:get, "#{base_url}/api/v4/projects?membership=true&per_page=100&simple=true").to_timeout + + expect { client.list_repos }.to raise_error(WarpEngine::CI::ConnectionError) + end + + it "raises ApiError when a 200 response is not JSON" do + stub_request(:get, "#{base_url}/api/v4/projects?membership=true&per_page=100&simple=true") + .to_return(status: 200, body: "", + headers: { "Content-Type" => "text/html" }) + + expect { client.list_repos }.to raise_error(WarpEngine::CI::ApiError, /Expected JSON/) + end + end +end diff --git a/spec/ci/gitlab_signature_verifier_spec.rb b/spec/ci/gitlab_signature_verifier_spec.rb new file mode 100644 index 0000000..06aac3d --- /dev/null +++ b/spec/ci/gitlab_signature_verifier_spec.rb @@ -0,0 +1,45 @@ +require "rails_helper" + +RSpec.describe WarpEngine::CI::Gitlab::SignatureVerifier do + let(:webhook_secret) { "gl-webhook-secret-token" } + + def mock_request(token:) + instance_double(ActionDispatch::Request, + headers: { "X-Gitlab-Token" => token }) + end + + it "accepts a request with matching token" do + request = mock_request(token: webhook_secret) + verifier = described_class.new(request, webhook_secret: webhook_secret) + + expect(verifier).to be_valid + end + + it "rejects a request with wrong token" do + request = mock_request(token: "wrong-token") + verifier = described_class.new(request, webhook_secret: webhook_secret) + + expect(verifier).not_to be_valid + end + + it "rejects a request with no token" do + request = mock_request(token: nil) + verifier = described_class.new(request, webhook_secret: webhook_secret) + + expect(verifier).not_to be_valid + end + + it "rejects a request with blank token" do + request = mock_request(token: "") + verifier = described_class.new(request, webhook_secret: webhook_secret) + + expect(verifier).not_to be_valid + end + + it "rejects when webhook secret is not configured" do + request = mock_request(token: "anything") + verifier = described_class.new(request, webhook_secret: nil) + + expect(verifier).not_to be_valid + end +end diff --git a/spec/ci_spec.rb b/spec/ci_spec.rb index e5b7a8c..3be7277 100644 --- a/spec/ci_spec.rb +++ b/spec/ci_spec.rb @@ -19,6 +19,13 @@ RSpec.describe WarpEngine::CI do expect(WarpEngine.ci).not_to be_configured end + it "drives GitLab when configured" do + WarpEngine.config.ci_adapter = :gitlab + + expect(WarpEngine.ci).to be_a(WarpEngine::CI::Gitlab::Adapter) + expect(WarpEngine.ci.name).to eq("GitLab") + end + it "hands back whatever object the host named" do own = Class.new { def name = "Forge Runner" }.new WarpEngine.config.ci_adapter = own @@ -133,4 +140,109 @@ RSpec.describe WarpEngine::CI do .to eq({ configs: [ { name: "godot", data: "steps: []" } ] }) end end + + describe WarpEngine::CI::Gitlab::Adapter do + let(:client) { instance_double(WarpEngine::CI::Gitlab::Client) } + let(:adapter) do + described_class.new(url: "https://gitlab.test", api_token: "tok", + webhook_secret: "gl-secret", + platforms: { "godot" => { builder: "registry.test/godot:1" } }, + update_server: "https://games.test") + end + + before { allow(adapter).to receive(:client).and_return(client) } + + it "is inactive without a server and a token" do + expect(described_class.new(url: nil, api_token: nil)).not_to be_configured + expect(adapter).to be_configured + end + + it "normalizes a repository from GitLab JSON" do + allow(client).to receive(:list_repos).and_return([ + { "id" => 7, "path" => "game", "name" => "game", + "namespace" => { "path" => "org", "name" => "Org" } } + ]) + + repo = adapter.repos.first + + expect(repo.id).to eq(7) + expect(repo.name).to eq("game") + expect(repo.full_name).to eq("org/game") + expect(repo).to be_active + end + + it "normalizes a run from GitLab pipeline JSON" do + allow(client).to receive(:trigger_pipeline).and_return( + { "id" => 42, "status" => "success", "ref" => "main", + "created_at" => "2025-08-06T12:00:00.000Z", + "web_url" => "https://gitlab.test/org/game/-/pipelines/42" } + ) + + run = adapter.trigger(7, branch: "main") + + expect(run.number).to eq(42) + expect(run).to be_success + expect(run.created_at).to be_present + expect(run.url).to eq("https://gitlab.test/org/game/-/pipelines/42") + end + + it "creates a variable the project does not have yet" do + allow(client).to receive(:list_secrets).and_return([]) + allow(client).to receive(:create_secret) + + adapter.secret_set(7, name: "application_token", value: "plain") + + expect(client).to have_received(:create_secret).with( + 7, name: "application_token", value: "plain" + ) + end + + it "updates a variable the project already has" do + allow(client).to receive(:list_secrets).and_return([ { "key" => "application_token" } ]) + allow(client).to receive(:update_secret) + + adapter.secret_set(7, name: "application_token", value: "plain") + + expect(client).to have_received(:update_secret).with(7, "application_token", value: "plain") + end + + it "reads the platform marker out of a config request" do + params = ActionController::Parameters.new( + repo: { name: "mygame" }, + configs: [ { name: ".gitlab-ci.yml", data: "platform: godot\n" } ] + ) + + expect(adapter.config_marker(params)).to eq({ platform: "godot", name: "mygame" }) + end + + it "ignores a config request that is not a marker" do + params = ActionController::Parameters.new( + configs: [ { name: ".gitlab-ci.yml", data: "stages:\n - build\n" } ] + ) + + expect(adapter.config_marker(params)).to be_nil + end + + it "renders only the platforms it was given" do + expect(adapter.pipeline_config(platform: "godot", name: "mygame", + update_server: "https://games.test")).to include("registry.test/godot:1") + expect(adapter.pipeline_config(platform: "amiga", name: "mygame", + update_server: "https://games.test")).to be_nil + end + + it "shapes the response the way the CI server expects" do + expect(adapter.config_response(platform: "godot", config: "stages: []")) + .to eq({ configs: [ { name: "godot", data: "stages: []" } ] }) + end + + it "verifies webhook requests via X-Gitlab-Token" do + good_request = instance_double(ActionDispatch::Request, + headers: { "X-Gitlab-Token" => "gl-secret" }) + bad_request = instance_double(ActionDispatch::Request, + headers: { "X-Gitlab-Token" => "wrong" }) + + expect(adapter.verify_config_request(good_request)).to be(true) + expect(adapter.verify_config_request(bad_request)).to be(false) + end + end end