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