forge-ci 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: b200819f98e6f303864df60059e2df4145cc5f20811db8e7111d3fc75781c540
4
+ data.tar.gz: c42f8826e29c17e82bcfb27813d03f20214c6b47cfb1e9069a516efc739dead6
5
+ SHA512:
6
+ metadata.gz: 469f6da6b783565507019cb118749c74dcc5bd0cec712882211e0663417cb172a61ff6e7d4547546d7713ec800ac5abb84b1c9e87acdae0e75a3276e603d268b
7
+ data.tar.gz: fc6b483f9b7f780fa69a96bb3c5d9569d6c08cc0134a0ce5eb0176ac535346763257f00c8558358691ce61646c01f6cc2d0ab054e532fd286f5fd64c07a56d31
data/MIT-LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Azmi Muwahid
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,273 @@
1
+ # forge-ci
2
+
3
+ [![CI](https://github.com/azmi2409/forge-ci/actions/workflows/ci.yml/badge.svg)](https://github.com/azmi2409/forge-ci/actions/workflows/ci.yml)
4
+
5
+ An open-source, **CircleCI-compatible CI/CD system** — pure Ruby, MIT licensed.
6
+
7
+ **Status: MVP complete.** All 8 planned components are built, tested (259 runs,
8
+ 1023 assertions, green), and running from this repo.
9
+
10
+ Push code to GitHub → webhook → config compiled → jobs claimed by polling
11
+ runners → executed in Docker → results and commit statuses posted back. No
12
+ inbound ports on runners, no proprietary control plane.
13
+
14
+ ## Quickstart
15
+
16
+ **1. Validate a config anywhere (Ruby ≥ 3.0 only, no Docker):**
17
+
18
+ ```sh
19
+ gem build forge-ci.gemspec && gem install ./forge-ci-0.1.0.gem
20
+ forge-ci validate .circleci/config.yml
21
+ forge-ci compile .circleci/config.yml --branch main
22
+ ```
23
+
24
+ **2. Run the full system locally** (needs Docker for the runner):
25
+
26
+ ```sh
27
+ # Control plane (SQLite fallback for dev; Postgres for production)
28
+ cd web
29
+ DATABASE_URL="sqlite3:db/development.sqlite3" bin/rails db:prepare db:seed
30
+ DATABASE_URL="sqlite3:db/development.sqlite3" bin/rails server
31
+ # → http://localhost:3000 (admin / password in dev)
32
+
33
+ # Runner (separate shell; needs Docker Engine)
34
+ cd ../runner
35
+ CONTROL_PLANE_URL="http://localhost:3000" \
36
+ RUNNER_TOKEN="<raw token printed by db:seed>" \
37
+ bin/forge-ci-runner
38
+ ```
39
+
40
+ Then create a pipeline: compile a config with the CLI and `POST` the IR to
41
+ `/api/v1/projects/:id/pipelines` (see `web/README.md` — API reference), or
42
+ install the GitHub App and just push.
43
+
44
+ Production deployment: **[DEPLOY.md](DEPLOY.md)** · Remaining work:
45
+ **[TODO.md](TODO.md)**
46
+
47
+ ## Architecture
48
+
49
+ ```
50
+ GitHub ──push/PR webhook──▶ ┌──────────────────┐
51
+ │ Control plane │──claim API──▶ ┌────────────┐
52
+ │ (Rails 8) │◀─heartbeats── │ Runner │
53
+ │ │──log chunks──▶│ (Docker) │
54
+ │ Postgres │ └────────────┘
55
+ │ S3/MinIO │ ┌────────────┐
56
+ └──────────────────┘◀─heartbeats── │ Runner │
57
+ │ │ (Docker) │
58
+ ▼ └────────────┘
59
+ commit statuses
60
+ back to GitHub
61
+ ```
62
+
63
+ - **Control plane** owns projects, pipelines, workflow/job state machines,
64
+ the GitHub App, and the polling APIs. Runners never accept inbound
65
+ connections — they poll and claim work, Buildkite-style.
66
+ - **Runners** are dumb and disposable: claim → `docker run` → stream logs →
67
+ report. Scale by adding hosts.
68
+ - **Config compatibility**: `.circleci/config.yml` (v2.1 subset) is compiled
69
+ server-side into a normalized IR, mirroring CircleCI's own
70
+ `configs/compile` semantics. See the supported-subset table below.
71
+
72
+ ## Repository layout
73
+
74
+ | Path | What | Component |
75
+ |---|---|---|
76
+ | `/` (this dir) | `forge-ci` gem: config **compiler** + CLI | 1 |
77
+ | `web/` | Rails control plane: intake, claim API, GitHub App, log streaming, cache/artifacts, contexts | 2, 3, 4, 6, 7, 8 |
78
+ | `runner/` | Docker runner daemon (stdlib-only Ruby) | 5 |
79
+ | `TODO.md` | Remaining work, honestly listed | — |
80
+ | `DEPLOY.md` | Production deployment guide | — |
81
+
82
+ Each part has its own README with setup, API, and env vars:
83
+ [`web/README.md`](web/README.md) · [`runner/README.md`](runner/README.md)
84
+
85
+ ## Component status
86
+
87
+ | # | Component | Status |
88
+ |---|---|---|
89
+ | 1 | Config compiler (pure function: parse → validate → interpolate → DAG → IR) | ✅ shipped as the `forge-ci` gem |
90
+ | 2 | Rails control plane (projects, pipelines, state machines, REST API, web UI) | ✅ |
91
+ | 3 | GitHub App (HMAC webhooks, config fetch at SHA, commit statuses, fork-PR secret gating) | ✅ code done — [app registration is manual](web/README.md#github-app-setup-component-3) |
92
+ | 4 | Polling runner claim API (atomic claim, heartbeats, reclaim-on-timeout) | ✅ |
93
+ | 5 | Docker runner (poll → `docker run` + sidecars → step exec → report) | ✅ |
94
+ | 6 | Log streaming (chunked upload → object storage, SSE live tail) | ✅ |
95
+ | 7 | Cache + artifacts (S3-compatible, signed URLs, key fallback) | ✅ |
96
+ | 8 | Contexts/secrets (encrypted at rest, env injection, masking, rotation, fork gating) | ✅ |
97
+
98
+ Deliberately deferred: `machine`/`macos`/`windows` executors, orb registry,
99
+ dynamic config, `parallelism`/test splitting, cron pipelines, SSH re-run.
100
+ Full list in [TODO.md](TODO.md).
101
+
102
+ ## The gem (config compiler)
103
+
104
+ `forge-ci` compiles CircleCI v2.1-style pipeline YAML into a normalized,
105
+ JSON-serializable **intermediate representation (IR)** that the control plane
106
+ and runner execute. Pure Ruby, **stdlib only** — no network, no processes, no
107
+ dependencies. Tested on Ruby 3.2.3+ (requires >= 3.0).
108
+
109
+ ### Install
110
+
111
+ ```ruby
112
+ # Gemfile
113
+ gem "forge-ci"
114
+ ```
115
+
116
+ Or build locally:
117
+
118
+ ```sh
119
+ gem build forge-ci.gemspec
120
+ gem install ./forge-ci-0.1.0.gem
121
+ ```
122
+
123
+ ### Usage
124
+
125
+ ```ruby
126
+ require "forge_ci"
127
+
128
+ yaml = File.read(".circleci/config.yml")
129
+
130
+ ir, errors = ForgeCI::Compiler.compile(
131
+ yaml,
132
+ pipeline_params: {
133
+ "branch" => "main", # resolves << pipeline.git.branch >> and branch filters
134
+ "number" => 42, # resolves << pipeline.number >>
135
+ "parameters" => { "deploy_env" => "production" } # overrides for top-level `parameters:`
136
+ }
137
+ )
138
+
139
+ if errors.empty?
140
+ puts JSON.pretty_generate(ir)
141
+ else
142
+ puts errors # actionable messages with YAML line numbers
143
+ end
144
+
145
+ # Just validation, no IR:
146
+ errors = ForgeCI::Compiler.validate(yaml)
147
+ ```
148
+
149
+ ### CLI
150
+
151
+ The gem ships a `forge-ci` executable (pure stdlib, no extra gems):
152
+
153
+ ```sh
154
+ # Validate a config: exit 0 if valid, 1 otherwise.
155
+ forge-ci validate .circleci/config.yml
156
+
157
+ # Compile a config to its IR as JSON (pretty-printed by default).
158
+ forge-ci compile .circleci/config.yml --branch main --sha abc123 --number 42
159
+
160
+ # Compact JSON output:
161
+ forge-ci compile .circleci/config.yml --json
162
+ ```
163
+
164
+ On failure, each error goes to stderr with its YAML line number:
165
+
166
+ ```sh
167
+ $ forge-ci validate broken.yml
168
+ forge-ci: broken.yml: line 17: workflows.main.jobs: dependency cycle detected: a -> b -> a
169
+ $ echo $?
170
+ 1
171
+ ```
172
+
173
+ `compile` returns `[ir, errors]`. `errors` is empty on success; `ir` is `nil`
174
+ when errors are present. `ForgeCI::Error` is raised **only** when the YAML text
175
+ cannot be parsed at all — everything else (unknown keys, bad references,
176
+ dependency cycles) comes back as error strings.
177
+
178
+ ### IR shape
179
+
180
+ ```ruby
181
+ {
182
+ version: "2.1",
183
+ workflows: [
184
+ {
185
+ name: "build-test-deploy",
186
+ jobs: [
187
+ {
188
+ name: "build",
189
+ executor: {
190
+ type: "docker",
191
+ image: "cimg/node:20",
192
+ environment: { "NODE_ENV" => "test" },
193
+ secondary_images: [{ image: "redis:7", name: "redis", environment: {} }],
194
+ resource_class: "medium"
195
+ },
196
+ working_directory: "~/project",
197
+ steps: [
198
+ { type: "checkout" },
199
+ { type: "run", name: "Build", command: "npm run build",
200
+ shell: nil, background: false, when: "on_success" },
201
+ { type: "save_cache", keys: ["node-deps-v1-abc"], paths: ["node_modules"] },
202
+ # ...
203
+ ]
204
+ }
205
+ ]
206
+ }
207
+ ],
208
+ meta: { warnings: ["no branch supplied via pipeline_params; ..."] }
209
+ }
210
+ ```
211
+
212
+ Jobs inside each workflow are emitted in **topological order** of their
213
+ `requires` edges.
214
+
215
+ ### Supported subset vs CircleCI
216
+
217
+ | CircleCI v2.1 feature | forge-ci v1 |
218
+ |---|---|
219
+ | `version: 2.1` | ✅ |
220
+ | `parameters:` (string/boolean/integer/enum, defaults, overrides) | ✅ |
221
+ | `commands:` reusable steps with parameters | ✅ (inlined at compile time) |
222
+ | `executors:` reusable executors with parameters | ✅ (inlined; job-level keys win) |
223
+ | `jobs:` with `docker:` executor (primary + service images) | ✅ |
224
+ | `working_directory`, `environment`, `resource_class` | ✅ |
225
+ | Steps: `checkout`, `run`, `save_cache`, `restore_cache`, `persist_to_workspace`, `attach_workspace`, `store_artifacts`, `store_test_results`, `add_ssh_keys` | ✅ (last two: runner skips loudly, see below) |
226
+ | `run:` shorthand, `when: on_success/on_fail/always` | ✅ |
227
+ | `when:`/`unless:` conditional steps | ✅ (evaluated at compile time) |
228
+ | `<< parameters.x >>`, `<< pipeline.git.branch >>`, `<< pipeline.number >>` | ✅ |
229
+ | Workflows: `requires` DAG, cycle detection, topological order | ✅ |
230
+ | `filters: branches: only/ignore` (plain names and `/regex/`) | ✅ |
231
+ | `matrix:` (desugared to `job-0`, `job-1`, …; alias usable in `requires`) | ✅ |
232
+ | `orbs:` | ❌ rejected with a clear error (no registry in v1) |
233
+ | `machine:` / `macos:` executors | ❌ rejected with a clear error (Docker only) |
234
+ | `type: approval` jobs | ❌ rejected with a clear error |
235
+ | Dynamic config (`setup:` workflows) | ❌ not yet supported |
236
+ | Docker layer caching, `parallelism` / test splitting | ❌ not yet supported |
237
+ | `filters: tags:` | ⚠️ parsed, ignored with a warning |
238
+ | Missing `workflows:` | ⚠️ implicit `default` workflow with a warning |
239
+
240
+ Cache `keys:` templates (e.g. `{{ checksum "package.json" }}`) are passed
241
+ through untouched — checksum expansion is a runner concern.
242
+
243
+ ### Design notes
244
+
245
+ - **Compile is a pure function.** YAML text in, IR out. No I/O, no network,
246
+ no Docker — the whole thing is unit-testable offline.
247
+ - **Validation is compilation.** Like CircleCI's own `configs/compile`
248
+ endpoint, there is no separate "validate then run" — one pass collects every
249
+ structural problem with file locations.
250
+ - **Errors, not exceptions.** The only raise is `ForgeCI::Error` on
251
+ unparseable YAML. Error strings carry Psych line numbers where the AST can
252
+ locate the key.
253
+ - **Warnings vs errors.** Anything that changes what runs (unknown step,
254
+ cycle, bad reference) is an error. Anything informational lands in
255
+ `ir[:meta][:warnings]`.
256
+
257
+ ## Development
258
+
259
+ ```sh
260
+ rake test # gem suite, fixtures in test/fixtures/
261
+ cd web && bin/rails test
262
+ cd runner && rake test
263
+ ```
264
+
265
+ ## Contributing
266
+
267
+ PRs welcome. Keep the zero-dependency philosophy for the gem and the runner
268
+ (stdlib only). The control plane is a standard Rails 8 app. Please include
269
+ tests — every component ships with its suite green.
270
+
271
+ ## License
272
+
273
+ MIT — see [MIT-LICENSE](MIT-LICENSE).
data/exe/forge-ci ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
5
+ require "forge_ci"
6
+ require "forge_ci/cli"
7
+
8
+ exit ForgeCI::CLI.run(ARGV)
@@ -0,0 +1,203 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require "json"
5
+
6
+ module ForgeCI
7
+ # Command-line interface for forge-ci.
8
+ #
9
+ # forge-ci validate <config.yml>
10
+ # forge-ci compile <config.yml> [--branch B] [--sha S] [--number N] [--json]
11
+ # forge-ci --help | --version
12
+ #
13
+ # Kept deliberately thin so it is unit-testable: +run+ takes argv and IO
14
+ # objects and returns an integer exit status instead of calling +exit+
15
+ # itself. exe/forge-ci is just a wrapper that passes ARGV through.
16
+ class CLI
17
+ USAGE = <<~USAGE.freeze
18
+ Usage: forge-ci <command> [options]
19
+
20
+ Commands:
21
+ validate <config.yml> validate a pipeline config
22
+ (exit 0 if valid, 1 otherwise)
23
+ compile <config.yml> [--branch B] [--sha S]
24
+ [--number N] [--json] compile a pipeline config to
25
+ its IR as JSON
26
+ (pretty JSON by default,
27
+ --json for compact)
28
+
29
+ Options:
30
+ -h, --help show this help and exit
31
+ -v, --version show the gem version and exit
32
+ USAGE
33
+
34
+ # Exit statuses:
35
+ # 0 - success (config valid / compile succeeded / help / version)
36
+ # 1 - the config is invalid, the file could not be read, or the YAML
37
+ # could not be parsed
38
+ # 2 - CLI usage error (unknown command, bad flags, missing arguments)
39
+ def self.run(argv, out: $stdout, err: $stderr)
40
+ new(out: out, err: err).run(argv.dup)
41
+ end
42
+
43
+ def initialize(out:, err:)
44
+ @out = out
45
+ @err = err
46
+ end
47
+
48
+ def run(argv)
49
+ command = argv.shift
50
+ case command
51
+ when "validate" then validate_command(argv)
52
+ when "compile" then compile_command(argv)
53
+ when "-h", "--help", nil then help
54
+ when "-v", "--version" then version
55
+ else usage_error("unknown command #{command.inspect}")
56
+ end
57
+ end
58
+
59
+ private
60
+
61
+ def help
62
+ @out.puts USAGE
63
+ 0
64
+ end
65
+
66
+ def version
67
+ @out.puts ForgeCI::VERSION
68
+ 0
69
+ end
70
+
71
+ def usage_error(message)
72
+ @err.puts "forge-ci: #{message}"
73
+ @err.puts
74
+ @err.puts USAGE
75
+ 2
76
+ end
77
+
78
+ def read_config(path)
79
+ File.read(path)
80
+ rescue Errno::ENOENT
81
+ @err.puts "forge-ci: #{path}: no such file"
82
+ nil
83
+ rescue SystemCallError, IOError => e
84
+ @err.puts "forge-ci: #{path}: could not read file (#{e.message})"
85
+ nil
86
+ end
87
+
88
+ # Prints each error (they already carry YAML line numbers) to stderr.
89
+ # Returns the exit status: 0 when there are no errors, 1 otherwise.
90
+ def report_errors(path, errors)
91
+ if errors.empty?
92
+ @out.puts "forge-ci: #{path}: valid"
93
+ 0
94
+ else
95
+ errors.each { |error| @err.puts "forge-ci: #{path}: #{error}" }
96
+ 1
97
+ end
98
+ end
99
+
100
+ def with_parsed_yaml(path)
101
+ yaml = read_config(path)
102
+ return 1 if yaml.nil?
103
+
104
+ yield yaml
105
+ rescue ForgeCI::Error => e
106
+ # The only raise in the library: the YAML could not be parsed at all.
107
+ @err.puts "forge-ci: #{path}: #{e.message}"
108
+ 1
109
+ end
110
+
111
+ # -- forge-ci validate <config.yml> ------------------------------------
112
+
113
+ def validate_command(argv)
114
+ parser = OptionParser.new do |opts|
115
+ opts.banner = "Usage: forge-ci validate <config.yml>"
116
+ opts.on("-h", "--help", "show this help and exit") do
117
+ @out.puts opts
118
+ return 0
119
+ end
120
+ end
121
+
122
+ begin
123
+ parser.parse!(argv)
124
+ rescue OptionParser::ParseError => e
125
+ return usage_error(e.message)
126
+ end
127
+
128
+ path = argv.shift
129
+ return usage_error("validate requires a <config.yml> argument") if path.nil?
130
+ return usage_error("validate takes exactly one <config.yml> argument") unless argv.empty?
131
+
132
+ with_parsed_yaml(path) do |yaml|
133
+ report_errors(path, ForgeCI::Compiler.validate(yaml))
134
+ end
135
+ end
136
+
137
+ # -- forge-ci compile <config.yml> --------------------------------------
138
+
139
+ def compile_command(argv)
140
+ options = {}
141
+ parser = OptionParser.new do |opts|
142
+ opts.banner = "Usage: forge-ci compile <config.yml> " \
143
+ "[--branch B] [--sha S] [--number N] [--json]"
144
+ opts.on("--branch BRANCH", "pipeline branch; resolves " \
145
+ "<< pipeline.git.branch >> and branch filters") do |v|
146
+ options[:branch] = v
147
+ end
148
+ opts.on("--sha SHA", "pipeline git sha (forwarded to the compiler " \
149
+ "for pipeline context)") do |v|
150
+ options[:sha] = v
151
+ end
152
+ opts.on("--number NUMBER", "pipeline number; resolves " \
153
+ "<< pipeline.number >>") do |v|
154
+ options[:number] = v
155
+ end
156
+ opts.on("--json", "emit compact JSON instead of pretty-printed JSON") do
157
+ options[:json] = true
158
+ end
159
+ opts.on("-h", "--help", "show this help and exit") do
160
+ @out.puts opts
161
+ return 0
162
+ end
163
+ end
164
+
165
+ begin
166
+ parser.parse!(argv)
167
+ rescue OptionParser::ParseError => e
168
+ return usage_error(e.message)
169
+ end
170
+
171
+ path = argv.shift
172
+ return usage_error("compile requires a <config.yml> argument") if path.nil?
173
+ return usage_error("compile takes exactly one <config.yml> argument") unless argv.empty?
174
+
175
+ pipeline_params = build_pipeline_params(options)
176
+
177
+ with_parsed_yaml(path) do |yaml|
178
+ ir, errors = ForgeCI::Compiler.compile(yaml, pipeline_params: pipeline_params)
179
+ if errors.any?
180
+ errors.each { |error| @err.puts "forge-ci: #{path}: #{error}" }
181
+ 1
182
+ else
183
+ @out.puts(options[:json] ? JSON.generate(ir) : JSON.pretty_generate(ir))
184
+ 0
185
+ end
186
+ end
187
+ end
188
+
189
+ def build_pipeline_params(options)
190
+ params = {}
191
+ params["branch"] = options[:branch] if options[:branch]
192
+ params["sha"] = options[:sha] if options[:sha]
193
+ params["number"] = coerce_number(options[:number]) if options[:number]
194
+ params
195
+ end
196
+
197
+ # Pipeline numbers are integers; keep anything else as-is so the
198
+ # interpolator still substitutes it.
199
+ def coerce_number(value)
200
+ value.match?(/\A\d+\z/) ? Integer(value, 10) : value
201
+ end
202
+ end
203
+ end