feat(gitfoss-ci): add gitfoss-ci-runner package (work 100%)@@ -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
@@ -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
@@ -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>
@@ -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
@@ -0,0 +1,6 @@
+/docs/
+/lib/
+/bin/
+/.shards/
+*.dwarf
+cache/
@@ -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.
@@ -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).
@@ -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
@@ -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
+
@@ -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
@@ -0,0 +1,9 @@
+require "./spec_helper"
+
+describe Gitfoss::Ci::Runner do
+ # TODO: Write tests
+
+ it "works" do
+ false.should eq(true)
+ end
+end
@@ -0,0 +1,2 @@
+require "spec"
+require "../src/gitfoss-ci-runner"
@@ -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
@@ -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)
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,7 @@
+
+
+# --- Stage placeholder ---
+class Stage
+ property name : String
+ def initialize(@name : String); end
+end
@@ -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
@@ -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
@@ -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/
@@ -0,0 +1,5 @@
+/docs/
+/lib/
+/bin/
+/.shards/
+*.dwarf
@@ -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.
@@ -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
@@ -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
@@ -0,0 +1,9 @@
+require "./spec_helper"
+
+describe Gitfoss::Ci do
+ # TODO: Write tests
+
+ it "works" do
+ false.should eq(true)
+ end
+end
@@ -0,0 +1,2 @@
+require "spec"
+require "../src/gitfoss-ci"
@@ -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