@ethicdevs/gitfoss | Show object: ae041954903a2e402d4229a987fd9742ed122a97 ∙ GitFOSS
feat(gitfoss-ci): add gitfoss-ci-runner package (work 100%)
+ 1122
- 8
@@ -40,10 +40,10 @@ COPY ./LICENSE             /usr/src/app/LICENSE
 ENV NODE_ENV=production
 ENV NODE_OPTIONS=--openssl-legacy-provider
 
+RUN yarn clean             # Cleanup working dir
 RUN yarn generate:prisma   # Generate Prisma Client from schema (db/schema.prisma)
 RUN yarn typecheck         # Validate TS types are valid first
 RUN yarn test              # Test code to ensure it is regression-free (jest)
-RUN yarn clean             # Cleanup working dir
 RUN yarn build:ts          # Transpile TypeScript to JavaScript (node)
 RUN yarn bundle:islands    # Bundle Islands (react-monolith) to ESM/CJS/UMD
 

...
@@ -51,6 +51,7 @@ RUN yarn bundle:islands    # Bundle Islands (react-monolith) to ESM/CJS/UMD
 
 COPY ./public              /usr/src/app/public
 COPY ./app.manifest.json   /usr/src/app/app.manifest.json
+# COPY ./dist                /usr/src/app/dist
 
 FROM node:slim AS base
 

app/services/gitServer/onPushEvent.ts
@@ -100,7 +100,7 @@ const makeOnPushEvent: ServiceMethodFactory<
       // it is receiving, server is uploading
       console.log("upload-pack");
       // ⬇️ disabled because it seem to cause issues on git pull. ⬇️
-      message.write(toAscii(`🖖 Welcome at GitFOSS ${data.username}!\n`));
+      // message.write(toAscii(`🖖 Welcome at GitFOSS ${data.username}!\n`));
     }
 
     // Finally, accept the payload from client

app/views/repository/RepositoryShowObjectView.tsx
@@ -65,7 +65,7 @@ const RepositoryShowObjectView: ReactView<RepositoryShowObjectViewProps> = ({
             forkedFromRepo={repo.forkedFromRepo}
             forksCount={repo.forks.length}
             parentOrg={parentOrg}
-            path={gitObject.abbreviated_commit}
+            path={gitObject?.abbreviated_commit}
             repo={repo}
             separator={"~"}
           />

...
@@ -101,13 +101,13 @@ const RepositoryShowObjectView: ReactView<RepositoryShowObjectViewProps> = ({
               <strong>-</strong> <span>{totalDeletions}</span>
             </div>
           </Grid.Row>
-          {gitObject.body.trim() !== "" && (
+          {gitObject?.body?.trim() !== "" && (
             <div style={{ width: "100%" }}>
               {getThemedCodeCss(commonProps.themeScheme)}
               <div data-islandid={`${Code.name}$$0`} style={{ width: "100%" }}>
                 <Code
                   language={"plain"}
-                  code={gitObject.body}
+                  code={gitObject?.body}
                   themeScheme={commonProps.themeScheme}
                 />
               </div>

new file
packages/gitfoss-ci-runner/.editorconfig
@@ -0,0 +1,9 @@
+root = true
+
+[*.cr]
+charset = utf-8
+end_of_line = lf
+insert_final_newline = true
+indent_style = space
+indent_size = 2
+trim_trailing_whitespace = true

new file
packages/gitfoss-ci-runner/.gitignore
@@ -0,0 +1,6 @@
+/docs/
+/lib/
+/bin/
+/.shards/
+*.dwarf
+cache/

new file
packages/gitfoss-ci-runner/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Willi Wonka <willi.wonka38@proton.me>
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

new file
packages/gitfoss-ci-runner/README.md
@@ -0,0 +1,223 @@
+# gitfoss-ci-runner
+
+GitFOSS CI Runner is a crystal app that runs a GitFOSS CI pipeline stage  
+from an input manifest file. Runs pipeline stages and exit with a status code.
+
+It takes a manifest file as input and executes the pipeline stage(s) defined within.
+It returns with an exit code of 0 or 1+, on success or failure respectively.
+It has an endpoint that returns the pipeline stage status and logs.
+
+## Installation
+
+```
+$ git clone https://github.com/gitfoss/gitfoss-ci-runner.git
+$ cd gitfoss-ci-runner
+$ shards build
+$ cp ./bin/gitfoss-ci-runner /usr/bin/gitfoss-ci-runner
+```
+
+## Usage
+
+Go to the [GitFOSS CI Runner register page] to get your registration nonce.  
+
+```
+$ gitfoss-ci-runner register --nonce=12345ABCDEF
+# GitFOSS CI runner registered successfully.
+# GitFOSS CI runner token: FEDCBA54321
+```
+
+After registration, return to the [GitFOSS CI Runner register page] to complete the registration. Input the runner token to associate it with your GitFOSS organization.  
+
+That's it! Your runner is now registered and ready to run pipeline stages.  
+Commit some code and push it to your GitFOSS repository to trigger a pipeline run.  
+
+## Exposed API
+
+The GitFOSS CI Runner exposes a simple HTTP API.  
+
+### Pipeline
+
+#### Trigger a stage run
+
+```
+POST /trigger
+
+Headers:
+  Authorization: Bearer $token
+  Accept: application/json
+
+Body:
+  pipelineId: $pipelineId
+  orgId: $orgId
+  repoId: $repoId
+  gitRepoDir: $gitRepoDir
+  stageId: $stageId
+```
+
+This will return a JSON response with the pipeline run details.
+
+```json
+{
+  "pipelineId": $pipelineId,
+  "runId": $runId,
+  "stageId": $stageId,
+  "status": "PENDING" // PASSED, PENDING, RUNNING, SUCCESS, FAILURE, CANCELED
+}
+```
+
+#### Reset/clear pipeline cache
+
+Pass on `stage` to clear the cache for a specific pipeline stage.
+
+```
+POST /cache/reset
+
+Headers:
+  Authorization: Bearer $token
+  Accept: application/json
+```
+
+This will clear the pipeline cache and respond with a 200 OK status.
+
+### Pipeline Stages
+
+#### Stage Status
+
+```
+GET /stage/$stageId
+
+Headers:
+  Authorization: Bearer $token
+  Accept: application/json
+```
+
+This will return a JSON response with the pipeline stage status.
+
+```json
+{
+  "pipelineId": $pipelineId,
+  "stageId": $stageId,
+  "status": "RUNNING" // PASSED, PENDING, RUNNING, SUCCESS, FAILURE, CANCELED
+}
+```
+
+### Stage Logs
+
+This endpoint has a special feature allowing to stream real-time logs from the pipeline stage.
+
+Pass an optional query param `?stream=(http|ws|sse)` to stream logs via HTTP, WebSocket, or Server-Sent Events respectively.
+
+```
+GET /stage/$stageId/logs?stream=(http|ws|sse)
+
+Headers:
+  Authorization: Bearer $token
+  Accept: application/json
+```
+
+This will return a JSON response with the pipeline run logs.
+
+```json
+{
+  "pipelineId": $pipelineId,
+  "stageId": $stageId,
+  "logs": "... whatever the docker container logs are ..."
+}
+```
+
+### Pipeline Artefacts
+
+#### Artefact List
+
+```
+GET /run/$runId/artifacts
+
+Headers:
+  Authorization: Bearer $token
+  Accept: application/json
+```
+
+This will return a JSON response with the pipeline run artefacts.
+
+```json
+{
+  "pipelineId": $pipelineId,
+  "runId": $runId,
+  "stageId": $stageId,
+  "artefacts": [
+    {
+      "id": $artefactId,
+      "name": "filename.txt",
+      "path": "/path/to/filename.txt",
+      "size": 1024,
+      "sha256": "sha256 hash of the file"
+    }
+  ]
+}
+```
+
+### Runner Config
+
+#### Get runner config
+
+```
+GET /runner/config
+
+Headers:
+  Authorization: Bearer $token
+  Accept: application/json
+```
+
+This will return a JSON response with the runner configuration.
+
+```json
+{
+  "registration": {
+    "id": $runnerId,
+    "url": "https://ci.gitfoss.dev/runners/$runnerId"
+    "token": $runnerToken,
+  },
+  "defaultImage": "alpine:latest",
+  "defaultTimeout": 900, // 15 minutes in seconds
+  "successExit": [0], // exit codes that indicate success
+}
+```
+
+### Runner version
+
+```
+GET /runner/version
+
+Headers:
+  Authorization: Bearer $token
+  Accept: application/json
+```
+
+This will return a JSON response with the runner version.
+
+```json
+{
+  "version": "0.1.0",
+  "commit": "7ecdd3b"
+}
+```
+
+## Development
+
+TODO: Write development instructions here
+
+## Contributing
+
+1. Fork it (<https://gitfoss.dev/ethicdevs/gitfoss-ci-runner/fork>)
+2. Create your feature branch (`git checkout -b my-new-feature`)
+3. Commit your changes (`git commit -am 'Add some feature'`)
+4. Push to the branch (`git push origin my-new-feature`)
+5. Create a new Pull Request
+
+## Contributors
+
+- [Willi Wonka](https://gitfoss.dev/@willi.wonka38) - creator and maintainer
+
+## License
+
+This project is released under the [MIT License](LICENSE).

new file
packages/gitfoss-ci-runner/example.ci.yml
@@ -0,0 +1,26 @@
+stages:
+  - name: hello
+    image: alpine:3.18
+    workdir: .:/home/node/
+    env:
+      FOO: "$HOME"
+    shell: sh
+    commands:
+      - echo "0.1.0" > build/version.txt
+      - echo "hello from build! ($FOO)" > file.txt
+      - cat file.txt
+      - exit 0 # On-purpose success for example
+    cached:
+      - /home/node/build/
+  - name: success
+    image: alpine:3.18
+    workdir: .:/home/node/
+    shell: sh
+    commands:
+      - exit 0 # On-purpose success for example
+  - name: failing
+    image: alpine:3.18
+    workdir: .:/home/node/
+    shell: sh
+    commands:
+      - exit 1 # On-purpose failure for example

new file
packages/gitfoss-ci-runner/shard.lock
@@ -0,0 +1,10 @@
+version: 2.0
+shards:
+  crystar:
+    git: https://github.com/naqvis/crystar.git
+    version: 0.4.0
+
+  docker:
+    git: https://gitfoss.dev/ethicdevs/crystal-docker-api.git
+    version: 0.4.4+git.commit.6859cb7a8fb64df78812fab318c9bb8e91ebdb9d
+

new file
packages/gitfoss-ci-runner/shard.yml
@@ -0,0 +1,21 @@
+name: gitfoss-ci-runner
+version: 0.1.0
+
+authors:
+  - Willi Wonka <willi.wonka38@proton.me>
+
+targets:
+  gitfoss-ci-runner:
+    main: src/gitfoss-ci-runner.cr
+
+crystal: ">= 1.20.0"
+
+license: MIT
+
+dependencies:
+  crystar:
+    github: naqvis/crystar
+  docker:
+    # github: aca-labs/crystal-docker
+    git: https://gitfoss.dev/ethicdevs/crystal-docker-api.git
+    tag: 0.4.4

new file
packages/gitfoss-ci-runner/spec/gitfoss-ci-runner_spec.cr
@@ -0,0 +1,9 @@
+require "./spec_helper"
+
+describe Gitfoss::Ci::Runner do
+  # TODO: Write tests
+
+  it "works" do
+    false.should eq(true)
+  end
+end

new file
packages/gitfoss-ci-runner/spec/spec_helper.cr
@@ -0,0 +1,2 @@
+require "spec"
+require "../src/gitfoss-ci-runner"

new file
packages/gitfoss-ci-runner/src/command.cr
@@ -0,0 +1,325 @@
+require "docker"
+require "crystar"
+
+require "./logger"
+
+# --- Command struct with Docker interactions ---
+struct Command
+  # include Gitfoss::Ci::Runner::Logger
+
+  image : String
+  name : String
+  env : Hash(String, String)
+  commands : Array(String)
+  cached : Array(String)
+  shell : String
+  workdir : String
+  artifacts_to_set : Hash(String, String)   # was Stash
+  artifacts_to_get : Hash(String, String)   # was UnStash
+
+  def initialize(
+    @image : String,
+    @name : String,
+    @env = {} of String => String,
+    @commands = [] of String,
+    @cached = [] of String,
+    @shell = "",
+    @workdir = "",
+    @artifacts_to_set = {} of String => String,
+    @artifacts_to_get = {} of String => String,
+  )
+    log_pipe "Command initialized:\n- image: #{@image}\n- name: #{@name}\n- env: #{@env}\n- commands: #{@commands}\n- cached: #{@cached}\n- shell: #{@shell}\n- workdir: #{@workdir}\n- artifacts_to_set: #{@artifacts_to_set}\n- artifacts_to_get: #{@artifacts_to_get}"
+  end
+
+  def run(
+    docker : Docker::Client,
+    project : Project,
+    pipeline : Pipeline,
+    stage : Stage,
+  )
+    exit_code = 0
+    binds = [] of String
+    container = nil
+
+    begin
+      # log_pipe "Env. vars parsing ..."
+      env_array = [] of String
+      @env.each do |k, v|
+        val = v
+        if val.size > 0 && val[0] == '$'
+          key = val[1..-1]?
+          val = key ? ENV[key]? || "" : ""
+        end
+        env_array << "#{k}=#{val}"
+      end
+      log_pipe "Env. vars set up"
+    rescue ex : Exception
+      err_pipe "Failure: env_array", ex
+      exit 1
+    end
+
+    begin
+      # log_pipe "Binds parsing ..."
+      workdir_local = @workdir
+      idx = workdir_local.index(":")
+      if idx && idx > 0
+        host_path = File.realpath(workdir_local[0...idx])
+        workdir_local = workdir_local[(idx + 1)..-1]?
+        if workdir_local
+          binds << "#{host_path}:#{workdir_local}"
+        end
+      end
+      log_pipe "Workdir bind set up"
+    rescue ex : Exception
+      err_pipe "Failure: workdir.binds", ex
+      exit 1
+    end
+
+    begin
+      log_pipe "Cache volumes parsing ..."
+      @cached.each do |path|
+        vid = "cache_volume"
+        # todo(docker): fork docker shard and add missing modules (volumes, archives, etc)
+        volumes = docker.volumes.list(filters: { "name" => [vid] }) rescue [] of Docker::Api::Models::Volume
+        if volumes.size == 0
+          docker.volumes.create(name: vid)
+        end
+        log_pipe "Mounted volume: #{vid}:#{path}"
+        binds << "#{vid}:#{path}"
+      end
+      log_pipe "Cache volumes set up"
+    rescue ex : Exception
+      err_pipe "Failure: cache volumes", ex
+      exit 1
+    end
+
+    begin
+      log_pipe "Pulling image #{@image} ..."
+      docker.images.pull(@image)
+      log_pipe "Pulled image #{@image}"
+    rescue ex : Exception
+      err_pipe "Failure: docker.images.pull: cannot pull #{@image}", ex
+      exit 1
+    end
+
+    begin
+      log_pipe "Creating container ..."
+      container = docker.containers.create(
+        image: @image,
+        name: @name,
+        host_config: {
+          binds: binds
+        },
+        tty: true,
+        attach_stdout: true,
+        attach_stderr: true,
+        working_dir: workdir_local,
+        cmd: ["/tmp/script.sh"],
+        env: env_array,
+      )
+      log_pipe "Container created: #{container.id}"
+    rescue ex : Exception
+      err_pipe "Failure: docker.containers.create", ex
+      exit 1
+    end
+
+    begin
+      # log_pipe "Send pipeline script to container ..."
+      tar_bytes = create_commands_tar
+      container.archive.put("/tmp", tar_bytes)
+      log_pipe "Written pipeline script in container: /tmp/script.sh"
+    rescue ex : Exception
+      err_pipe "Failure: container.archive.put", ex
+      exit 1
+    end
+
+      # begin
+      #   container.start
+      #   container.exec(["chmod", "a+x", "/tmp/script.sh"])
+      # rescue e : Exception
+      #   puts "script chmod failed: #{e.message}\n"
+      #   exit 1
+      # end
+
+    begin
+      # log_pipe "Reloading container ..."
+      container.reload!
+      workdir_actual = container.attrs.config.working_dir
+      log_pipe "Changed workdir to: #{workdir_actual}"
+      # log_pipe "Container reloaded: #{container.id}"
+    rescue ex : Exception
+      err_pipe "Failure: container.reload", ex
+      exit 1
+    end
+
+    begin
+      log_pipe "Getting artifacts ..."
+      @artifacts_to_get.each do |name, path|
+        data = pipeline.get_artifact(name)
+        if data.nil?
+          raise "Failure: pipeline.get_artifact: #{name} not found"
+        end
+
+        dest = path
+        dest = "#{workdir_actual}/#{path}" if path[0]? != '/'
+
+        container.archive.put(dest, data)
+      end
+      log_pipe "Artifacts retrieved!"
+    rescue ex : Exception
+      err_pipe "Failure: container.archive.put", ex
+      exit 1
+    end
+
+    # start log streamer
+    # spawn do
+    #   begin
+    #     if container.responds_to?(:logs)
+    #       container.logs(follow: true, stdout: true, stderr: true) do |chunk|
+    #         STDOUT.print chunk
+    #         STDOUT.flush
+    #       end
+    #     else
+    #       # todo(docker): fork docker shard and add missing modules (volumes, archives, api, etc)
+    #       # docker.api.server_logs(container.id, follow: true, stdout: true, stderr: true) do |chunk|
+    #       #   STDOUT.print chunk
+    #       #   STDOUT.flush
+    #       # end
+    #     end
+    #   rescue ex
+    #     STDERR.puts "log stream error: #{ex.message}"
+    #   end
+    # end
+
+    begin
+      log_pipe "Starting container ..."
+      container.start
+      log_pipe "Container started: #{container.id}"
+    rescue ex : Exception
+      log_pipe "Failed to start container: #{ex.message}"
+      exit 1
+    end
+
+    begin
+      log_pipe "Waiting for container to exit ..."
+      exit_code = container.wait
+      log_pipe "Container exited: #{exit_code} (#{container.id})"
+    rescue ex : Exception
+      log_pipe "Failed to wait for container: #{ex.message}"
+      exit 1
+    end
+
+    begin
+      log_pipe "Fetching container logs ..."
+      logs = container.logs
+      log_pipe "Container logs:\n#{logs}"
+    rescue ex : Exception
+      log_pipe "Failed to fetch container logs: #{ex.message}"
+      exit 1
+    end
+
+    if exit_code != 0
+      raise Exception.new("Stage #{stage.name} didn't complete.")
+    end
+
+    begin
+      # log_pipe "Reloading container ..."
+      container.reload!
+      log_pipe "Container reloaded: #{container.id}"
+    rescue ex : Exception
+      log_pipe "Failed to reload container: #{ex.message}"
+      exit 1
+    end
+
+    begin
+      # log_pipe "Setting artifacts ..."
+      @artifacts_to_set.each do |artifact_name, path|
+        tar_bytes_from_container = container.archive.get(path)
+        pipeline.set_artifact(
+          artifact_name,
+          tar_bytes_from_container
+        )
+      end
+      log_pipe "Artifacts set: #{@artifacts_to_set.size}"
+    rescue ex : Exception
+      err_pipe "Failed to set artifacts:", ex
+      exit 1
+    end
+
+    if container
+      begin
+        # log_pipe "Stopping container ..."
+        container.stop rescue nil
+        log_pipe "Stopped container: #{container.id}"
+      ensure
+        # log_pipe "Removing container ..."
+        container.remove(v: true, force: true) rescue nil
+        log_pipe "Removed container: #{container.id}"
+      end
+    end
+
+    exit_code
+  end
+
+  def create_commands_tar : Bytes
+    buf = IO::Memory.new
+    Crystar::Writer.open(buf) do |tw|
+      script = create_script
+      hdr = Crystar::Header.new(
+        name: "script.sh",
+        size: script.size.to_i64,
+        mode: 0o644 | 0o111, # results in 0o755
+        # uname: "user",
+        # gname: "group",
+        format: Crystar::Format::USTAR,
+      )
+      tw.write_header(hdr)
+      tw.write(script.to_slice)
+    end
+    buf.to_s.to_slice
+  end
+
+  def create_script : Bytes
+    sh = @shell.size > 0 ? @shell : "sh"
+    sb = String.build do |io|
+      io << "#! /bin/#{sh}\n"
+      io << "set -e\n"
+      # io << "set -x\n"
+      @commands.each do |c|
+        io << c
+        io << "\n"
+      end
+    end
+    sb.to_slice
+  end
+
+  def to_s : String
+    s = "Command:#{@image}:#{@shell}:cmds["
+    @commands.each_with_index do |c, i|
+      s << (i > 0 ? "," : "")
+      s << c
+    end
+    s << "]"
+
+    unless @env.empty?
+      s << ":env["
+      i = 0
+      @env.each do |k, v|
+        s << (i > 0 ? "," : "")
+        i += 1
+        s << "#{k}=#{v}"
+      end
+      s << "]"
+    end
+
+    unless @cached.empty?
+      s << ":cached["
+      @cached.each_with_index do |c, i|
+        s << (i > 0 ? "," : "")
+        s << c
+      end
+      s << "]"
+    end
+    s
+  end
+end

new file
packages/gitfoss-ci-runner/src/gitfoss-ci-runner.cr
@@ -0,0 +1,145 @@
+require "option_parser"
+
+require "./project"
+require "./pipeline"
+require "./stage"
+require "./command"
+
+require "./logger"
+require "./utils"
+
+module Gitfoss::Ci::Runner
+  # include Gitfoss::Ci::Runner::Logger
+
+  VERSION = "0.1.0"
+  DOCKER_API_VERSION = "v1.47" # docker 27.3.1
+
+  def self.run()
+    # Entry point
+    options = {} of Symbol => String
+
+    OptionParser.parse do |parser|
+      parser.banner = "Usage: run_pipeline.cr [options] pipeline.yml"
+      parser.on("-d", "--docker-host HOST", "Docker host (optional)") do |h|
+        options[:docker_host] = h
+      end
+      parser.on("-f", "--file FILE", "Path to the YAML file") do |f|
+        options[:yaml_file] = f
+      end
+      parser.on("-h", "--help", "Show this help") do
+        puts parser
+        exit 0
+      end
+      parser.invalid_option do |flag|
+        on_failed_pipeline
+        err_pipe "ERROR: #{flag} is not a valid option."
+        err_pipe parser.to_s
+        exit 1
+      end
+    end
+
+    yaml_path = options[:yaml_file]? || ARGV[0]?
+    unless yaml_path
+      on_failed_pipeline
+      err_pipe "No YAML file specified"
+      exit 1
+    end
+
+    unless File.exists?(yaml_path.not_nil!)
+      on_failed_pipeline
+      err_pipe "File not found: #{yaml_path}"
+      exit 1
+    end
+
+    yaml = load_yaml(yaml_path)
+
+    # Instantiate Docker client (respect optional host)
+    docker =
+      if options[:docker_host]?
+        # puts "Docker base url: #{options[:docker_host]}\n"
+        Docker::Client.new(options[:docker_host], DOCKER_API_VERSION)
+      else
+        Docker::Client.new(nil, DOCKER_API_VERSION)
+      end
+
+    project = Project.new(orgSlug: "ethicdevs", repoSlug: "gitfoss")
+    pipeline = Pipeline.new(id: "pipeline-1")
+
+    # YAML expected top-level "stages" array
+    stages_node = yaml["stages"]
+    unless stages_node
+      on_failed_pipeline
+      err_pipe "YAML missing 'stages' top-level key"
+      exit 1
+    end
+
+    # Iterate stages in order
+    stages_node.as_a.each do |stage_node|
+      stage_name = stage_node["name"].as_s
+      log_pipe "Running stage: #{stage_name}"
+      stage = Stage.new(stage_name)
+
+      # The YAML could describe a single command per stage or multiple; support both.
+      # If stage has "commands" at top-level, map stage->Command. Otherwise if it contains
+      # a "steps" array with multiple commands, create one Command per step.
+      if stage_node["steps"]?
+        stage_node["steps"].as_a.each do |step_node|
+          cmd_node =
+            if step_node.is_a?(YAML::Any) && step_node["image"]
+              step_node
+            else
+              # inherit common stage-level fields if step is a plain command string
+              YAML.parse({
+                "image" => stage_node["image"],
+                "workdir" => stage_node["workdir"]? || "",
+                "shell" => stage_node["shell"]? || "sh",
+                "env" => stage_node["env"]? || {} of String => String,
+                "commands" => [step_node]
+              }.to_yaml)
+            end
+
+          begin
+            command_name = "#{project.to_s}/#{pipeline.id}/stage_#{stage_name}_#{Time.utc.to_unix}".gsub('/', '_')
+            command = map_to_command(command_name, cmd_node)
+            exit_code = command.run(docker, project, pipeline, stage)
+            if exit_code != 0
+              on_failed_pipeline
+            else
+              on_passed_pipeline
+            end
+          rescue ex
+            on_failed_pipeline
+            err_pipe "Stage #{stage_name} step failed", ex
+            exit 1
+          end
+        end
+      else
+        # single-command-per-stage case: map entire stage node to one Command
+        begin
+          command_name = "#{project.to_s}/#{pipeline.id}/stage_#{stage_name}_#{Time.utc.to_unix}".gsub('/', '_')
+          command = map_to_command(command_name, stage_node)
+          exit_code = command.run(docker, project, pipeline, stage)
+          if exit_code != 0
+            on_failed_pipeline
+          else
+            on_passed_pipeline
+          end
+        rescue ex
+          on_failed_pipeline
+          err_pipe "Stage #{stage_name} failed", ex
+          exit 1
+        end
+      end
+    end
+  end
+end
+
+
+Gitfoss::Ci::Runner.run
+
+# Example usage (commented out):
+# docker = Docker.client
+# pipeline = Pipeline.new
+# stage = Stage.new("build")
+# cmd = Command.new("alpine", {"FOO" => "$HOME"}, ["echo hello"], [], "", ".", {"out" => "/tmp/out"}, {"in" => "/tmp/in"})
+# cmd.run(docker, pipeline, stage)

new file
packages/gitfoss-ci-runner/src/logger.cr
@@ -0,0 +1,27 @@
+# module Gitfoss::Ci::Runner::Logger
+  module C
+    RESET = "\033[0m"
+    BOLD = "\033[1m"
+    GREEN = "\033[38;5;10m"
+    RED = "\033[38;5;9m"
+    TEAL="\033[38;5;6m"
+  end
+
+  def log_pipe(msg : String, **props)
+    puts "#{C::BOLD}#{C::TEAL}>>#{C::RESET} #{msg}#{C::RESET}\n"
+    puts props.inspect if props.size > 0
+  end
+
+  def err_pipe(msg : String, ex : Exception? = nil)
+    STDERR.puts "#{C::BOLD}#{C::RED}>>#{C::RESET} #{msg}#{C::RESET}\n"
+    STDERR.puts ex.inspect
+  end
+
+  def on_passed_pipeline
+    puts "#{C::BOLD}#{C::GREEN}Pipeline completed successfully!#{C::RESET}\n"
+  end
+
+  def on_failed_pipeline
+    puts "#{C::BOLD}#{C::RED}Pipeline failed!#{C::RESET}\n"
+  end
+# end

new file
packages/gitfoss-ci-runner/src/pipeline.cr
@@ -0,0 +1,34 @@
+# --- Pipeline implementation matching Go semantics ---
+#
+# Go behavior (from provided code):
+# - Pipeline has Stash(name, bytes) which stores bytes by name.
+# - Pipeline has UnStash(name) which returns bytes previously stashed.
+#
+# Here we implement an in-memory Pipeline that stores artifacts in a Hash(String, Bytes).
+# This preserves the same behavior and API as the Go code used in the original snippet.
+class Pipeline
+  getter id : String
+
+
+  def initialize(@id : String)
+    @artifacts = {} of String => Bytes
+    @mutex = Mutex.new
+  end
+
+  # get_artifact(name) corresponds to Go's UnStash(name) -> ([]byte, error)
+  # Returns Bytes? (nil if not found)
+  def get_artifact(name : String) : Bytes?
+    @mutex.synchronize do
+      @artifacts[name]
+    end
+  end
+
+  # set_artifact(name, data) corresponds to Go's Stash(name, bytes)
+  def set_artifact(name : String, data : Bytes)
+    @mutex.synchronize do
+      # store a copy to mimic Go's slice ownership semantics
+      @artifacts[name] = data.to_slice
+    end
+    nil
+  end
+end

new file
packages/gitfoss-ci-runner/src/project.cr
@@ -0,0 +1,11 @@
+class Project
+  property orgSlug : String
+  property repoSlug : String
+
+  def initialize(@orgSlug : String, @repoSlug : String)
+  end
+
+  def to_s
+    "#{@orgSlug}/#{@repoSlug}"
+  end
+end

new file
packages/gitfoss-ci-runner/src/stage.cr
@@ -0,0 +1,7 @@
+
+
+# --- Stage placeholder ---
+class Stage
+  property name : String
+  def initialize(@name : String); end
+end

new file
packages/gitfoss-ci-runner/src/utils.cr
@@ -0,0 +1,59 @@
+require "yaml"
+
+require "./command"
+
+# Simple structs to map YAML; we will parse into native types directly.
+def load_yaml(path : String) : YAML::Any
+  YAML.parse(File.read(path))
+end
+
+def map_to_command(command_name : String, node : YAML::Any) : Command
+  image = node["image"].as_s
+  workdir = node["workdir"].as_s? || "."
+  shell = node["shell"].as_s? || "bash"
+  env = {} of String => String
+  if node["env"]?
+    node["env"].as_h.each do |k, v|
+      env[k.as_s] = v.as_s
+    end
+  end
+
+  commands = [] of String
+  if node["commands"]?
+    node["commands"].as_a.each do |c|
+      commands << c.as_s
+    end
+  end
+
+  cached = [] of String
+  if node["cached"]?
+    node["cached"].as_a.each do |c|
+      cached << c.as_s
+    end
+  end
+
+  artifacts_to_set = {} of String => String
+  if node["stash"]?
+    node["stash"].as_h.each do |k, v|
+      artifacts_to_set[k.as_s] = v.as_s
+    end
+  end
+
+  artifacts_to_get = {} of String => String
+  if node["unstash"]?
+    node["unstash"].as_h.each do |k, v|
+      artifacts_to_get[k.as_s] = v.as_s
+    end
+  end
+
+  Command.new(image,
+    command_name,
+    env,
+    commands,
+    cached,
+    shell,
+    workdir,
+    artifacts_to_set,
+    artifacts_to_get
+  )
+end

new file
packages/gitfoss-ci/.editorconfig
@@ -0,0 +1,9 @@
+root = true
+
+[*.cr]
+charset = utf-8
+end_of_line = lf
+insert_final_newline = true
+indent_style = space
+indent_size = 2
+trim_trailing_whitespace = true

new file
packages/gitfoss-ci/.gitfoss.ci
@@ -0,0 +1,32 @@
+pipeline:
+  image: alpine:latest
+  stages:
+    - name: test
+      commands:
+        - yarn install --dev-only
+        - yarn test
+      test_report:
+        format: junit-coverage
+        path: ./tests/coverage/
+    - name: build
+      env:
+        - NODE_ENV=production
+        - DATABASE_URL=$DATABASE_URL
+      commands:
+        - yarn install
+        - yarn generate
+        - yarn build
+      save_artefacts:
+        - distributables: ./dist/
+    - name: deploy-staging
+      manual: true
+      commands:
+        - ./scripts/deploy.sh --env=staging /app/dist/
+      load_artefacts:
+        - distributables:/app/dist/
+    - name: deploy-prod
+      manual: true
+      commands:
+        - ./scripts/deploy.sh --env=production /app/dist/
+      load_artefacts:
+        - distributables:/app/dist/

new file
packages/gitfoss-ci/.gitignore
@@ -0,0 +1,5 @@
+/docs/
+/lib/
+/bin/
+/.shards/
+*.dwarf

new file
packages/gitfoss-ci/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Willi Wonka <willi.wonka38@proton.me>
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

new file
packages/gitfoss-ci/README.md
@@ -0,0 +1,79 @@
+# gitfoss-ci
+
+GitFOSS CI is a continuous integration orchestraction app built in Crystal.  
+It exposes a set of endpoints so the GitFOSS forge can interact with it.  
+
+## Features
+
+- Simple pipeline manifest file format (yaml): .gitfoss.ci
+
+```yml
+pipeline:
+  image: alpine:latest
+  stages:
+    - name: test
+      commands:
+        - yarn install --dev-only
+        - yarn test
+      test_report:
+        format: junit-coverage
+        path: ./tests/coverage/
+    - name: build
+      env:
+        - NODE_ENV=production
+        - DATABASE_URL=$DATABASE_URL # from Env["DATABASE_URL"]
+      commands:
+        - yarn install
+        - yarn generate
+        - yarn build
+      save_artefacts:
+        - distributables:./dist/
+    - name: deploy-staging
+      manual: true
+      commands:
+        - ./scripts/deploy.sh --env=staging /app/dist/
+      load_artefacts:
+        - distributables:/app/dist/
+    - name: deploy-prod
+      manual: true
+      commands:
+        - ./scripts/deploy.sh --env=production /app/dist/
+      load_artefacts:
+        - distributables:/app/dist/
+```
+
+- Run containerized builds using Docker and specified image.
+- Report logs in real-time during pipeline execution (streamed to the console).
+- Report test results and coverage reports.
+- Save artefacts (distributables) from each stage and make them available for download.
+- Report pipeline execution status back to GitFOSS.
+- More integrations with Slack, GitHub, and other tools in GitFOSS.
+
+## Installation
+
+```bash
+$ git clone https://github.com/your-github-user/gitfoss-ci.git
+$ cd gitfoss-ci
+$ shards build
+$ ./bin/gitfoss-ci [--flags] CMD [INPUT]
+```
+
+## Usage
+
+TODO: Write usage instructions here
+
+## Development
+
+TODO: Write development instructions here
+
+## Contributing
+
+1. Fork it (<https://gitfoss.dev/ethicdevs/gitfoss-ci/fork>)
+2. Create your feature branch (`git checkout -b my-new-feature`)
+3. Commit your changes (`git commit -am 'Add some feature'`)
+4. Push to the branch (`git push origin my-new-feature`)
+5. Create a new Pull Request
+
+## Contributors
+
+- [Willi Wonka](https://gitfoss.dev/@willi.wonka38) - creator and maintainer

new file
packages/gitfoss-ci/shard.yml
@@ -0,0 +1,13 @@
+name: gitfoss-ci
+version: 0.1.0
+
+authors:
+  - Willi Wonka <willi.wonka38@proton.me>
+
+targets:
+  gitfoss-ci:
+    main: src/gitfoss-ci.cr
+
+crystal: '>= 1.20.0'
+
+license: MIT

new file
packages/gitfoss-ci/spec/gitfoss-ci_spec.cr
@@ -0,0 +1,9 @@
+require "./spec_helper"
+
+describe Gitfoss::Ci do
+  # TODO: Write tests
+
+  it "works" do
+    false.should eq(true)
+  end
+end

new file
packages/gitfoss-ci/spec/spec_helper.cr
@@ -0,0 +1,2 @@
+require "spec"
+require "../src/gitfoss-ci"

new file
packages/gitfoss-ci/src/gitfoss-ci.cr
@@ -0,0 +1,6 @@
+# TODO: Write documentation for `Gitfoss::Ci`
+module Gitfoss::Ci
+  VERSION = "0.1.0"
+
+  # TODO: Put your code here
+end

@@ -1,9 +1,11 @@
 todo:
 
-- [x] make ssh server work
-- [ ] make ssh server work every times !!!!!
+- [x] make http git push work
+- [x] make ssh git push work
+- [ ] make ssh git push work every times
+- [ ] find a way to output emoji to git sideband channel
 - [x] make the islands runtime load dependencies properly
-- [ ] finish merge pull request feature
+- [x] finish merge pull request feature
 - [x] add ssh key feature
 - [ ] add update/delete ssh key feature
 - [ ] add a function to change default branch -> update repo/HEAD ref, update config->init.defaultBranch