Conversation
There was a problem hiding this comment.
Pull request overview
This PR replaces the previous Rails implementation of Buildlight with a Zig-based server, including HTTP handlers, WebSocket broadcasting, database access/migrations, and static/template rendering. It also removes the Ruby/Node toolchain and updates deployment/CI configuration accordingly.
Changes:
- Add Zig server implementation (HTTP routes, webhook parsers, DB models/migrations, WebSocket hub, external triggers).
- Replace Rails views/assets pipeline with static
public/assets and simple HTML templates (embedded in release builds). - Remove Rails/RSpec/Node-related code and update Dockerfile, Fly.io config, and GitHub Actions CI to build/test Zig.
Reviewed changes
Copilot reviewed 134 out of 154 changed files in this pull request and generated 12 comments.
Show a summary per file
| File | Description |
|---|---|
| vendor/javascript/.keep | Retains vendor directory placeholder. |
| templates/red.html | Adds simple template placeholder for failing projects page. |
| templates/layout.html | Adds main HTML layout template for the traffic light UI. |
| src/websocket.zig | Implements WebSocket hub, client subscriptions, and broadcast helpers. |
| src/triggers.zig | Adds outbound webhook + Particle.io trigger functions. |
| src/templates.zig | Implements template loading (embedded vs disk) and basic string substitution renderer. |
| src/parsers.zig | Adds webhook parsers (GitHub/Travis/CircleCI) + DB upserts + device update fanout; includes Zig tests. |
| src/models.zig | Adds DB model types and query/update helpers for statuses/devices and color aggregation. |
| src/migrations/001_initial_schema.sql | Adds initial SQL schema migration for statuses/devices. |
| src/main.zig | Adds Zig application entrypoint, route registration, and graceful shutdown. |
| src/handlers.zig | Adds HTTP handlers for health, static files, webhooks, colors, devices, API endpoints, and RYG streaming. |
| src/db.zig | Adds DB pool initialization and embedded migration runner. |
| spec/support/json_helpers.rb | Removes Rails/RSpec JSON helper module. |
| spec/spec_helper.rb | Removes RSpec configuration. |
| spec/requests/circle_webhooks_spec.rb | Removes Rails request specs (CircleCI webhooks). |
| spec/rails_helper.rb | Removes Rails test helper. |
| spec/models/status_spec.rb | Removes Rails model specs for Status. |
| spec/models/device_spec.rb | Removes Rails model specs for Device. |
| spec/interactors/trigger_webhook_spec.rb | Removes Rails interactor specs (webhook trigger). |
| spec/interactors/parse_travis_spec.rb | Removes Rails interactor specs (Travis parsing). |
| spec/interactors/parse_github_spec.rb | Removes Rails interactor specs (GitHub parsing). |
| spec/interactors/parse_circle_spec.rb | Removes Rails interactor specs (CircleCI parsing). |
| spec/fixtures/travis.json | Removes Rails test fixture. |
| spec/fixtures/github.json | Removes Rails test fixture. |
| spec/fixtures/circle_pr.json | Removes Rails test fixture. |
| spec/fixtures/circle.json | Removes Rails test fixture. |
| spec/factories.rb | Removes FactoryBot factories. |
| spec/controllers/webhooks_controller_spec.rb | Removes Rails controller specs (webhooks). |
| spec/controllers/devices_controller_spec.rb | Removes Rails controller specs (devices). |
| spec/controllers/colors_controller_spec.rb | Removes Rails controller specs (colors). |
| spec/controllers/api/red_controller_spec.rb | Removes Rails controller specs (API red). |
| spec/controllers/api/devices_controller_spec.rb | Removes Rails controller specs (API devices). |
| public/websocket.js | Adds vanilla JS WebSocket client to subscribe and update page state/favicon. |
| public/application.css | Adds compiled CSS stylesheet to replace the prior Sass pipeline. |
| package.json | Removes Node/Sass build configuration. |
| package-lock.json | Removes npm lockfile. |
| log/.gitkeep | Retains log directory placeholder. |
| lib/tasks/.gitkeep | Retains legacy lib/tasks placeholder. |
| lib/assets/.gitkeep | Retains legacy lib/assets placeholder. |
| fly.toml | Updates Fly.io deploy config to run Zig migration command and serve /public statics from /app/public. |
| db/seeds.rb | Removes Rails seed file. |
| db/schema.rb | Removes Rails schema.rb (now using embedded SQL migrations). |
| db/migrate/20230311115915_add_workflow_to_statues.rb | Removes Rails migration file. |
| db/migrate/20230305131208_add_status_to_devices.rb | Removes Rails migration file. |
| db/migrate/20230304144230_make_identifier_nullable_on_devices.rb | Removes Rails migration file. |
| db/migrate/20230304135152_add_slug_to_devices.rb | Removes Rails migration file. |
| db/migrate/20230303181951_add_webhook_url_to_devices.rb | Removes Rails migration file. |
| db/migrate/20161012193415_add_service_to_status.rb | Removes Rails migration file. |
| db/migrate/20160510213407_add_name_to_devices.rb | Removes Rails migration file. |
| db/migrate/20160510212722_add_identifier_to_devices.rb | Removes Rails migration file. |
| db/migrate/20160510201736_create_devices.rb | Removes Rails migration file. |
| db/migrate/20121124190606_add_user_to_statuses.rb | Removes Rails migration file. |
| db/migrate/20121123195427_split_colors_on_statuses.rb | Removes Rails migration file. |
| db/migrate/20121123182506_rename_status_to_color_on_statuses.rb | Removes Rails migration file. |
| db/migrate/20121123172057_add_payload_to_statuses.rb | Removes Rails migration file. |
| db/migrate/20121123160543_create_statuses.rb | Removes Rails migration file. |
| config/storage.yml | Removes Rails ActiveStorage config. |
| config/secrets.yml | Removes Rails secrets.yml. |
| config/routes.rb | Removes Rails routes. |
| config/puma.rb | Removes Puma config (now Zig server). |
| config/locales/en.yml | Removes Rails i18n locale file. |
| config/initializers/wrap_parameters.rb | Removes Rails initializer. |
| config/initializers/session_store.rb | Removes Rails initializer. |
| config/initializers/permissions_policy.rb | Removes Rails initializer. |
| config/initializers/particle.rb | Removes Rails initializer. |
| config/initializers/new_framework_defaults_8_0.rb | Removes Rails defaults initializer. |
| config/initializers/new_framework_defaults_5_2.rb | Removes Rails defaults initializer. |
| config/initializers/mime_types.rb | Removes Rails initializer. |
| config/initializers/locale.rb | Removes Rails initializer. |
| config/initializers/inflections.rb | Removes Rails initializer. |
| config/initializers/filter_parameter_logging.rb | Removes Rails initializer. |
| config/initializers/cookies_serializer.rb | Removes Rails initializer. |
| config/initializers/content_security_policy.rb | Removes Rails initializer. |
| config/initializers/backtrace_silencers.rb | Removes Rails initializer. |
| config/initializers/assets.rb | Removes Rails initializer. |
| config/initializers/application_controller_renderer.rb | Removes Rails initializer. |
| config/importmap.rb | Removes Rails importmap config. |
| config/environments/test.rb | Removes Rails environment config. |
| config/environments/production.rb | Removes Rails environment config. |
| config/environments/development.rb | Removes Rails environment config. |
| config/environment.rb | Removes Rails bootstrapping. |
| config/dockerfile.yml | Removes dockerfile-rails config. |
| config/database.yml | Removes Rails database.yml. |
| config/cable.yml | Removes Rails ActionCable config. |
| config/boot.rb | Removes Rails boot file. |
| config/application.rb | Removes Rails application config. |
| config/application.example.yml | Removes Rails example config file. |
| build.zig.zon | Adds Zig package manifest (httpz/pg dependencies). |
| build.zig | Adds Zig build steps for exe + tests and embed_assets option wiring. |
| bin/update | Removes Rails update script. |
| bin/thrust | Removes Rails thruster wrapper. |
| bin/setup | Removes Rails setup script. |
| bin/rubocop | Removes RuboCop wrapper script. |
| bin/rake | Removes rake wrapper script. |
| bin/rails | Removes rails runner script. |
| bin/importmap | Removes importmap runner script. |
| bin/docker-entrypoint | Removes Rails docker entrypoint. |
| bin/dev | Removes Rails dev Procfile runner. |
| bin/bundle | Removes bundler wrapper script. |
| app/views/layouts/application.html.erb | Removes Rails layout view. |
| app/views/colors/index.html.erb | Removes Rails colors index view. |
| app/views/api/red/show.html.erb | Removes Rails API red view. |
| app/models/status.rb | Removes Rails Status model. |
| app/models/device.rb | Removes Rails Device model. |
| app/models/application_record.rb | Removes Rails ApplicationRecord. |
| app/models/.gitkeep | Leaves placeholder after removing Rails models. |
| app/mailers/.gitkeep | Leaves placeholder after removing Rails mailers. |
| app/javascript/channels/index.js | Removes ActionCable JS entry. |
| app/javascript/channels/consumer.js | Removes ActionCable consumer setup. |
| app/javascript/channels/colors_channel.js | Removes ActionCable channel client. |
| app/javascript/application.js | Removes Rails JS entrypoint. |
| app/interactors/trigger_webhook.rb | Removes Rails webhook trigger interactor. |
| app/interactors/trigger_particle.rb | Removes Rails Particle trigger interactor. |
| app/interactors/parse_travis.rb | Removes Rails Travis parser interactor. |
| app/interactors/parse_github.rb | Removes Rails GitHub parser interactor. |
| app/interactors/parse_circle.rb | Removes Rails CircleCI parser interactor. |
| app/helpers/color_helper.rb | Removes Rails helper for body attrs/favicon. |
| app/controllers/webhooks_controller.rb | Removes Rails controller (webhooks). |
| app/controllers/devices_controller.rb | Removes Rails controller (devices). |
| app/controllers/colors_controller.rb | Removes Rails controller (colors/ryg streaming). |
| app/controllers/application_controller.rb | Removes Rails base controller. |
| app/controllers/api/red_controller.rb | Removes Rails API controller. |
| app/controllers/api/devices_controller.rb | Removes Rails API controller. |
| app/controllers/api/application_controller.rb | Removes Rails API base controller. |
| app/channels/device_channel.rb | Removes Rails ActionCable channel. |
| app/channels/colors_channel.rb | Removes Rails ActionCable channel. |
| app/channels/application_cable/connection.rb | Removes Rails ActionCable connection class. |
| app/channels/application_cable/channel.rb | Removes Rails ActionCable channel base class. |
| app/assets/stylesheets/components/_message.scss | Removes Sass source (messages). |
| app/assets/stylesheets/components/_light.scss | Removes Sass source (light container). |
| app/assets/stylesheets/components/_bulb.scss | Removes Sass source (bulb). |
| app/assets/stylesheets/base/_variables.scss | Removes Sass source (variables). |
| app/assets/stylesheets/base/_reset.scss | Removes Sass source (reset). |
| app/assets/stylesheets/base/_keyframes.scss | Removes Sass source (keyframes). |
| app/assets/stylesheets/base/_elements.scss | Removes Sass source (elements). |
| app/assets/stylesheets/base/_base.scss | Removes Sass source (base imports). |
| app/assets/stylesheets/application.sass.scss | Removes Sass entrypoint. |
| app/assets/config/manifest.js | Removes Rails asset manifest. |
| app/assets/builds/.keep | Keeps legacy builds placeholder. |
| app.json | Removes Heroku app.json. |
| README.md | Updates documentation for Zig development, testing, migrations, deployment, and API/WebSocket protocol. |
| Procfile.dev | Removes Rails dev Procfile. |
| Procfile | Removes Rails Procfile. |
| Gemfile.lock | Removes Ruby dependency lockfile. |
| Gemfile | Removes Ruby dependencies. |
| Dockerfile | Replaces Rails image build with Zig multi-stage build producing a static binary + public assets. |
| .travis.yml | Removes legacy Travis CI config. |
| .standard.yml | Removes Ruby lint config. |
| .ruby-version | Removes Ruby version pin. |
| .rspec | Removes RSpec config. |
| .node-version | Removes Node version pin. |
| .mise.toml | Adds Zig toolchain pin via mise. |
| .gitignore | Updates ignore rules for Zig build artifacts. |
| .github/workflows/ci.yml | Updates CI to use Postgres 16 and build/test via Zig. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const parsed = std.json.parseFromSlice(std.json.Value, allocator, payload_str, .{}) catch | ||
| return error.InvalidJson; | ||
| defer parsed.deinit(); | ||
| const root = parsed.value.object; | ||
|
|
||
| // Ignore pull requests | ||
| if (std.mem.eql(u8, getStringOr(root, "type", ""), "pull_request")) return; |
There was a problem hiding this comment.
parseTravis assumes the parsed JSON root is an object (parsed.value.object). For non-object JSON payloads this is unsafe; validate via switch (parsed.value) before accessing object fields and return a structured error for invalid shapes.
| const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch | ||
| return error.InvalidJson; | ||
| defer parsed.deinit(); | ||
| const root = parsed.value.object; | ||
|
|
||
| // Only handle workflow-completed | ||
| if (!std.mem.eql(u8, getStringOr(root, "type", ""), "workflow-completed")) return; | ||
|
|
There was a problem hiding this comment.
parseCircle assumes the parsed JSON root is an object (parsed.value.object). For non-object JSON payloads this is unsafe; validate the root value tag before accessing .object and treat mismatches as invalid payloads.
| .string => |channel| { | ||
| // Store a copy of the channel name | ||
| const owned = std.heap.page_allocator.dupe(u8, channel) catch return; | ||
| self.subscriptions.put(owned, {}) catch {}; | ||
| }, |
There was a problem hiding this comment.
In clientMessage subscribe handling, owned is allocated and then put errors are ignored. If put fails (e.g., OOM) the allocated owned slice is leaked; repeated subscribes to an existing channel may also allocate unnecessarily. Free owned on failure and avoid allocating when already subscribed.
| // Broadcast device colors to WebSocket clients | ||
| if (device.slug.len > 0) { | ||
| var channel_buf: [256]u8 = undefined; | ||
| const channel = std.fmt.bufPrint(&channel_buf, "device:{s}", .{device.slug}) catch return; | ||
| hub.broadcastColors(channel, colors); | ||
| } |
There was a problem hiding this comment.
updateDeviceStatus only broadcasts device updates on device:{device.slug}. If someone visits /devices/:id using the UUID (which deviceShow accepts), the browser subscribes to device:{uuid} and will never receive live updates. Broadcast on both slug and id channels (or make the client subscribe to the canonical slug).
| Like this? | ||
| <a href="https://github.com/collectiveidea/buildlight">Fork it on Github</a>. | ||
| </p> |
There was a problem hiding this comment.
Link text uses "Github"; standard capitalization is "GitHub".
| const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch | ||
| return error.InvalidJson; | ||
| defer parsed.deinit(); | ||
| const root = parsed.value.object; | ||
|
|
||
| const repo_str = try getString(root, "repository"); |
There was a problem hiding this comment.
parseGithub assumes the parsed JSON root is an object (parsed.value.object). If the payload is valid JSON but not an object (e.g., []), this is unsafe and may misbehave/crash. Switch on parsed.value and return error.InvalidJson/error.InvalidField when it isn’t an object.
| try writer.writeAll("{\"username\":\""); | ||
| try writer.writeAll(s.username); | ||
| try writer.writeAll("\",\"project_name\":\""); | ||
| try writer.writeAll(s.project_name); | ||
| try writer.writeAll("\"}"); |
There was a problem hiding this comment.
The apiRedShow JSON response is built by concatenating s.username and s.project_name directly into string literals without JSON escaping. If an attacker controls these fields, they can break the JSON structure or inject additional keys/values, which can mislead or compromise downstream consumers that trust this data. Use a JSON encoder or at minimum escape quotes, backslashes, and control characters before writing untrusted strings into JSON string values.
| pub fn webhookCreate(handler: *Handler, req: *httpz.Request, res: *httpz.Response) !void { | ||
| const content_type = req.header("content-type") orelse ""; | ||
|
|
||
| // Travis CI sends form-encoded payload | ||
| if (std.mem.startsWith(u8, content_type, "application/x-www-form-urlencoded")) { | ||
| const form_data = try req.formData(); | ||
| var it = form_data.iterator(); | ||
| while (it.next()) |kv| { | ||
| if (std.mem.eql(u8, kv.key, "payload")) { | ||
| parsers.parseTravis(handler.pool, handler.hub, kv.value, handler.host, handler.particle_token, handler.debug, handler.allocator) catch |err| { | ||
| std.log.err("ParseTravis error: {}", .{err}); | ||
| }; | ||
| res.status = 200; | ||
| return; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Read JSON body | ||
| const body = req.body() orelse { | ||
| res.status = 400; | ||
| return; | ||
| }; | ||
|
|
||
| // Check for CircleCI (has Circleci-Event-Type header) | ||
| if (req.header("circleci-event-type") != null) { | ||
| parsers.parseCircle(handler.pool, handler.hub, body, handler.host, handler.particle_token, handler.debug, handler.allocator) catch |err| { | ||
| std.log.err("ParseCircle error: {}", .{err}); | ||
| }; | ||
| res.status = 200; | ||
| return; | ||
| } | ||
|
|
||
| // GitHub Actions sends JSON with "repository" containing "owner/repo" | ||
| const parsed = std.json.parseFromSlice(std.json.Value, res.arena, body, .{}) catch { | ||
| res.status = 400; | ||
| return; | ||
| }; | ||
| defer parsed.deinit(); | ||
|
|
||
| const is_github = switch (parsed.value) { | ||
| .object => |obj| if (obj.get("repository")) |repo| switch (repo) { | ||
| .string => |s| std.mem.indexOf(u8, s, "/") != null, | ||
| else => false, | ||
| } else false, | ||
| else => false, | ||
| }; | ||
|
|
||
| if (!is_github) { | ||
| res.status = 400; | ||
| return; | ||
| } | ||
|
|
||
| parsers.parseGithub(handler.pool, handler.hub, body, handler.host, handler.particle_token, handler.debug, handler.allocator) catch |err| { | ||
| std.log.err("ParseGithub error: {}", .{err}); | ||
| }; | ||
| res.status = 200; | ||
| } |
There was a problem hiding this comment.
The webhook endpoint webhookCreate accepts Travis, CircleCI, and GitHub payloads based only on headers/content type and never verifies a shared secret or HMAC signature. This allows anyone on the internet to POST forged payloads that create or update build statuses and trigger downstream device webhooks/Particle events, spoofing CI results. Add per-service authentication (e.g., GitHub’s X-Hub-Signature-256, secret tokens, or Basic auth) and validate it before dispatching to the parser functions.
| # Install Zig | ||
| RUN apt-get update && apt-get install -y curl xz-utils && \ | ||
| curl -L https://ziglang.org/download/0.15.2/zig-x86_64-linux.tar.xz | tar xJ && \ | ||
| mv zig-* /opt/zig |
There was a problem hiding this comment.
The Dockerfile downloads the Zig toolchain via curl ... | tar from ziglang.org without any checksum or signature verification. If the download endpoint or TLS channel is compromised, an attacker could supply a malicious compiler that injects backdoors into the built buildlight binary. Pin the archive with a strong hash or use a base image or package manager that provides integrity verification for Zig.
| for (statuses) |s| { | ||
| try list.appendSlice(allocator, " <li>"); | ||
| try list.appendSlice(allocator, s.project_name); | ||
| try list.appendSlice(allocator, "</li>\n"); |
There was a problem hiding this comment.
The red-projects template builds HTML by concatenating raw s.project_name into a <li> without any HTML escaping. If an attacker can control a project name (for example via a monitored CI repository), they can inject arbitrary HTML/JavaScript into the response and execute code in users’ browsers. Ensure project_name is properly HTML-escaped or rendered via a safe templating helper before being inserted into the page.
Rust is so 2024. Let's rewrite it all in Zig.
Entirely written by the ghost I have trapped in a jar. Dictated but not read.