Add app versioning, a changelog, and wire both into the footer

The site had no version of its own. Introduce Sigitsi::VERSION in lib/sigitsi/version.rb (semver, bumped per release) and surface it in the footer, which used to read "Get siGit Code & Deploy". Because the Sigitsi module is opened by hand in config/application.rb, outside the autoload paths, Zeitwerk never installs an autoload for constants under it, so the version file is required directly and told to ignore, verified with a clean zeitwerk:check under eager loading. On top of that, a public changelog. /changelog is the index and /changelog/vX.Y.Z is one page per release, both declared before the username matcher and the version route constrained to semver. Entries come from per-release Markdown files in config/changelog with a small YAML frontmatter block, read by the Changelog service: newest first by numeric semver, malformed files skipped rather than fatal, bodies rendered through the existing MarkdownRenderer trust boundary. The footer version links to its own release notes, and the sitemap lists the index and every entry. v1.0.0 is the first entry and marks the MCP server and the platform API as stable.

Seto Elkahfi committed Jul 5, 2026 at 12:38 UTC c744287af66d5253d705ebb4ecb2c5ac1dbc4a5f
13 files changed +379 -2
app/controllers/changelog_controller.rb
+25
new file mode 100644 index 0000000..8bdea0a --- /dev/null +++ b/app/controllers/changelog_controller.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +# Public changelog: an index of releases at /changelog and one page per release +# at /changelog/v1.0.0. Entries come from `Changelog` (Markdown files under +# config/changelog). No auth — the changelog is marketing/reference content. +class ChangelogController < ApplicationController + def index + @entries = Changelog.all + expires_in 1.hour, public: true + end + + def show + @entry = Changelog.find(params[:version]) + return render_not_found if @entry.nil? + + @entries = Changelog.all + expires_in 1.hour, public: true + end + + private + + def render_not_found + render file: Rails.public_path.join("404.html"), status: :not_found, layout: false + end +end
app/controllers/sitemaps_controller.rb
+1
index 9ad9d68..7cad840 100644 --- a/app/controllers/sitemaps_controller.rb +++ b/app/controllers/sitemaps_controller.rb @@ -15,6 +15,7 @@ class SitemapsController < ApplicationController .order(updated_at: :desc) .limit(MAX_REPOSITORIES) @profiles = @repositories.filter_map(&:user).uniq + @changelog_entries = Changelog.all expires_in 6.hours, public: true respond_to(&:xml)
app/services/changelog.rb
+93
new file mode 100644 index 0000000..4e38d7d --- /dev/null +++ b/app/services/changelog.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +# Reads the site changelog from per-version Markdown files in +# `config/changelog/` and exposes them as ordered entries for the /changelog +# pages. Each file is `v<MAJOR>.<MINOR>.<PATCH>.md` with a small YAML +# frontmatter block: +# +# --- +# date: 2026-07-05 +# title: First stable release +# headline: One-line summary shown in the index. +# --- +# Markdown body... +# +# A release adds one file; nothing else needs to change. Entries are sorted +# newest-first by semantic version, so the index and "latest" always reflect +# the highest version present rather than file mtime or date typos. +class Changelog + CHANGELOG_DIR = Rails.root.join("config/changelog") + FILENAME_RE = /\Av(\d+)\.(\d+)\.(\d+)\.md\z/ + + # One changelog release. `version` is the bare "1.0.0"; `tag` is "v1.0.0" + # (the URL slug and display form). `body_html` is sanitized by MarkdownRenderer. + Entry = Struct.new(:version, :date, :title, :headline, :body_html, keyword_init: true) do + def tag = "v#{version}" + + # [major, minor, patch] for sorting; comparison is numeric, not string, so + # 1.10.0 correctly sorts after 1.9.0. + def sort_key = version.split(".").map(&:to_i) + end + + # All entries, newest first. Malformed files are skipped rather than raising, + # so one bad file can never take down the whole changelog page. + def self.all + return [] unless CHANGELOG_DIR.directory? + + CHANGELOG_DIR.children.filter_map { |path| load_entry(path) } + .sort_by { |entry| entry.sort_key } + .reverse + end + + # The entry for a tag like "v1.0.0", or nil if there is none. + def self.find(tag) + version = tag.to_s.delete_prefix("v") + all.find { |entry| entry.version == version } + end + + # The highest-versioned entry, or nil when there are none. + def self.latest = all.first + + def self.load_entry(path) + match = path.basename.to_s.match(FILENAME_RE) + return nil unless match + + raw = path.read + front, body = split_frontmatter(raw) + return nil if front.nil? + + Entry.new( + version: "#{match[1]}.#{match[2]}.#{match[3]}", + date: parse_date(front["date"]), + title: front["title"].to_s.strip, + headline: front["headline"].to_s.strip, + # MarkdownRenderer is the trust boundary: it sanitizes to a tag allow-list, + # so the result is safe to render as HTML (same contract the repository + # README rendering relies on). + body_html: MarkdownRenderer.render(body).html_safe + ) + rescue StandardError => e + Rails.logger.warn("Changelog: skipping #{path.basename} (#{e.class}: #{e.message})") + nil + end + private_class_method :load_entry + + # Split "---\n<yaml>\n---\n<body>" into [Hash, body]. Returns [nil, raw] when + # there is no frontmatter block, which marks the file as malformed. + def self.split_frontmatter(raw) + match = raw.match(/\A---\s*\n(.*?\n)---\s*\n(.*)\z/m) + return [ nil, raw ] unless match + + front = YAML.safe_load(match[1], permitted_classes: [ Date ]) || {} + [ front, match[2] ] + end + private_class_method :split_frontmatter + + def self.parse_date(value) + return value if value.is_a?(Date) + Date.parse(value.to_s) + rescue ArgumentError + nil + end + private_class_method :parse_date +end
app/views/changelog/index.html.erb
+40
new file mode 100644 index 0000000..e6c087a --- /dev/null +++ b/app/views/changelog/index.html.erb @@ -0,0 +1,40 @@ +<% content_for :title, "Changelog" %> +<% content_for :description, "What's new in siGit — Git hosting, model repositories, and the MCP server for AI coding agents, release by release." %> + +<div class="max-w-3xl mx-auto px-4 sm:px-6 py-16 sm:py-20"> + <header class="mb-12"> + <h1 class="text-3xl font-semibold tracking-tight text-gray-100 sm:text-4xl">Changelog</h1> + <p class="mt-3 text-base leading-7 text-gray-400"> + Every siGit release, newest first. + </p> + </header> + + <% if @entries.empty? %> + <p class="text-sm text-gray-500">No releases yet.</p> + <% else %> + <ol class="relative border-l border-surface-600"> + <% @entries.each do |entry| %> + <li class="mb-12 ml-6"> + <span class="absolute -left-1.5 mt-1.5 h-3 w-3 rounded-full border border-surface-500 bg-brand-500"></span> + <div class="flex flex-wrap items-baseline gap-x-3 gap-y-1"> + <%= link_to entry.tag, changelog_version_path(version: entry.tag), + class: "text-xl font-semibold text-brand-500 hover:text-brand-400 transition-colors" %> + <% if entry.date %> + <time datetime="<%= entry.date.iso8601 %>" class="text-xs uppercase tracking-[0.18em] text-gray-500"> + <%= entry.date.strftime("%B %-d, %Y") %> + </time> + <% end %> + </div> + <% if entry.title.present? %> + <h2 class="mt-2 text-lg font-medium text-gray-100"><%= entry.title %></h2> + <% end %> + <% if entry.headline.present? %> + <p class="mt-1 text-sm leading-6 text-gray-400"><%= entry.headline %></p> + <% end %> + <%= link_to "Read the release notes →", changelog_version_path(version: entry.tag), + class: "mt-3 inline-block text-sm text-gray-400 hover:text-gray-200 transition-colors" %> + </li> + <% end %> + </ol> + <% end %> +</div>
app/views/changelog/show.html.erb
+42
new file mode 100644 index 0000000..9c1ba0f --- /dev/null +++ b/app/views/changelog/show.html.erb @@ -0,0 +1,42 @@ +<% content_for :title, "#{@entry.tag}#{" — #{@entry.title}" if @entry.title.present?}" %> +<% content_for :description, @entry.headline.presence || "siGit #{@entry.tag} release notes." %> + +<div class="max-w-3xl mx-auto px-4 sm:px-6 py-16 sm:py-20"> + <nav class="mb-8 text-sm"> + <%= link_to "← All releases", changelog_path, + class: "text-gray-400 hover:text-gray-200 transition-colors" %> + </nav> + + <header class="mb-10 border-b border-surface-600 pb-8"> + <div class="flex flex-wrap items-baseline gap-x-3 gap-y-1"> + <h1 class="text-3xl font-semibold tracking-tight text-gray-100 sm:text-4xl"><%= @entry.tag %></h1> + <% if @entry.date %> + <time datetime="<%= @entry.date.iso8601 %>" class="text-xs uppercase tracking-[0.18em] text-gray-500"> + <%= @entry.date.strftime("%B %-d, %Y") %> + </time> + <% end %> + </div> + <% if @entry.title.present? %> + <p class="mt-3 text-lg text-gray-300"><%= @entry.title %></p> + <% end %> + </header> + + <div class="prose-readme"> + <%= @entry.body_html %> + </div> + + <% if @entries.length > 1 %> + <footer class="mt-16 border-t border-surface-600 pt-8"> + <p class="text-xs uppercase tracking-[0.18em] text-gray-500 mb-4">Other releases</p> + <ul class="flex flex-wrap gap-x-4 gap-y-2 text-sm"> + <% @entries.each do |other| %> + <% next if other.version == @entry.version %> + <li> + <%= link_to other.tag, changelog_version_path(version: other.tag), + class: "text-gray-400 hover:text-brand-400 transition-colors" %> + </li> + <% end %> + </ul> + </footer> + <% end %> +</div>
app/views/layouts/application.html.erb
+2 -1
index bcefab9..3e44f08 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -38,7 +38,8 @@ <div class="max-w-6xl mx-auto px-4 sm:px-6 py-8 flex flex-col gap-6 text-xs text-gray-500 md:flex-row md:items-center md:justify-between"> <span class="whitespace-nowrap text-center md:text-left"> - Get <a href="https://getsigit.5mb.app/" class="text-brand-500 hover:text-brand-400 transition-colors" target="_blank" rel="noopener noreferrer">siGit&nbsp;Code&nbsp;&amp;&nbsp;Deploy</a> + <%= link_to "v#{Sigitsi::VERSION}", changelog_version_path(version: "v#{Sigitsi::VERSION}"), + class: "text-brand-500 hover:text-brand-400 transition-colors" %> </span> <nav class="flex items-center justify-center gap-6"> <%= link_to "About", about_path, class: "text-gray-400 hover:text-gray-200 transition-colors" %>
app/views/sitemaps/index.xml.erb
+9 -1
index 3e45c17..579c0f6 100644 --- a/app/views/sitemaps/index.xml.erb +++ b/app/views/sitemaps/index.xml.erb @@ -1,12 +1,20 @@ <?xml version="1.0" encoding="UTF-8"?> <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> <% [[root_url, "1.0"], [code_url, "0.9"], [models_url, "0.8"], [repos_url, "0.8"], - [about_url, "0.5"], [contact_url, "0.3"], [privacy_url, "0.2"], [terms_url, "0.2"]].each do |loc, priority| %> + [about_url, "0.5"], [changelog_url, "0.5"], [contact_url, "0.3"], + [privacy_url, "0.2"], [terms_url, "0.2"]].each do |loc, priority| %> <url> <loc><%= loc %></loc> <priority><%= priority %></priority> </url> <% end %> + <% @changelog_entries.each do |entry| %> + <url> + <loc><%= changelog_version_url(version: entry.tag) %></loc> + <% if entry.date %><lastmod><%= entry.date.iso8601 %></lastmod><% end %> + <priority>0.4</priority> + </url> + <% end %> <% @repositories.each do |repository| %> <url> <loc><%= repository_url(repository.user.username, repository.name) %></loc>
config/application.rb
+11
index c888276..b03985f 100644 --- a/config/application.rb +++ b/config/application.rb @@ -1,5 +1,11 @@ require_relative "boot" +# The app version defines `Sigitsi::VERSION`. Load it here rather than through +# autoloading: `Sigitsi` is opened manually below (outside the autoload paths), +# so Zeitwerk never installs an autoload for constants nested under it. The +# matching Zeitwerk `ignore` for this file lives in the Application config. +require_relative "../lib/sigitsi/version" + require "rails" # Pick the frameworks you want: require "active_model/railtie" @@ -28,6 +34,11 @@ module Sigitsi # Common ones are `templates`, `generators`, or `middleware`, for example. config.autoload_lib(ignore: %w[assets tasks]) + # `lib/sigitsi/version.rb` is required by hand above (see the note there), + # so keep Zeitwerk from also managing it — otherwise eager loading in + # production would try to define an already-defined constant. + Rails.autoloaders.main.ignore(Rails.root.join("lib/sigitsi/version.rb")) + # Configuration for the application, engines, and railties goes here. # # These settings can be overridden in specific environments using the files
config/changelog/v1.0.0.md
+43
new file mode 100644 index 0000000..8ea389a --- /dev/null +++ b/config/changelog/v1.0.0.md @@ -0,0 +1,43 @@ +--- +date: 2026-07-05 +title: First stable release +headline: siGit reaches 1.0 — Git hosting, model repositories, and an MCP server that lets your agent work the repo from the terminal. +--- + +siGit is now at version 1.0. Git hosting and open-weights model repositories +have run in production for a while; this release marks the point where the +platform's API and the tools built on it are stable enough to depend on. + +## Model Context Protocol server + +The headline of this release is the built-in MCP server at `/api/v1/mcp`. Point +an MCP client (siGit Code, Claude Code, or any conforming client) at it with a +bearer token and the agent can work your repositories without leaving the +terminal: + +- **Browse and read**: `list_repositories`, `get_file_contents`, and + `search_code` let the agent find a repo and read across it. +- **Issues and pull requests**: `list_issues`, `get_issue`, `create_issue`, + `list_pull_requests`, `get_pull_request` (with the full three-dot diff), and + `create_pull_request` bring the review workflow into the agent's reach. +- **Comments**: `add_issue_comment` posts to an issue or a pull request, which + share one per-repository number space. +- **Web search**: `web_search` gives the agent a way to find pages, not just + fetch known URLs. Search runs through the platform as a signed-in feature + rather than a key pasted into every client. + +Every tool authorizes against the same repository visibility rules as the rest +of the site, so an agent can never reach a private repository its user cannot. + +## Accounts and access + +- Brokered Google sign-in on the web and the code.sigit.si SPA. +- An admin console for operating the platform. +- OAuth connection status surfaced on the settings page. + +## Under the hood + +- Repository diffs are read through a hardened git layer that rejects any ref + starting with a dash, closing off git argument injection. +- The MCP server is a stateless Streamable-HTTP JSON-RPC endpoint, so it scales + with the rest of the app and needs no separate service.
config/routes.rb
+8
index 7902dfd..e8e88d7 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -9,6 +9,14 @@ Rails.application.routes.draw do get "/privacy", to: "pages#privacy", as: :privacy get "/terms", to: "pages#terms", as: :terms + # Public changelog: an index and one page per release. The version constraint + # (v<major>.<minor>.<patch>) keeps "/changelog/anything-else" from matching, + # and both are declared before the "/:username" matcher so the literal + # "changelog" slug isn't read as a profile. + get "/changelog", to: "changelog#index", as: :changelog + get "/changelog/:version", to: "changelog#show", as: :changelog_version, + constraints: { version: /v\d+\.\d+\.\d+/ } + # XML sitemap for search engines (referenced from public/robots.txt). get "/sitemap.xml", to: "sitemaps#index", defaults: { format: "xml" }, as: :sitemap
lib/sigitsi/version.rb
+9
new file mode 100644 index 0000000..ecbe1ec --- /dev/null +++ b/lib/sigitsi/version.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Sigitsi + # The application's semantic version (https://semver.org). Bump on release: + # MAJOR for incompatible changes, MINOR for backwards-compatible features, + # PATCH for backwards-compatible fixes. Surfaced in the site footer and + # available anywhere as `Sigitsi::VERSION`. + VERSION = "1.0.0" +end
spec/requests/changelog_spec.rb
+72
new file mode 100644 index 0000000..367ddb2 --- /dev/null +++ b/spec/requests/changelog_spec.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require "rails_helper" + +# The public changelog: an index at /changelog and one page per release at +# /changelog/vX.Y.Z, sourced from config/changelog/*.md via Changelog. +RSpec.describe "Changelog", type: :request do + describe "GET /changelog" do + before { get "/changelog" } + + it "renders the index" do + expect(response).to have_http_status(:ok) + end + + it "lists the shipped releases with links to their pages" do + Changelog.all.each do |entry| + expect(response.body).to include(entry.tag) + expect(response.body).to include("/changelog/#{entry.tag}") + end + end + end + + describe "GET /changelog/:version" do + let(:entry) { Changelog.latest } + + it "renders the release page with its rendered body" do + get "/changelog/#{entry.tag}" + + expect(response).to have_http_status(:ok) + expect(response.body).to include(entry.tag) + expect(response.body).to include(entry.title) if entry.title.present? + end + + it "renders the Markdown body as HTML, not escaped source" do + get "/changelog/#{entry.tag}" + + # The v1.0.0 entry has an H2; it must appear as a real heading tag, and + # never as escaped angle brackets. + expect(response.body).to include("<h2") + expect(response.body).not_to include("&lt;h2") + end + + it "404s an unknown but well-formed version" do + get "/changelog/v9.9.9" + expect(response).to have_http_status(:not_found) + end + + it "does not treat a malformed version as a changelog page" do + # The route constraint rejects non-semver slugs, so "/changelog/latest" + # falls through to the profile matcher rather than the changelog show. + get "/changelog/latest" + expect(response).not_to have_http_status(:ok) + end + end + + describe "the footer" do + it "links the version to its changelog page on every layout render" do + get "/" + expect(response.body).to include("/changelog/v#{Sigitsi::VERSION}") + end + end + + describe "the sitemap" do + it "includes the changelog index and each release" do + get "/sitemap.xml" + expect(response.body).to include("/changelog") + Changelog.all.each do |entry| + expect(response.body).to include("/changelog/#{entry.tag}") + end + end + end +end
spec/requests/footer_version_spec.rb
+24
new file mode 100644 index 0000000..cda43f3 --- /dev/null +++ b/spec/requests/footer_version_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require "rails_helper" + +# The application layout footer shows the current release version, sourced from +# the single `Sigitsi::VERSION` constant so a release bump updates the site. +RSpec.describe "Footer version", type: :request do + it "renders the semantic version from Sigitsi::VERSION" do + get "/" + + expect(response).to have_http_status(:ok) + expect(response.body).to include("v#{Sigitsi::VERSION}") + end + + it "links the version to its changelog page" do + get "/" + + expect(response.body).to include("/changelog/v#{Sigitsi::VERSION}") + end + + it "exposes a valid semantic version string" do + expect(Sigitsi::VERSION).to match(/\A\d+\.\d+\.\d+\z/) + end +end