Shiplog
A mountable Rails engine that turns your GitHub issues, pull requests and commits into a public roadmap and changelog written for customers, not developers. An LLM that you supply drafts the plain-language text. Nothing is public until an admin approves it.
-
Roadmap: issues labelled
publicappear in three columns (In progress, Coming soon, Later) once approved. - Changelog: shipped features and small changes, grouped by month, each with its own permalink page, plus an Atom feed.
- Admin: drafts inbox, entry editing, a drag-and-drop roadmap board and a sync status page.
Requirements
- Ruby 4.0 or newer
- Rails 8.1.4 or newer (< 9)
- PostgreSQL (tables use
jsonb) - A job backend for Active Job (GoodJob, Solid Queue, ...)
- Active Storage, only for screenshots in entries (the rest works without it)
Installation
# Gemfile
gem "shiplog"bundle install
bin/rails generate shiplog:install
bin/rails db:migrateThe generator:
- creates
config/initializers/shiplog.rbwith every setting, commented; - adds the public mount (and a commented admin mount) to
config/routes.rb; - runs
shiplog:install:migrations(all tables are prefixedshiplog_); - copies the issue form to
.github/ISSUE_TEMPLATE/shiplog-feature.yml. It has Problem / Before / After fields and applies thepubliclabel.
With --tailwind the initializer also sets config.stylesheet = false and a config.classes hash of Tailwind classes, and the generator adds the @source line to app/assets/tailwind/application.css. See Styling.
Mounting
Shiplog ships two engines so that the admin can sit behind your own authentication while the public pages and the GitHub webhook stay open (the same approach as GoodJob):
# config/routes.rb
mount Shiplog::Engine => "/" # /roadmap, /changelog, /shiplog/github/webhook, engine assets
authenticate :user, ->(user) { user.admin? } do # Devise shown; use whatever fits your app
mount Shiplog::Admin::Engine => "/admin/shiplog"
endThe generator writes the admin mount as a comment because authentication differs between apps. Uncomment it once it is wrapped in a constraint.
config.authorize_admin is checked by the admin controllers and a falsy result returns 403. It is fail-closed: the default denies everyone, so a mount that is not configured is never open. The generated initializer sets config.authorize_admin = ->(controller) { true } because the route constraint around the mount does the authentication. If you do not wrap the mount in a constraint, replace it with a real check (for example controller.current_user&.admin?).
The public paths come from config.roadmap_path and config.changelog_path, read when routes are drawn.
Customising the admin layout
By default the admin uses its own layout (layouts/shiplog/admin) and its own CSS and JS, served by the engine (and aware of your CSP nonce). To change it, create the same file in your app, which takes precedence over the engine's:
<%# app/views/layouts/shiplog/admin.html.erb %>To render the admin inside your own admin layout instead, see Rendering inside your layouts.
For smaller tweaks override a partial instead:
-
app/views/shiplog/admin/_custom_head.html.erb: rendered inside<head>of the engine layout (extra stylesheets, favicon, ...). -
app/views/shiplog/admin/_nav.html.erb: the navigation bar (a compact sub-nav when a host layout is used). -
app/views/shiplog/admin/_assets.html.erb: linksadmin.css; every admin page renders it, so it works in any layout.
Engine views use the engine's route helpers, so inside a host layout or partial rendered by a Shiplog controller your own routes are available as main_app. helpers, or directly (see host route helpers).
Public pages
The public controllers inherit from config.public_parent_controller and render with config.public_layout.
Configuration
All settings live in config/initializers/shiplog.rb:
| Key | Default | Description |
|---|---|---|
repositories |
[] |
GitHub repositories to sync, as "owner/name" or a Hash with options. Sub-issues may cross repositories of the same owner. See Repositories. |
lockfile_path |
"Gemfile.lock" |
Lockfile read in merged pull requests to detect gem upgrades. Change it for a monorepo. |
github_token |
nil |
A String, or a callable evaluated on every GitHub request. |
webhook_secret |
nil |
Secret of the GitHub webhook. While blank, the webhook endpoint answers 503. |
public_label |
"public" |
Only issues with this label reach the roadmap. |
soon_labels |
["soon"] |
Labels that put an issue in "Coming soon". A milestone does the same. |
excluded_labels |
["internal", "dependencies", "refactor", "chore"] |
Pull requests with one of these labels never become changelog entries. |
excluded_authors |
[/\[bot\]\z/] |
Patterns (matched with ===) for authors whose pull requests and commits are skipped. |
commit_types |
["feat", "fix", "perf"] |
Conventional-commit types eligible for the changelog. Commits need one of these types; pull requests whose title has a different type (ci:, chore(deps):, ...) are skipped, untyped titles are kept. |
rewriter |
nil |
Callable that rewrites text with an LLM. See The rewriter. |
product_name |
nil |
Your product's name, used in the prompts. |
audience |
nil |
Who your customers are, used in the prompts. |
tone |
"Friendly, concise, no jargon. Address the customer as 'you'." |
Writing style used in the prompts. |
prompt_builder |
nil |
Optional ->(kind:, context:) { String } replacing the built-in prompts. kind is "roadmap", "feature" or "change". The built-in prompts ask for the product area; a custom builder must ask for "area" in the JSON reply itself (an existing name, or a new one of 1-3 words), and can list the current areas with Shiplog::Area.ordered.pluck(:name). Without "area" in the reply, rewrites leave areas alone. |
public_parent_controller |
"ActionController::Base" |
Parent class of the public controllers. Read once when they load, so set it in the initializer; assigning it after the controllers loaded raises ArgumentError. |
public_layout |
"shiplog/application" |
Layout of the public pages. |
admin_parent_controller |
"ActionController::Base" |
Parent class of the admin controllers. Read once when they load, so set it in the initializer; assigning it after the controllers loaded raises ArgumentError. |
admin_layout |
"shiplog/admin" |
Layout of the admin pages. |
admin_chrome |
:standalone |
:standalone wraps the admin in Shiplog's own page chrome (nav bar, flash, background). :hosted is for an admin_layout of your own: Shiplog renders only a compact sub-nav inside your content. Anything else raises ArgumentError. The layout name is never inspected. |
admin_flash |
true |
In :hosted mode, render Shiplog's flash messages inside the admin content. Set to false if your layout already shows the flash. Standalone always shows it. |
stylesheet |
true |
Include the bundled shiplog.css in the public pages. Set to false when you style them yourself. |
classes |
{} |
Extra classes per element, added to the stable shiplog-* classes. See Styling. |
authorize_admin |
->(controller) { false } |
Admin check; return false for a 403. Denies everyone until set; the generated initializer opens it, relying on your route constraint. |
current_admin |
->(controller) { controller.try(:current_user)&.try(:email) } |
Stored as the approver of an entry. |
roadmap_path |
"roadmap" |
Path of the public roadmap. |
changelog_path |
"changelog" |
Path of the public changelog: segments of letters, digits, - and _, nested with / if you like ("docs/whats_new"). Surrounding slashes are stripped ("/changelog" is "changelog"); a blank value, spaces, dots, ? or # raise ArgumentError. Permalinks live below it and the feed is <path>.atom. |
canonical_url |
nil |
The site's canonical origin, e.g. "https://trackberry.com". See Canonical URL. |
extra_pages |
[] |
Pages added to the switcher after Roadmap and Changelog: [{ label:, path: }], each a String or a callable evaluated in the view. See Product updates navigation. Anything else raises ArgumentError. |
repository_labels |
{} |
Tag shown on a roadmap card for the repository of its issue: { "owner/name" => "iPhone & iPad" }. Values are Strings or callables evaluated in the view; repositories without one show no tag. |
roadmap_shipped_count |
4 |
How many recently shipped changelog entries (features and changes) the roadmap lists below its columns. 0 hides the section. |
cache_version |
nil |
A String added to the public pages' ETag. Callables (extra_pages, repository_labels, feedback_path, og_image) cannot be fingerprinted, so bump it when the output of a callable you configured changes. Also bump it when you change your layout's head or override a shiplog/head/* partial: ETags don't cover layouts. |
feedback_path |
nil |
Target of the roadmap's "Suggest a feature" link, a String or a callable evaluated in the view. No link while nil. |
changelog_search |
true |
Search box on the public changelog and ?q= filtering. false hides the form and ignores q. See Changelog search. |
structured_data |
true |
schema.org JSON-LD on the public pages (see Head tags). false emits none. See Structured data. Part of the ETag. |
og_image |
nil |
The image shared with a permalink page that has no screenshot: nil or a callable ->(entry, locale) { url } evaluated in the view (instance_exec, so main_app route helpers work). See Head tags. Anything else raises ArgumentError. Only its presence is part of the ETag, not its output: bump cache_version when its output changes. |
on_changelog_seen |
nil |
->(controller) { ... } called before each changelog index or permalink view renders (also for a 304 revisit), to stamp a visitor's "last seen" time. See What's new. Anything but nil or a callable raises ArgumentError. |
images |
true |
Screenshots in entries. Effective only when Active Storage is loaded in the host; false turns fetching, upload and rendering off. true or false, anything else raises ArgumentError. |
max_images_per_entry |
4 |
How many images are fetched from the GitHub body of a draft. A non-negative Integer; 0 fetches none (admin uploads are not capped). |
locales |
[] |
Locales entries are translated into when approved, e.g. [:es]. An Array of Symbols or Strings (stored as symbols; the default locale I18n.default_locale is dropped); anything else raises ArgumentError. See Translations. Part of the ETag. |
announcements |
:page |
Where monthly announcements are published: :page, :external or false. Anything else raises ArgumentError. Part of the ETag. |
announcement_url |
nil |
With announcements = :external, ->(announcement, locale) { url } evaluated in the view: where the month heading's summary link goes. |
on_announcement_approved |
nil |
->(announcement) { ... }, called once an announcement is approved and translated. See Monthly announcements. |
newsletter |
false |
Emails the approved announcement to your opted-in users and to the visitors who subscribed. true or false, anything else raises ArgumentError. Needs announcements. Part of the ETag. See Newsletter. |
newsletter_recipients |
nil |
-> { User.where(newsletter: true) }: your users who opted in, each responding to email, locale and id (a relation is read in batches). Never stored. |
newsletter_unsubscribe_url |
nil |
->(recipient) { url }: your own settings page, linked as "Preferences" in your users' emails. Must be an absolute http(s) URL; a failure is logged and the footer links Shiplog's page. |
on_newsletter_unsubscribe |
nil |
->(email) { ... }, called with the lowercased address on every unsubscribe (one-click, link or preferences page) so your list follows; Shiplog suppresses the address itself either way. Errors are logged. Warned about at boot when missing with newsletter_recipients. |
newsletter_from |
nil |
The sender, e.g. "Acme <updates@acme.com>"; also used by the confirmation email. Falls back to ActionMailer::Base.default[:from]. Required to send. |
newsletter_address |
nil |
The postal address in the footer, required to send (CAN-SPAM). |
newsletter_logo_url |
nil |
The logo in the email, an absolute http(s) URL shown 140px wide. Anything else raises ArgumentError. |
newsletter_accent |
nil |
The colour of buttons and links in the email, "#rrggbb" (default #0969da). Anything else raises ArgumentError. |
newsletter_mailer_options |
{} |
A Hash merged into every newsletter message's mail(...): { message_stream: "broadcast" } for a Postmark broadcast stream, delivery_method_options, ... Keys that would change the recipients, the sender or the content (to, cc, bcc, from, reply_to, subject, body, template_name, ...) raise ArgumentError. Not applied to the confirmation email. |
newsletter_batch_size |
500 |
How many recipients a send reads at a time (1 to 10,000); rows are claimed and emailed 100 at a time within. |
max_areas |
8 |
How many product areas the AI may create. Past it, entries can only join existing areas. A non-negative Integer; 0 stops the AI creating areas. |
Example:
Shiplog.configure do |config|
config.repositories = %w[acme/app acme/app-ios]
config.github_token = -> { Rails.application.credentials.dig(:github, :shiplog_token) }
config.webhook_secret = Rails.application.credentials.dig(:github, :shiplog_webhook_secret)
config.product_name = "Acme"
config.audience = "small business owners who invoice customers"
config.rewriter = ->(prompt) { RubyLLM.chat.ask(prompt).content }
endRepositories
Each entry of config.repositories is a String ("owner/name") or a Hash:
config.repositories = [
"acme/app",
"acme/app-ios",
{ name: "acme/market_data", changelog: false, gem: "market_data" },
{ name: "acme/produce", changelog: false, gem: "acme-produce" }
]| Option | Default | Description |
|---|---|---|
name |
required |
"owner/name". |
changelog |
true |
false makes the repository roadmap-only: its issues still feed the roadmap, but its pull requests and commits never become changelog entries. |
gem |
nil |
The gem's name in your Gemfile.lock, for gem upgrades. |
Unknown options and invalid values raise ArgumentError when you assign repositories, and so does a name that is not owner/name (letters, digits, ., _ and -; a segment cannot be only dots). A repository with both gem: and changelog: true is accepted, but a warning is logged: its changes would be drafted twice, from its own pull requests and from the upgrade. Set changelog: false.
gem: must be set explicitly because gem names and repository names differ (acme-produce lives in acme/produce), and a public gem can share a name with one of your repositories without being it: the holidays gem from RubyGems is not acme/holidays. Matching on names would attach someone else's changes to your changelog, so only gems listed with gem: are tracked.
config.lockfile_path (default "Gemfile.lock") is the lockfile Shiplog reads in the repositories where changelog is true.
Gem upgrades
A change made in a gem you depend on should appear in the changelog once, when your customers get it: the day your app upgraded the gem. Shiplog does this with the repositories you listed with gem:.
For each merged pull request in a repository with changelog: true that changes the lockfile:
- Shiplog reads the lockfile at the merge commit's first parent and at the merge commit itself, and compares the tracked gems with
Bundler::LockfileParser. - For a gem whose version changed it resolves the tag in the gem's repository (
v<version>and<version>, trying the matching styles first and then the mixed ones). For a git source it uses the locked revision. Shiplog then compares the two refs. - It lists the pull requests merged between the two refs (GitHub's compare API, then the pull requests associated with those commits, the newest 50) and stores them with the upgrade.
- The pull request gets one change draft, dated at its merge. The gem's changes (up to 20 titles with excerpts) go into the rewrite prompt, and the rewriter writes one customer-facing entry, or marks it not customer-facing.
A Dependabot bump, a chore(deps): title or the dependencies label does not exclude a pull request that carries an upgrade with changes; without one it is skipped as usual. Other excluded labels (such as internal) still exclude it. If a tag cannot be found, or the gem's repository is not readable by the token, the upgrade is recorded without changes and a warning is logged; the rewriter then works from the pull request alone.
The GitHub token needs read access to the gem repositories as well (Contents and Pull requests). Detection is capped per sync (50 pull requests), so a large repository's first sync finishes over several syncs.
Only pull requests synced by 0.2.0 or later are inspected: merges imported earlier are not backfilled, and their gem changes are not turned into entries. Pull requests merged before the next scan's start (the last scan, or seven days ago for a repository never scanned) are marked checked without being fetched, since nothing that old would be drafted. A pull request whose inspection fails with a temporary GitHub error is retried on the next syncs and given up after three attempts; any other error gives it up at once, with a warning.
Rebase merges. Shiplog reads the lockfile at the merge commit and at its first parent. For a rebase merge, GitHub creates no merge commit: the pull request's "merge commit" is the last rebased commit, so only that commit's lockfile change is seen. Dependabot's pull requests are a single commit, so they are unaffected; a rebased branch that bumps a gem in an earlier commit is missed.
GitHub token
Shiplog talks to the GitHub GraphQL API. Provide either:
- a fine-grained personal access token with access to the configured repositories and read-only permissions for Issues, Pull requests, Contents and Metadata; or
- a GitHub App installation token with the same permissions. Because
github_tokenaccepts a callable, you can mint a fresh token on each call:
config.github_token = -> { MyApp::GithubApp.installation_token }Webhook setup
Webhooks keep Shiplog fresh between scheduled syncs. In each repository (or at organisation level) add a webhook:
-
Payload URL:
https://your-app.example.com/shiplog/github/webhook(prefix it if you mountedShiplog::Enginebelow/) -
Content type:
application/json -
Secret: the value of
config.webhook_secret - Events: Issues, Pull requests and Sub-issues
Requests are verified with X-Hub-Signature-256; an invalid signature returns 401. Each X-GitHub-Delivery id is recorded, so redeliveries are harmless. Valid events enqueue a Shiplog::RefreshJob that refetches the current state of the issue or pull request and respond 202. ping answers 200 and other events are ignored. Recent deliveries are listed on the admin Sync page.
Scheduling
Webhooks can be missed, so schedule these jobs:
-
Shiplog::SyncJob: reconciles issues and pull requests and creates missing drafts. Every 30 minutes is a good start. The first run backfills everything. -
Shiplog::ScanChangesJob: looks at default-branch commits without a pull request, then drafts small changes. Run it weekly. -
Shiplog::DraftAnnouncementJob: drafts the previous month's announcement. Run it early each month, after the month's last weekly scan if you can. -
Shiplog::PurgeNewsletterJob(withconfig.newsletter): deletes the newsletter subscribers who never confirmed within 30 days and the delivery rows older than 180 days. Run it weekly.
GoodJob (config/initializers/good_job.rb or config/application.rb):
config.good_job.enable_cron = true
config.good_job.cron = {
shiplog_sync: { cron: "*/30 * * * *", class: "Shiplog::SyncJob" },
shiplog_scan_changes: { cron: "0 6 * * 1", class: "Shiplog::ScanChangesJob" },
shiplog_announcement: { cron: "0 7 2 * *", class: "Shiplog::DraftAnnouncementJob" },
shiplog_purge_newsletter: { cron: "0 5 * * 1", class: "Shiplog::PurgeNewsletterJob" }
}Solid Queue (config/recurring.yml):
production:
shiplog_sync:
class: Shiplog::SyncJob
schedule: every 30 minutes
shiplog_scan_changes:
class: Shiplog::ScanChangesJob
schedule: every monday at 6am
shiplog_announcement:
class: Shiplog::DraftAnnouncementJob
schedule: at 7am on the 2nd day of every month
shiplog_purge_newsletter:
class: Shiplog::PurgeNewsletterJob
schedule: every monday at 5amYou can also trigger a sync from the admin Sync page. Rewriting always runs in Shiplog::RewriteEntryJob, never in a request.
The rewriter
config.rewriter is a callable that receives the prompt (a String) and returns a String containing a JSON object:
{"title": "...", "body": "...", "customer_facing": true, "area": "Billing"}area is optional (see Product areas); a reply without it works as before.
Shiplog extracts the first valid JSON object from the reply, so models that wrap it in prose or code fences still work. customer_facing: false means the model thinks the change is irrelevant for customers; such small changes are stored as discarded and can be restored by an admin. If no rewriter is configured, the reply can't be parsed or the call raises, the entry keeps the original GitHub text and the error is shown in the admin, where you can edit the text or regenerate.
The same callable also receives the translation, area-name translation and area-detection prompts, whose replies are other JSON shapes ({"title", "body"} for a translation, {"areas": [...]} or {"assignments": [...]} for areas). It must handle free-form prompts: pass the prompt to your model as is, and return its text reply, without assuming the rewrite schema above.
With RubyLLM:
config.rewriter = ->(prompt) { RubyLLM.chat.ask(prompt).content }With the Anthropic SDK:
client = Anthropic::Client.new
config.rewriter = lambda do |prompt|
message = client.messages.create(model: "claude-sonnet-5-5", max_tokens: 1024, messages: [ { role: "user", content: prompt } ])
message.content.first.text
endHow items reach the roadmap
- An issue is public when it has
config.public_label. - A public, open issue whose parent is not itself public is a roadmap item. Public children of a public parent are folded into the parent.
- A roadmap item is drafted, rewritten and visible on
/roadmaponly after an admin approves it. It leaves the roadmap when it is closed. - The column is the first match of:
- the column an admin pinned it to;
- In progress: the issue or one of its sub-issues has an open linked pull request, or the issue has assignees;
-
Coming soon: it has a milestone or a label from
soon_labels; - Later.
- Within a column cards are ordered by their board position, then by last GitHub update. Dragging a card on the admin board pins it to the target column and stores the order. "Unpin" returns it to the automatic column. Without JavaScript, each card has a column select and a Move button.
How changelog entries are produced
-
Features: a public top-level issue closed as completed that has at least one merged linked pull request on itself or a sub-issue. The issue form sections (
### Problem,### Before,### After), sub-issue titles and linked pull request titles are given to the rewriter. Only issues closed in the last 90 days are drafted, so a first sync doesn't flood the inbox. The release date is the newest merge date. -
Small changes: the weekly scan looks at pull requests merged, and stored commits made, since the last scan (seven days on the first run). Commits are only considered when they are on the default branch and have no pull request, and their conventional-commit type (
feat:,fix(scope):, ...) must be incommit_types. Pull requests are skipped when their title starts with a Conventional Commits type (chore:,ci:,docs:, ...) outsidecommit_types; a topic prefix such asImporters:is not a type; untyped titles are left for the rewriter to judge. Bot authors are recorded asname[bot], so the defaultexcluded_authorspattern matches them. - Exclusions: pull requests with an excluded label or an excluded author, commits from excluded authors, pull requests that belong to a feature's issue tree (the feature entry covers them), and anything that already has an entry.
Approval workflow
Every entry starts as a draft. In the admin inbox you can edit the text, regenerate it, approve it or discard it. Only approved entries are public. Discarded entries are never proposed again but can be restored. If the GitHub text changes after a draft was written, the entry is flagged "GitHub text changed since this draft" and can be regenerated, and approved text is never replaced silently.
Approved changelog entries do not react to later GitHub changes (a reverted pull request, a removed label): discard them by hand in the admin. A roadmap issue that is re-labelled or reopened shows its previously approved text again, and the inbox flags it as "GitHub text changed". Issues deleted or transferred on GitHub are closed locally (state reason deleted), which removes them from the roadmap; their entries are kept.
Drafts whose rewrite job was lost (no result and no error after an hour) are queued again by the next sync, and the admin marks them "Not rewritten yet" until then.
Product updates navigation
The default page header shows a switcher under the lede: Roadmap, Changelog, then config.extra_pages. The current page carries aria-current="page": of the pages whose path equals or contains the request path, the one with the longest path is current. Add your own pages, for example a news section:
config.extra_pages = [
{ label: "News", path: "/learn/news" },
# Callables are evaluated in the view, so route helpers and I18n work:
{ label: -> { t("pages.docs") }, path: -> { main_app.docs_path } }
]To show the same switcher on your own pages, add the helper to the controller and call it in the view. It links the stylesheet (unless config.stylesheet is false) and finds the engine's route proxy in your routes whatever name you gave the mount (as: :updates), so it works in main-app views. It raises an error if Shiplog::Engine is not mounted:
class NewsController < ApplicationController
helper Shiplog::SwitcherHelper
end<%= shiplog_switcher %>A path String must start with / (not //, /\ or / followed by whitespace), http://, https:// or mailto:, and contain no control characters; anything else, such as "news", raises ArgumentError when you assign extra_pages. An absolute http(s) page is marked current only on its own host (request.host). A callable that returns another scheme renders the label as text, without a link, and logs a warning. feedback_path follows the same rule at render time: an unsafe value shows no link.
Labels come from shiplog.switcher.roadmap, .changelog and .label (the nav label); every Shiplog key has an English default, so a locale without translations shows English. Style it with the switcher and switcher_link keys of config.classes; the current link also has shiplog-switcher__link--current. Hosts that override shiplog/_page_header keep their own header; render shiplog_switcher or the shiplog/switcher partial in it to get the links.
Roadmap details
-
Status: each column has a rule on top and a marker (
shiplog-column__marker--in-progress/--coming-soon/--later). -
Card meta: the label of the issue's repository (
config.repository_labels) and "Includes N parts" when the issue has more than one sub-issue (shiplog.roadmap.parts). A card with neither has no meta element. -
Recently shipped: the last
config.roadmap_shipped_countapproved feature and change entries (default 4), each linking to its month on the changelog, plus a "See the full changelog" link. The roadmap's ETag follows changelog approvals. -
Footnote: set the
shiplog.roadmap.footnotetranslation (empty by default, nothing renders while it is empty) andconfig.feedback_pathfor a "Suggest a feature" link (shiplog.roadmap.suggest).
Changelog search
The public changelog has a search form above its months: GET /changelog?q=csv lists the approved features and changes whose title or body contain every word of the query (case-insensitive ILIKE, so PostgreSQL; %, _ and \ are matched literally). Only the first 5 words of the first 100 characters count. Results keep the changelog's order and month grouping and are paginated; the pagination links keep q. With a query the page shows "N results for “csv”" and a "Clear search" link, or a "no entries match" message. A blank q is the same as no q. The form is a plain GET form (role="search", a type="search" input named q with an aria-label), so it needs no JavaScript.
- Turn it off with
config.changelog_search = false: no form, andqis ignored. - Override
shiplog/changelogs/_search.html.erb(localsquery:andcount:, bothnilwithout a query) to change the markup. Style it with thesearch*keys ofconfig.classes. - Texts:
shiplog.changelog.search.label,.submit,.summary(one/other, withcountandquery),.clearand.empty(withquery); all have English defaults. - The ETag includes the normalised query.
- The model scope is
Shiplog::Entry.search(query).
Keeping search pages out of search engines. A page with a query is noindex: shiplog_head_tags renders <meta name="robots" content="noindex"> (see Head tags). Hosts still on the older blocks read content_for :shiplog_robots.
Product areas
The rewriter groups entries into product areas ("Billing", "Reports"), and the changelog lets visitors filter by area.
-
Detection while rewriting. The prompt lists the existing areas (as data, before the
<source>block) and asks the model for"area"in its JSON reply: an existing name, or a new name of 1-3 words when none fits. Shiplog matches the name against the existing areas ignoring case, then by slug, and creates a new area only while fewer thanconfig.max_areas(default 8) exist and the name is valid: at most 40 characters, letters, digits, spaces,&and-. Any other name leaves the entry's current area as it is (a rewrite never erases an area). A reply without"area"(or with one that is not a String) changes nothing. Roadmap entries get areas too, but only features and changes show them publicly. A customconfig.prompt_builderbuilds its own prompt, so it has to ask for"area"itself and can read the existing areas withShiplog::Area.ordered.pluck(:name). -
Detect areas. The admin has an Areas page with a "Detect areas" button, which enqueues
Shiplog::DetectAreasJob. With no areas yet, the job asks yourconfig.rewriter(same callable, a dedicated prompt, reply{"areas": [...]}) to propose up tomax_areasareas from the titles of up to 200 approved features and changes. Then it classifies the draft and approved entries that have no area, 50 titles per prompt (reply{"assignments": [{"id": 1, "area": "Billing"}]}), using only existing areas; unknown ids and areas are ignored. It is idempotent (it only touches entries without an area), bounded (at most 20 classification prompts, 1000 entries, per run; click again for more) and logs what it did ([shiplog] Detect areas: ...). Each entry it sent is stamped (area_detected_at), placed or not, so entries the AI declined are not asked again and the next run moves on to the rest; tick "Re-check unplaced entries" next to the button (for example after renaming, merging or adding areas) to ask about them again. Only one detection runs at a time (a database advisory lock); a second one logs and returns. The button needsconfig.rewriter(without it nothing is enqueued and a flash says so). The job never retries: a failure is logged (class, message, first 5 backtrace lines) and the Areas page shows "Last detection: , assigned N" or the error from the last run (stored in theshiplog_settingstable). After clicking, refresh to see the result. The titles are sent, never the bodies. -
Admin. The Areas page lists areas with their entry counts. You can rename (the slug follows, translated names are dropped and translated again), move up/down, merge into another area (moves the entries, deletes the source) and delete (the entries have no area). The entry edit page has an Area select with "None"; changing it marks the entry as set by an admin (
area_set_by_admin), and neither rewrites nor detection ever change such an entry's area. The inbox shows the area as a badge. -
Public. With at least 2 areas that have published features or changes, the changelog shows an "All" link and one link per area above the list (
?area=slug, theareas/area_linkkeys,--currenton the selected one). It combines with search and pagination (the links keep both) and is part of the ETag, with the names, slugs and order of all areas. An unknown?area=slug answers 404; a blank one is ignored. Every entry shows its area as a tag (entry_area), in the list and on the permalink page. The Atom feed adds<category term="slug" label="Name"/>to entries with an area and honours?area=(404 for an unknown one). Roadmap cards don't change. -
Translated names. With
config.localesset, area names are translated too; see Translations. Public pages showarea.display_name. -
Texts:
shiplog.changelog.areas.labeland.all, andshiplog.admin.areas.*,shiplog.admin.nav.areas,shiplog.admin.entries.no_areaand.fields.area; all have English defaults. -
Schema: the
shiplog_areastable (name,slugunique,position) andshiplog_entries.area_id(nullable foreign key,ON DELETE SET NULL) andarea_set_by_admin. The cap is checked under a Postgres advisory lock, so two rewrites racing for the last slot cannot go past it. Alsoshiplog_entries.area_detected_at(entries already sent to detection) andshiplog_settings(the last detection run).
Translations
Set config.locales = [:es] and entries are translated into Spanish by your config.rewriter, then published with the English text: there is no second approval.
-
When.
Shiplog::TranslateEntryJobis enqueued for each configured locale when an entry is approved (roadmap entries too), and when the English title or body of an approved entry changes (an admin edit). Editing anything else (the release date, the area) does not translate. Drafts, discarded entries and the unpublishing "Regenerate" (which returns an entry to draft) do not either. -
How. One LLM call per entry, per locale and per change. The prompt asks for the title and body in the language of the locale (Shiplog names about 40 languages; any other locale code is named by its code), keeping the meaning, the tone, product names, numbers, links and formatting, with your
tone,audienceandproduct_nameas context, and for a JSON reply{"title": "...", "body": "..."}. The English text goes inside<source>and is neutralised like the rewriting prompts (a<source>or</source>in it becomes[source]). The reply is validated like the English kinds: strings, a non-blank title, at most 120 characters of title and 2000 of body (a roadmap body is cut at the roadmap limit, about 220 characters, so a translation longer than that loses its last sentence). A customconfig.prompt_builderis for rewriting GitHub text and is not used here. -
Stored in
shiplog_entry_translations(entry_id,locale,title,body,source_digest,edited_by_admin; unique on[entry_id, locale], deleted with the entry).source_digestis the SHA-256 of the English title and body the translation was made from. -
The stale rule. A translation is current while its
source_digestmatches the entry's English text now. Pages show it only then; a missing or stale one shows the English text. A job whose translation is current does nothing and makes no LLM call, so repeated approvals and double clicks cost nothing. The job checks again, under the entry's row lock, before it writes: if the entry was unpublished or its English text changed meanwhile, the result is dropped (the change enqueued its own job). -
Admin edits. The entry edit page has a Translations section per locale with the title and body, editable. Saving marks the translation as edited by an admin and as made from the current English text (so it shows at once, even when it was stale). An edited translation is kept as long as the English text stays the same; when the English text changes, the new translation replaces it and clears the mark (the replacement is logged). The section marks each locale as Missing, Stale, Up to date, and Edited by an admin. Retranslate enqueues the job with
force: true, which ignores the digest and an admin's edit. -
Entries approved before 0.3.0 (or before you set
config.locales) have no translations: runbin/rails shiplog:translate_approved. It enqueuesTranslateEntryJobfor every approved entry and configured locale that has no current translation, reading entries in batches (BATCH_SIZE=100by default), and prints the counts per locale (and enqueuesTranslateAreaJobfor areas missing a name in some locale). It is idempotent: what is current is not enqueued, and the job's digest check skips it anyway. It needsconfig.localesandconfig.rewriter, and costs one LLM call per job. -
Errors. A failing rewriter (or an unusable reply) is logged and retried twice with a growing wait (
retry_on StandardError, attempts: 3, wait: :polynomially_longer), then logged and dropped: the English text stays shown and Retranslate tries again. Withoutconfig.rewriternothing is translated. An entry deleted since the job was queued is ignored. -
Area names.
areas.name_translations(jsonb, keyed by locale string) holds each area's name per configured locale, andArea#display_name(locale = I18n.locale)returns the translation for a locale inconfig.locales, else the name. The filter pills, the entry tags, the permalink tag and the Atom<category label>use it; slugs and the categorytermstay English, so?area=URLs are the same in every locale.Shiplog::TranslateAreaJob(area, force: false)is enqueued when an area is created (a rewrite, detection) or renamed, ifconfig.localesandconfig.rewriterare set. It makes one call for all locales (the name inside<source>, neutralised; reply{"es": "..."}); every locale asked for must come back as a valid area name (at most 40 characters, letters, digits, spaces,&,-) or the reply is rejected, retried twice and dropped like entry translations. Withoutforceit only fills locales that have no name and never touches an existing one, so a repeat costs no call. A rename drops the stored translations and translates the new name. The Areas page shows a field per locale on each area: what an admin types is kept (an empty field removes it) until the area is renamed or forced. Translate names enqueues the job for the areas missing a name in some locale; it only fills in the missing names, so edits are kept.bin/rails shiplog:translate_approvedenqueues the job (no force) for those areas too. The ETags include the translated names. - Unpublishing. The job writes nothing for an entry that is not approved. Discarding keeps the rows, but they are never shown: every public query lists approved entries only.
-
Public pages. When
I18n.localeis one ofconfig.locales, the roadmap cards, the changelog list, the permalink page (title, body,<title>, meta description and Open Graph tags), the Atom feed and the "Recently shipped" list showentry.display_titleandentry.display_body(the translation, else English); call them in your own views. Translations are loaded for the current locale only, in one query per page. Slugs stay English and are shared by all locales. Under the default locale or any other, nothing is queried and English shows. Screenshots' default alt text follows the title shown. - Search under a translated locale matches the translated title or body of a current translation, or the English text, with the same escaping.
-
Feed.
/changelog.atomfollows the request locale (xml:lang); an entry'supdatedis the later of its own and its translation's. -
Caching. The ETag includes the locale,
config.locales(throughrender_digest) and the id andupdated_atof the translations shown. -
Routing is yours. Shiplog renders under whatever
I18n.localeyour app sets; it adds no locale routes. For/es/roadmapand/es/changelog, scope your engine mount or set the locale in a controller concern or middleware (the test app uses a tiny middleware that moves/esintoSCRIPT_NAME, so links keep the prefix). A host that redirects/es/...to English has to stop doing so. The locale must be inI18n.available_locales. -
The gem's own texts.
config/locales/es.ymltranslates everyshiplog.*key (public and admin) into neutral Spanish, and the month names its date formats use. Override any key in your own locale files;test/shiplog/es_locale_test.rbkeepses.ymlcomplete, and you can copy it for another language. - Cost. Each approval and each English edit of an approved entry costs one LLM call per locale; a Retranslate costs one.
Screenshots
Entries can show screenshots: the first one on the changelog list, all of them on the entry's permalink page, and in the Atom feed and the og:image tag.
-
Needs Active Storage. The host must load it (
require "active_storage/engine"inconfig/application.rb, orrails/all) and have its tables (bin/rails active_storage:install, thendb:migrate). Without it, or when its tables (active_storage_attachments,active_storage_blobs) don't exist yet, screenshots are off: nothing is fetched, uploaded, queried or rendered, nothing crashes, and Shiplog logs the reason once ([shiplog] Screenshots are off: Active Storage is not loaded...at boot, or...tables are missing...the first time a page checks; the check runs once per process and is not repeated per request).config.images = falseturns them off too. Shiplog declareshas_many_attached :imagesonShiplog::Entryonly when Active Storage is loaded. Active Storage's routes belong to your app: Shiplog's views reach them throughmain_app. -
From GitHub. When a draft is written (and when it is regenerated), the job fetches the issue's or pull request's rendered body (
bodyHTML, one small GraphQL query per draft, so the sync payloads don't grow) and downloads the<img>images in it, at mostconfig.max_images_per_entry(4). It only does so for a draft that has no images yet: images of an approved entry are never changed automatically, and nothing is fetched twice. Only these hosts are fetched, https only, exact host match:private-user-images.githubusercontent.com,user-images.githubusercontent.com,camo.githubusercontent.com,raw.githubusercontent.com,objects.githubusercontent.comandgithub.comfor/user-attachments/assets/paths.github.compaths must be exactly/user-attachments/assets/<letters, digits and dashes>(an encoded..or%2fis refused).raw.andobjects.githubusercontent.comare on the list because GitHub serves attachments, and redirects them, from there. Redirects are followed up to 3 times, each checked against the list again, and the body of a redirect is read under the same size cap. Timeouts are 5 seconds (connect and read), a download gets 15 seconds in total (checked while streaming and between redirects, on a monotonic clock) and all the images of one entry 45 seconds (the rest are skipped and logged), the size limit is 5 MB of raw bytes (requests sendAccept-Encoding: identity; the download stops past the limit), and the type is decided by the file's first bytes: PNG, JPEG, GIF or WebP only (SVG and HTML are refused, whatever the extension says). The pixel size is read from the header too (no decoding): an image over 10,000 pixels on a side or 40 megapixels, or one whose size can't be read, is refused, for downloads and for admin uploads alike. The width and height are stored in the blob's metadata at once, so the pages carry them without waiting for Active Storage's analysis. The GitHub token is sent togithub.comonly, never to the other hosts; signedprivate-user-imagesURLs need none. Any failure is logged and skipped, and never fails the draft. The token needs no extra permission. -
Admin. The entry edit page lists the images in order, each with an alt text field, "Remove" and "Up"/"Down" buttons, plus an upload form (several files; same type and size checks, done on the file's bytes on the server). Thumbnails are 200px variants when the
image_processinggem is in your bundle and a processor is available (ruby-vipsloads, ormini_magickis in the bundle), else the original, sized by CSS. -
Order and alt text. Stored on the entry:
image_order(jsonb, blob ids in order) andimage_alts(jsonb, blob id => text). An image without alt text uses the entry's title.Shiplog::Entry#ordered_imagesreturns the attachments in order. -
Public.
<img loading="lazy">with the alt text,widthandheight(taken from the image's header when it is attached, so they don't wait for the analysis). The URLs come from your routes (url_foron the attachment, soresolve_model_to_routeapplies). Theog:imageand the feed use the absolute URL (config.canonical_urlwhen set). The ETags include the images' blob ids, order, alt texts and sizes. -
Texts:
shiplog.admin.entries.images.*, all with English defaults. -
Schema:
shiplog_entries.image_orderand.image_alts, in the 0.3.0 migration, plus Active Storage's own tables.
Entry permalinks
Every approved feature and change has its own page at GET /changelog/:slug (under config.changelog_path). The changelog list links each title to it. Roadmap entries have no permalink.
-
Slug: made when the entry is first approved, from the title (
parameterizewith underscores turned into dashes, soa-z,0-9and single dashes only; at most 80 characters,entry-<id>when nothing is left) and made unique with-2,-3, ... A later title edit keeps the slug, so links never break; restoring and re-approving an entry keeps it too. A unique index (partial, on non-null slugs) guards against two approvals racing for one slug, and the loser retries. -
Entries approved before 0.3.0 get their slug from the 0.3.0 migration (oldest first, so the oldest keeps the plain slug on duplicate titles).
bin/rails shiplog:backfill_slugs(idempotent, oldest first) does the same for entries that old code approved during a deploy window. The public pages never write, because a host may send GETs to a read replica: an approved entry without a slug is listed without a link, left out of the feed and has no page until it has a slug. An entry whose stored slug the route rejects is treated the same way. -
The page shows the entry, a "← Back to the changelog" link to the entry's month anchor, and its newer and older neighbours (
rel="prev"andrel="next", as in the pagination). Drafts, discarded and roadmap entries and unknown slugs answer 404 withX-Cascade: pass: the engine is usually mounted at/, and its/changelog/:slugroute would otherwise shadow a host route of yours below the changelog path (say/changelog/archive, declared after the mount). The router carries on with the routes after the mount, and your own 404 shows if none matches. Declare such host routes aftermount Shiplog::Engine; before it they win anyway. -
Caching: the ETag covers the entry and its neighbours' ids and
updated_at. -
Head tags: the page's description (the body, cut at 160 characters), canonical link and Open Graph tags (
og:title,og:description,og:type=article,og:url, and the image) come fromshiplog_head_tags; see Head tags. -
Styling: the
permalink(thearticle),permalink_back,permalink_navandpermalink_nav_linkkeys ofconfig.classes. Overrideshiplog/changelogs/_entry_content.html.erb(localsentry:,heading:,linked:) to change what an entry shows in both the list and the permalink page. -
Texts:
shiplog.permalink.title,.back,.nav_label,.newerand.older(withtitle); all have English defaults.
Structured data
The public pages describe themselves to search engines with schema.org JSON-LD: one <script type="application/ld+json"> per page, rendered by shiplog_head_tags (see Head tags; leave it out with except: :structured_data). The older content_for :shiplog_structured_data block still holds the same tag.
-
Permalink page: an
Articlewithheadline(the title, at most 110 characters),description(the meta description),inLanguage(I18n.locale, e.g.en,es),datePublished(the release date),dateModified,urlandmainEntityOfPage(the permalink, honouringconfig.canonical_url),image(absolute URLs of the screenshots, else theconfig.og_imageimage, left out without any),articleSection(the area's name, when it has one),publisher(anOrganizationnamedconfig.product_name, left out while it isnil) andisPartOf(the changelog'sCollectionPage). Titles and bodies follow the request locale, translations included. -
Changelog list: a
CollectionPagewhosemainEntityis anItemListof the entries on the current page (positioncounted on the page,url,name). Entries without a valid slug are skipped. Search results (they arenoindex) and an empty list have none. -
Roadmap: a
CollectionPagewith anItemListof the cards (positionandname; roadmap cards have no permalink, so nourl). -
Safety: the JSON is built from a Ruby Hash and
<,>and&are written as\u003c,\u003eand\u0026, so a title containing</script>cannot close the tag. -
Off:
config.structured_data = falseemits none. It is part of the ETag.
Head tags
Shiplog's pages describe themselves in the <head>: description, canonical link, robots, Open Graph, Twitter card, the Atom feed link and JSON-LD. Shiplog's own layout renders them. If you set config.public_layout to a layout of your own, add one line to its <head>:
<head>
<title><%= content_for(:title) %></title>
<%= shiplog_head_tags %>
</head>shiplog_head_tags is available in every view of your app (the only Shiplog helper that is), and on any page that is not a Shiplog page it returns an empty string, so it is safe in a layout shared with your own pages. Call it in the layout, not inside a component (ViewComponent, Phlex): in a view that doesn't have Shiplog's helpers it also returns an empty string. <title> stays yours: it is content_for(:title).
After changing your layout's <head> or overriding a shiplog/head/* partial, bump config.cache_version: ETags don't cover layouts, so visitors with a cached page would keep the old tags.
-
What each page gets. Permalink:
description,canonical(the permalink),og:title,og:description,og:type=article,og:url,og:imageandog:image:altwhen there is an image,twitter:card(summary_large_imagewith an image,summarywithout) andtwitter:image, the Atom link and theArticleJSON-LD. Changelog list:canonical(the list's own URL, with itsareaandpagebut without any other query parameter),og:title,og:type=website,og:url,twitter:card=summary, the Atom link and theCollectionPage. Roadmap: the same without the Atom link. Each key is emitted once. -
Search pages (
?q=) arenoindexand have no canonical (and noog:url, no JSON-LD). The alternative, a canonical pointing at the plain list, would tell search engines to index a page whilenoindextells them not to; mixed signals are ignored or worse, so a search page just asks not to be indexed. -
except:leaves groups out, for tags your layout already writes:shiplog_head_tags(except: [:canonical, :twitter]). Valid keys::description,:canonical,:robots,:open_graph(theog:*tags),:twitter(thetwitter:*tags),:feedand:structured_data. Any other key raisesArgumentError. -
except: :canonicalandog:url.except: :canonicaldrops only the<link rel="canonical">.og:urlkeeps Shiplog's canonical, which honoursconfig.canonical_url; to change it, drop:open_graphtoo. A host that writes its own canonical should make it agree with that one, and should not emit a canonical on search pages, where Shiplog setsnoindex: Shiplog fillscontent_for(:shiplog_robots)with"noindex"there (and leaves it empty elsewhere), which your layout can check. -
Overridable partials. Copy one into your app at the same path to change it (like the other partials):
Partial Renders shiplog/_head_tags.html.erbrenders the four below shiplog/head/_meta.html.erbdescription, canonical link androbotsshiplog/head/_open_graph.html.erbog:*andtwitter:*shiplog/head/_feed.html.erbthe Atom <link rel="alternate">shiplog/head/_structured_data.html.erbthe JSON-LD <script>Every partial gets the same locals:
head(a Hash with:kind(:permalink,:changelogor:roadmap),:title,:entry(permalink only),:image,:canonicaland:description),description,canonical,robots,open_graphandtwitter(Hashes of tag name to content),feed_url,feed_titleandjson_ld. Whatexcept:removed arrives asnilor{}, so a partial that renders its locals respects it. -
The share image. The permalink page's image is the first screenshot whose URL can be built (absolute URL), else what
config.og_imagereturns, else none.config.og_imageisnilor->(entry, locale) { url }, evaluated in the view (instance_exec, somain_app.my_card_url(entry.slug)works). A path ("/cards/dark.png") is made absolute withconfig.canonical_urlor the request's origin. A callable that raises, or returns anything but an http(s) URL or a path (javascript:,//host,nil), is logged once for the page ([shiplog] config.og_image ...) and the page renders without the image.Article.imageof the JSON-LD lists all the screenshots with a URL, else that same callable image. List pages and the roadmap get no image from Shiplog: your default applies, through your own tags (except: [:open_graph, :twitter]) or an override of_open_graph. -
The older
content_forblocks (:shiplog_description,:shiplog_og,:shiplog_feed,:shiplog_robots,:shiplog_structured_data) are still filled as in 0.3.0 and still work, but are superseded. The one difference::shiplog_ognow includes theconfig.og_imagefallback image when that is set and the entry has no screenshot. Useshiplog_head_tagsor the blocks, not both:shiplog_head_tagsnever reads the blocks, so a layout that renders both emits the tags twice. The blocks have neither the canonical link nor the Twitter tags. Upgrading: replace thecontent_forrenders in your<head>withshiplog_head_tags, and passexcept:for the tags your layout writes itself.
Atom feed
GET /changelog.atom serves the latest 50 approved features and changes (newest first) as an Atom feed. An entry has a stable id (a tag URI built from the permalink path, so a title edit does not change it), its permalink, published (the release date), updated, and its body as HTML. All URLs are absolute. The feed is cached with an ETag like the pages and answers 304 on a match; it does not call on_changelog_seen.
The changelog and permalink pages advertise it with a <link rel="alternate" type="application/atom+xml"> (rendered by shiplog_head_tags, see Head tags; the older content_for :shiplog_feed block holds the same link) and the changelog shows a visible "Subscribe (Atom)" link (the footnote_link key). The feed has an <author> (the product name, or "Changelog"), which RFC 4287 requires when entries carry none. The feed is titled "Changelog", or "<product_name> changelog" when config.product_name is set, and follows the request locale. Texts: shiplog.feed.title_with_product, .link_title and .subscribe.
Canonical URL
By default the feed's ids and every absolute URL (the feed's self and alternate links, entry links, og:url, the feed discovery link) are built from the request's host, which varies: apex and www, or a forged X-Forwarded-Host. A feed reader treats a changed id as a new entry, so set the origin once:
config.canonical_url = "https://trackberry.com"It must be an http(s) origin: scheme, host and an optional port, no path, query or credentials (a trailing slash is dropped; anything else raises ArgumentError). A path prefix is not needed: if you mount the engine under one (mount Shiplog::Engine => "/updates"), the routes already add it, so the feed lives at https://trackberry.com/updates/changelog.atom. The feed's tag URIs use the canonical host. It is part of render_digest, so changing it refreshes cached pages, as does product_name. Leave it nil to keep the request's host.
Behind a proxy or load balancer, the scheme of the URLs built from the request (when canonical_url is nil) comes from X-Forwarded-Proto: set config.assume_ssl = true in production (or have your proxy send the header), or the URLs say http://.
What's new: the unseen count
Shiplog doesn't know your users, so you supply the "last seen" time:
# config/initializers/shiplog.rb
config.on_changelog_seen = lambda do |controller|
user = controller.send(:current_user) # current_user is often private; a Current attribute (Current.user) works too
# Write only when something is new: the callback runs on every visit, before the page renders.
user.update_column(:changelog_seen_at, Time.current) if user && Shiplog.unseen_count(since: user.changelog_seen_at).positive?
endShiplog.unseen_count(since: user.changelog_seen_at) # approved features and changes approved after it, at most 99
Shiplog.unseen_count(since: nil) # all of them, at most 99class ApplicationController < ActionController::Base
helper Shiplog::UnseenHelper
end<%= shiplog_unseen_count(current_user.changelog_seen_at) %>The count is one COUNT query over the (status, approved_at) index. It filters on approved_at (when the entry was approved), not on the release date the list is ordered by: "new since you last looked" is about when an entry appeared, so an entry approved today with an earlier released_on still counts as new.
on_changelog_seen runs before the page renders, on the changelog index and on permalink pages whose entry exists, for 200 and 304 answers alike (a visitor whose browser revalidates its cached page has still seen the changelog). Running first means a navbar badge rendered in the same response shows the count after your update. It does not run for 404s (an unknown permalink, a page past the last one), the feed, the roadmap or non-HTML formats. Because it runs on every visit and may write, keep it cheap: write only when the stored time is older than the newest entry, as in the example above (one COUNT, and a write only when there is something new). The response is not sent yet, so the callback can still set a cookie. An exception in the callback is logged with its class, message and the first five backtrace lines, never shown. A host layout that shows the unseen count renders per-user content, so declare etag { current_user&.changelog_seen_at } on your public_parent_controller, or a cached page keeps showing the old count.
Monthly announcements
Once a month, Shiplog drafts a short announcement of what shipped: an intro, two or three highlights linking to their permalink pages, and an "Also improved" list grouped by product area. Your config.rewriter writes it, in Markdown, from the month's approved features and changes and from the small changes that got no entry of their own (changes the AI judged not customer-facing, and merged pull requests without an entry). An admin edits and approves it like any other entry. Nothing is public before that.
The changelog stays the daily stream. The announcement is its monthly summary.
Drafting. Schedule Shiplog::DraftAnnouncementJob early each month (see Scheduling). It drafts the previous month, once: running it again does nothing. A month without an approved feature or change gets no draft. The inbox also has "Draft announcement for " buttons for the current and the previous month, and bin/rails shiplog:draft_announcement MONTH=2026-10 drafts any month. Without a rewriter, the draft is a skeleton listing the month's entries with links, ready to edit, and Regenerate rebuilds it from the month's current entries. A month drafted from the button before it ended is not drafted again by the job: regenerate it once the month is over.
An announcement belongs to a month, so its page is /changelog/2026-10. The YYYY-MM slug is set when it is drafted, and features and changes never get a slug of that shape. If a month's entries change after the draft was written, the inbox flags "The month's entries changed since this draft": regenerate it, or edit it. Regenerating an approved announcement returns it to draft, as for other entries.
Where it is published is config.announcements:
| Value | What happens |
|---|---|
:page (default) |
Shiplog renders /changelog/2026-10, links it from the month's heading on the changelog ("Read the October 2026 summary") and adds it to the Atom feed. |
:external |
No Shiplog page. The month's heading links to config.announcement_url, and your app publishes the text itself through the hook below. |
false |
No announcements: nothing is drafted, rendered or announced. |
config.announcements = :external
config.announcement_url = ->(announcement, locale) { main_app.blog_post_path(announcement.slug, locale:) }announcement_url is evaluated in the view, so route helpers work. A nil or unsafe result renders no link. Bump config.cache_version when its output changes.
The approval hook. config.on_announcement_approved is called with the announcement once it is approved and translated into every configured locale (right after approval with no config.locales, or with no rewriter to translate). It runs once per approval, in Shiplog::AnnounceJob: an announcement regenerated and approved again is announced again. The hook runs inside the announcement's row lock and is only marked done when it returns, so a hook that raises, or a worker that dies during it, is called again by the retry (twice at most). Keep it fast and idempotent: enqueue your own job, as below. The same moment sends an announcement_approved.shiplog ActiveSupport notification with announcement: in the payload. If the hook gave up, or a translation never arrived, the admin page has a "Notify now" button, which does not wait for translations (missing ones fall back to English). Editing an approved announcement's text does not call the hook again.
config.on_announcement_approved = ->(announcement) { BlogImportJob.perform_later(announcement.id) }The announcement is a Shiplog::Entry that offers what you need to publish it elsewhere, in the default locale or any configured one:
announcement.display_title(:es) # the Spanish title, or English without a current translation
announcement.to_markdown(:es) # the body as written (Markdown)
announcement.to_html(:es) # rendered and escaped, safe to embed
announcement.to_text(:es) # plain text, e.g. for an email's text part
announcement.public_url # its Shiplog page (absolute with config.canonical_url)
announcement.slug # "2026-10"Export. The announcement's admin page has a preview, and Markdown, HTML and plain-text downloads in each locale, for blogs and newsletter tools without an API.
Badge. Shiplog.unseen_announcements_count(since:) (and shiplog_unseen_announcements_count(since) in views) counts approved announcements, where Shiplog.unseen_count counts every feature and change. Badge the rare monthly summary rather than every daily entry. config.on_changelog_seen also runs on an announcement's page, and controller.shiplog_entry is the entry being viewed, so you can stamp a separate "announcements seen" time:
config.on_changelog_seen = lambda do |controller|
user = controller.send(:current_user)
next unless user
if controller.shiplog_entry&.announcement?
user.update_column(:announcements_seen_at, Time.current) if Shiplog.unseen_announcements_count(since: user.announcements_seen_at).positive?
elsif Shiplog.unseen_count(since: user.changelog_seen_at).positive?
user.update_column(:changelog_seen_at, Time.current)
end
endTranslations follow the entries' flow: an approved announcement is translated into config.locales, keeping its Markdown and links.
Styling. The page uses shiplog-announcement, shiplog-announcement__title and shiplog-announcement__body; the month link uses shiplog-month__announcement and shiplog-month__announcement-link. All are config.classes keys. The body's HTML comes from a small built-in Markdown renderer (headings, paragraphs, lists, links, images, emphasis, code, quotes) that escapes everything else.
Prompt. config.prompt_builder receives kind: "announcement" and a context Hash: month, entries (each with kind, title, body, area, url) and skipped (titles). Ask for {"title": "...", "body": "..."} with a Markdown body, or return nil for this kind to keep the built-in prompt.
Links in the draft are the entries' permalink URLs, absolute when config.canonical_url is set (set it: the text travels to the feed, exports and your hook). With the engine mounted under an optional segment (scope "(:locale)"), they are the default locale's URLs.
Newsletter
Optional, off by default. The approved announcement goes out by email, in each recipient's language, to your users who opted in and to the visitors who subscribed through Shiplog's form. Approving publishes the announcement; sending the newsletter is a separate, explicit action on the announcement's admin page.
Setup.
Shiplog.configure do |config|
config.newsletter = true
config.newsletter_from = "Acme <updates@acme.com>"
config.newsletter_address = "Acme Inc., 123 Main Street, Springfield" # the footer's postal address (CAN-SPAM)
config.canonical_url = "https://acme.com" # the links in emails are absolute
config.newsletter_logo_url = "https://acme.com/logo.png" # optional branding
config.newsletter_accent = "#0969da"
endThen run the migrations (bin/rails shiplog:install:migrations && bin/rails db:migrate): the newsletter adds shiplog_newsletter_subscribers and shiplog_newsletter_deliveries. The admin nav gets a Newsletter page, and the changelog and announcement pages get a subscribe form. At boot, Shiplog logs what is still missing to send.
Recipients: your users. Shiplog never stores them. Give it a callable returning objects that respond to email, locale and id (an Active Record relation is read in batches), and two hooks so an unsubscribe reaches your side too:
config.newsletter_recipients = -> { User.where(newsletter: true) }
config.newsletter_unsubscribe_url = ->(user) { Rails.application.routes.url_helpers.settings_url(host: "acme.com") } # optional
config.on_newsletter_unsubscribe = ->(email) { User.where("lower(email) = ?", email).update_all(newsletter: false) }You own the opt-in, the settings toggle and the locale: resolve it yourself (for Trackberry: the user's locale, then the organization's default, then English). A locale that is not the default nor one of config.locales gets the default language. Objects without a usable email or id are skipped with a warning (which names no address).
How an unsubscribe works for your users: every newsletter carries the RFC 8058 one-click headers (List-Unsubscribe and List-Unsubscribe-Post, which Gmail and Yahoo require from bulk senders) pointing at Shiplog's own endpoint, POST /shiplog/newsletter/unsubscribe/<token>, where the token is the recipient's email, encrypted and signed with a key derived from your app's secret_key_base (nothing in a link or a log reveals the address). So Shiplog needs no row for your users until they unsubscribe, and one endpoint serves both lists: it records the unsubscribe in Shiplog, as a suppression row in shiplog_newsletter_subscribers holding that one email (created when the address has no row, marked when it has), and calls on_newsletter_unsubscribe with the lowercased email so your own setting follows. From then on the address is skipped at send time even if your list still returns it: an unsubscribe is honoured whether or not your hook works. Set the hook anyway (Shiplog warns at boot when newsletter_recipients is set without it), or your settings page will show the user as subscribed. When a user opts in again in your app, lift the suppression:
Shiplog::Newsletter.resubscribe(user.email) # true when the address was suppressedA suppression-only row is deleted (nothing is left to store); a visitor who had confirmed through Shiplog's form is confirmed again. The footer's "Unsubscribe" link opens the same endpoint as a page with a button (a GET changes nothing: mail scanners prefetch links). The footer's "Preferences" link is newsletter_unsubscribe_url when you set it (your own settings page, evaluated with the recipient object), else Shiplog's unsubscribe page. These pages keep working after you turn config.newsletter off (or announcements): a link in an old email must still unsubscribe. If you rotate secret_key_base, call Shiplog::Newsletter::Tokens.rotate(old_secret_key_base) in an initializer while old links must keep working.
Recipients: visitors. The subscribe form is on the changelog and on each announcement page, and you can render it on any page:
<%# a host view; add `helper Shiplog::NewsletterHelper` to the controller %>
<%= shiplog_newsletter_form %>One email field, a hidden honeypot, a plain POST that works without JavaScript. Double opt-in: the form creates a pending subscriber with the page's locale (if it is the default or one of config.locales) and the page it was on (source, a consent record: a same-site path, anything else is dropped), and emails a confirmation link valid 7 days; the link opens a page with a button, and the button confirms. The link is single use (an encrypted nonce, spent when it confirms, voided by an unsubscribe, replaced when a new confirmation is sent), so a replayed link resubscribes nobody. Only confirmed subscribers receive the newsletter. The form answers the same thing for a new, pending, confirmed or unsubscribed address, so it reveals nothing; a confirmed address gets no new email, and an existing row keeps its locale and source (an anonymous submission cannot rewrite them). Attempts are rate limited per address (3 an hour, keyed by a digest of the address without +tag and, for Gmail, without dots), per IP (20 an hour) and overall (300 confirmation emails an hour) through Rails.cache: with a null store nothing is counted, so give production a real cache store (Shiplog warns at boot otherwise). The subscribe POST carries no CSRF token and checks none: the pages carrying the form are served with ETags, so a cached page could hold a stale token, and nothing happens until the address confirms by email.
Every newsletter links a preferences page, reached through a token in the URL without an account, where a subscriber changes the language, unsubscribes, resubscribes (no new confirmation: the token proves the mailbox) or deletes their data. The token is rotated after each change there (the page continues with the new one; the link in the old email stops working, the next newsletter carries the new one). Unsubscribed rows stay as a suppression record; subscribing again through the form means confirming again. Schedule Shiplog::PurgeNewsletterJob weekly (see Scheduling): it deletes pending rows untouched for 30 days and delivery rows older than 180 days. bin/rails shiplog:purge_newsletter does the same.
One person on both lists. Recipients are merged at send time and deduplicated by lowercased email; when an address is on both lists, your user wins (its locale, its preferences link). Any unsubscribe removes the address from both.
Sending. On an approved announcement's admin page: "Send a test to " (config.current_admin's email, in a chosen locale; works before approval) and "Send…", which opens a confirmation step with the recipient count per locale and sends through Shiplog::SendNewsletterJob. Send stays disabled, with the reasons on the page, until the announcement is approved, every configured locale has a current translation (no stale translation is ever sent), and newsletter_from, newsletter_address and an origin for links (canonical_url, else action_mailer.default_url_options) are set. The job renders the body once per locale and emails each recipient once: a shiplog_newsletter_deliveries row (unique on announcement and recipient key, no address) is inserted before the email leaves, 100 recipients at a time, and touched right before each send, so a retried job, two workers or a second click never send twice. This is at-most-once: a worker dying between the insert and the send leaves a pending row, never a duplicate. The page shows sent, failed and pending counts; "Retry failed" reclaims, in one statement, the failed rows and the rows left pending for more than an hour, and sends them again (the one case where a recipient may get the email twice, and you trigger it knowingly); a reclaimed row whose recipient is gone (unsubscribed, deleted) ends as failed with that reason. Failures are recorded as the exception's class and its message with anything address-like removed. One newsletter per announcement: a later Send only reaches recipients added since.
Deliverability. Send bulk mail through a separate stream, so a complaint never hurts your transactional email. With Postmark:
config.newsletter_mailer_options = { message_stream: "broadcast" }
config.newsletter_batch_size = 500 # the default; how many recipients a run claims at a timenewsletter_mailer_options is merged into every newsletter message (not the confirmation email, which is transactional and uses your default delivery settings): ActionMailer options (delivery_method, delivery_method_options) or headers your delivery gem reads. Also: set up SPF, DKIM and DMARC for the sending domain, keep the postal address in the footer, and let Postmark (or your provider) suppress bounces and complaints per stream. Shiplog adds Precedence: bulk and the one-click headers; it does no open or click tracking.
Design. Shiplog::NewsletterMailer builds an HTML part (one 600px table column, fluid on phones, every style inline, absolute image URLs with width and alt, light colours that email clients invert sanely in dark mode) and a text part. The email: a logo row (or the product name as a wordmark) with "View in browser", a hero card with "Monthly update · October 2026", the title and the intro, then one highlight card per feature of the month (up to three, in the changelog's order): its first screenshot full width when it has one, its title, its first sentence and a "See how it works" button to its page; then a card with the rest of the announcement ("Also improved", grouped by area), a "See everything that shipped" button, and the footer with unsubscribe, preferences and the postal address. The subject, title and body are the announcement's translation in the recipient's locale (English without one); the chrome uses Shiplog's I18n keys under shiplog.newsletter (en and es shipped). "View in browser" opens the announcement's page (config.announcement_url, called with the entry and the locale, when announcements are :external).
Highlights and the body. The cards come from the month's approved features themselves (their approved, translated title and body, their screenshot and permalink; features without a permalink are skipped), not from the announcement's text, so an edit to the body's highlights section does not show in the cards. To avoid saying everything twice, the email applies a split rule: when there are highlights and the body has at least two ## sections, it shows the intro (the text before the first heading), the cards in place of the body's first section, and the body from its second section on. Otherwise (no feature with a permalink, or a body without that structure) the whole body is shown as written. The split is made on the Markdown of the locale's text, so translated headings work the same. Screenshots need config.images; their URLs go through Active Storage's redirect route of your app at the email's origin (canonical_url), and a screenshot whose URL cannot be built is left out with a log line.
Customising the email. Copy app/views/shiplog/newsletter_mailer/announcement.{html,text}.erb (and confirmation.{html,text}.erb) into your app, both parts of each: Rails takes a mailer's templates from the first view path that has any, so an app holding only the HTML part sends a single-part email. The templates get @content, a Shiplog::Newsletter::Content with title, month, preheader, html (the whole body, inline-styled), highlights_shown? (the split rule applies), intro_html / intro_text, highlights (each with title, summary, url, image_url, image_alt, image_width, image_height, image?), rest_html / rest_text, text, view_url, changelog_url, accent, logo_url, address and product_name; plus @unsubscribe_url and @preferences_url. Shiplog::NewsletterMailerHelper offers shiplog_mail_palette, shiplog_mail_font, shiplog_mail_button(label, url, accent) and shiplog_mail_screenshot(highlight), and Shiplog::Markdown.to_html(text, styles: { "p" => "..." }) renders Markdown with inline styles. An override must keep the unsubscribe link (@unsubscribe_url), the preferences link and the postal address (@content.address): the List-Unsubscribe headers are set by the mailer, but the footer is the recipient's visible way out and the address is required by CAN-SPAM. To preview locally, add the previews to config/environments/development.rb and open /rails/mailers/shiplog/newsletter_mailer (the sample shows three cards without any data):
config.action_mailer.preview_paths << Shiplog::Engine.root.join("lib/shiplog/previews").to_sAdmin. The Newsletter page lists the subscribers (counts per locale and status, search, remove) and downloads them as CSV (cells that a spreadsheet would read as a formula are neutralised). Your own users are not listed: they are yours.
What Shiplog stores, and consent. In shiplog_newsletter_subscribers: the visitors who subscribed through its form (email, locale, the page they subscribed on, when they subscribed, confirmed and unsubscribed, the preferences token and a pending confirmation nonce), and one row per address that unsubscribed, your users' included: the suppression record that keeps the address out of future sends, holding the email and the time (no locale of yours, no source). Nothing else about your users, and no tracking of opens or clicks. Delivery rows (shiplog_newsletter_deliveries) keep the recipient key (User/42 or Subscriber/7), the locale and the outcome, never the address; they are deleted with a subscriber's row, with the announcement, and after 180 days by the purge job. Log lines name recipient keys or a digest of the address, never the address; the rate-limit cache holds digests. A suppression row stays until the person deletes their data from the preferences page, an admin removes it, or your app calls Shiplog::Newsletter.resubscribe. No queued job carries an address: a test send is delivered in the request, a confirmation's job carries the subscriber's id, and the send job reads its recipients when it runs. Mention the newsletter in your privacy policy.
Styling. The form and pages use shiplog-newsletter with __title, __hint, __form, __label, __input, __button and __notice, all config.classes keys (newsletter, newsletter_title, ...). The honeypot sits in shiplog-newsletter__trap, which the bundled stylesheet moves out of view: keep a rule for it if you style the pages yourself.
Styling
The public pages ship plain semantic HTML with stable BEM classes (shiplog-card, shiplog-entry__title, ...) and a small, framework-free stylesheet. Pick the level that fits your app; the same views work in all of them.
1. CSS variables
The bundled stylesheet is driven by variables you can set on :root, body or .shiplog-page. Inside your own layout it inherits your text colour and draws cards and borders as translucent mixes of that colour (color-mix against currentColor), so it works on light and dark hosts without any setting. Only Shiplog's own layout (.shiplog-layout), which paints the page background, switches to dark colours with prefers-color-scheme.
| Variable | Meaning |
|---|---|
--shiplog-fg |
Text colour |
--shiplog-muted |
Secondary text |
--shiplog-bg |
Page background (only painted by the engine's own layout) |
--shiplog-card |
Card and entry background |
--shiplog-border |
Borders and dividers |
--shiplog-accent |
Links, feature marker, pill, hover |
--shiplog-radius |
Corner radius (default 0.5rem) |
--shiplog-font |
Font family |
--shiplog-space |
Base spacing unit (default 1rem) |
:root { --shiplog-accent: #7c3aed; --shiplog-radius: 0.75rem; }2. Override the BEM classes
All rules live in @layer shiplog, so any unlayered CSS in your app wins without extra specificity:
.shiplog-card { border-radius: 0; }
.shiplog-entry--feature { border-left-width: 8px; }If your own CSS is layered too, declare the order up front, for example @layer shiplog, app;.
3. config.classes (Tailwind and other utility frameworks)
config.classes maps a key to the classes added to that element, next to its BEM class. Set config.stylesheet = false to drop the bundled CSS:
Shiplog.configure do |config|
config.stylesheet = false
config.classes = {
columns: "grid gap-6 md:grid-cols-3",
card: "rounded-lg border border-gray-200 bg-white p-3 dark:border-gray-800 dark:bg-gray-900"
}
endWith Tailwind v4, the class names live in a Ruby file, so tell Tailwind to scan it. In app/assets/tailwind/application.css:
@import "tailwindcss";
@source "../../../config/initializers/shiplog.rb";(The path is relative to the stylesheet: app/assets/tailwind/ up three levels is the app root.) bin/rails generate shiplog:install --tailwind writes both for you, with neutral gray/indigo classes covering every key.
If you keep the bundled stylesheet (stylesheet: true) next to Tailwind v4 utilities, declare the layer order before the import so utilities win over Shiplog's rules:
@layer theme, base, shiplog, components, utilities;
@import "tailwindcss";
@source "../../../config/initializers/shiplog.rb";Without it, shiplog is first seen when shiplog.css loads, which can put it after utilities in the cascade order (a layer's position is fixed by its first mention), and then .shiplog-card { border-radius: ... } would beat a rounded-lg utility. Declaring the order first pins shiplog below components and utilities.
| Key | BEM class | Element |
|---|---|---|
page |
shiplog-page (+ --roadmap / --changelog) |
Page wrapper |
page_header |
shiplog-page__header |
Header with title and lede |
page_title |
shiplog-page__title |
h1 |
page_lede |
shiplog-page__lede |
Intro paragraph |
switcher |
shiplog-switcher |
Switcher nav (Roadmap, Changelog, extra pages) |
switcher_link |
shiplog-switcher__link (+ --current) |
One switcher link |
columns |
shiplog-columns |
Roadmap column grid |
column |
shiplog-column (+ --in-progress / --coming-soon / --later) |
One roadmap column |
column_header |
shiplog-column__header |
Column heading block |
column_marker |
shiplog-column__marker (+ --in-progress / --coming-soon / --later) |
Status dot inside the column h2. The column's rule is its border-top, set by the column modifier (shiplog-column--later); with utility classes, target it with a variant such as in-[.shiplog-column--later]:bg-gray-300. |
column_title |
shiplog-column__title |
Column h2
|
column_count |
shiplog-column__count |
Number of items |
column_description |
shiplog-column__description |
One-line description |
column_empty |
shiplog-column__empty |
Empty column notice |
cards |
shiplog-cards |
List of roadmap cards |
card |
shiplog-card |
Roadmap card |
card_title |
shiplog-card__title |
Card h3
|
card_body |
shiplog-card__body |
Card text |
card_meta |
shiplog-card__meta |
Line under the card text (omitted when empty) |
card_tag |
shiplog-card__tag |
Repository label |
shipped |
shiplog-shipped |
"Recently shipped" section |
shipped_title |
shiplog-shipped__title |
Its h2
|
shipped_list |
shiplog-shipped__list |
Its list |
shipped_item |
shiplog-shipped__item |
One entry |
shipped_date |
shiplog-shipped__date |
Release date of the entry |
shipped_link |
shiplog-shipped__link (+ --all on the changelog link) |
Links to the changelog |
footnote |
shiplog-footnote |
Footnote paragraph |
footnote_link |
shiplog-footnote__link (+ --feed on the Atom link) |
"Suggest a feature" and "Subscribe (Atom)" links |
months |
shiplog-months |
Changelog months wrapper |
month |
shiplog-month |
One month section |
month_title |
shiplog-month__title |
Month h2
|
entries |
shiplog-entries |
List of entries |
entry |
shiplog-entry (+ --feature / --change) |
Changelog entry |
entry_feature |
shiplog-entry--feature |
Also applied to feature entries |
entry_category |
shiplog-entry__category |
"New" / "Improved" pill |
entry_title |
shiplog-entry__title |
Entry h3
|
entry_body |
shiplog-entry__body |
Entry text |
entry_date |
shiplog-entry__date |
time element |
pagination |
shiplog-pagination |
Pagination nav
|
pagination_link |
shiplog-pagination__link (+ --newer / --older) |
Newer/older links |
empty |
shiplog-empty |
"Nothing here yet" notice |
search |
shiplog-search |
Changelog search form
|
search_input |
shiplog-search__input |
The type="search" input |
search_button |
shiplog-search__button |
Submit button |
search_summary |
shiplog-search__summary |
"N results for ..." line |
search_clear |
shiplog-search__clear |
"Clear search" link |
search_empty |
shiplog-search__empty |
"No entries match" notice |
permalink |
shiplog-permalink (+ --feature / --change) |
The article of a permalink page |
permalink_back |
shiplog-permalink__back |
"Back to the changelog" link |
permalink_nav |
shiplog-permalink__nav |
Newer / older nav
|
permalink_nav_link |
shiplog-permalink__nav-link (+ --newer / --older) |
One neighbour link |
areas |
shiplog-areas |
The area filter nav
|
area_link |
shiplog-areas__link (+ --current) |
One filter link, "All" included |
entry_area |
shiplog-entry__area |
An entry's area tag |
entry_images |
shiplog-entry__images |
The container of an entry's screenshots |
entry_image |
shiplog-entry__image |
One screenshot (img) |
Values may be strings or arrays. The list is Shiplog::Classes::KEYS; assigning config.classes raises ArgumentError (listing the valid keys) for any other key, in every environment. String keys are accepted and normalised to symbols, and the hash is stored frozen. Texts come from I18n (shiplog.roadmap.title, shiplog.roadmap.lede, shiplog.roadmap.* (parts, shipped_heading, full_changelog, footnote, suggest), shiplog.switcher.*, shiplog.changelog.*, shiplog.categories.feature / change, shiplog.date_format (default %-d %B %Y, used for entry dates), shiplog.columns.<column>_description), so override them in your locale files.
4. Override the partials
Copy any view from the engine's app/views/shiplog/ into your app with the same path and edit it. Each partial documents its locals in a comment on its first lines:
-
shiplog/_page.html.erb,shiplog/_page_header.html.erb,shiplog/_pagination.html.erb,shiplog/_empty.html.erb shiplog/_switcher.html.erb-
shiplog/roadmaps/_column.html.erb,shiplog/roadmaps/_card.html.erb,shiplog/roadmaps/_shipped.html.erb -
shiplog/changelogs/_month.html.erb,shiplog/changelogs/_entry.html.erb,shiplog/changelogs/_search.html.erb -
shiplog/newsletter/_form.html.erb(the subscribe form),shiplog/newsletter/_page.html.erb(the confirm, preferences and unsubscribe pages' frame), and the emails inshiplog/newsletter_mailer/
shiplog/_page.html.erb is what links the stylesheet (so it also works in layouts you don't control); keep that line if you copy it and still want the bundled CSS.
The admin has its own stylesheet, scoped under .shiplog-admin and also in @layer shiplog. Its variables (--shiplog-admin-fg, -muted, -bg, -surface, -border, -accent) can be set on :root or .shiplog-admin. The admin follows the same rule: dark colours only in the standalone chrome, inherited colours when admin_chrome is :hosted.
Rendering inside your layouts
-
Public pages: set
config.public_parent_controller(defaultActionController::Base; read once when the controller loads, so use the initializer) andconfig.public_layout(default"shiplog/application"). The public controllers run under the parent'sbefore_actions, so pick one that lets visitors through. -
Admin: set
config.admin_parent_controller(same rule),config.admin_layoutandconfig.admin_chrome = :hosted. Each admin page links its ownadmin.css(andboard.json the board) and wraps itself in<div class="shiplog-admin">, so nothing depends on your layout's<head>. In:hostedmode Shiplog adds a small Drafts / Roadmap board / Sync sub-nav inside the content and shows its flash messages there too (setconfig.admin_flash = falseif your layout renders the flash itself). The board reads the CSRF token frommeta[name=csrf-token]and falls back to adata-csrf-tokenattribute, so your layout does not needcsrf_meta_tagsfor drag and drop. -
config.authorize_adminand the route constraint still apply.
Host route helpers
A layout rendered by a Shiplog controller runs in the engine's isolated routing context. Shiplog's controllers include Shiplog::HostRoutesHelper, which forwards any *_path / *_url helper the engine does not define to main_app, so root_path, about_path or new_user_session_path work in your layout as they do elsewhere. Helpers the engine defines win: in the public engine roadmap_path is Shiplog's, and in the admin engine root_path is the admin root, so use main_app.root_path there when you mean your own.
Example: Trackberry-style setup
# config/routes.rb
mount Shiplog::Engine => "/" # /roadmap and /changelog next to the app's own pages
authenticate :user, ->(user) { user.admin? } do
mount Shiplog::Admin::Engine => "/admin/shiplog"
end
# config/initializers/shiplog.rb
Shiplog.configure do |config|
config.public_parent_controller = "ApplicationController"
config.public_layout = "application"
config.admin_parent_controller = "Admin::BaseController"
config.admin_layout = "admin"
config.admin_chrome = :hosted
config.stylesheet = false
config.classes = { columns: "grid gap-6 md:grid-cols-3", card: "rounded-lg border p-3" } # see the generator for all keys
endThe host layouts keep using their own helpers (root_path, current_user, ...) and Tailwind compiles the classes through the @source line.
Hosting notes
- Set
public_parent_controllerandadmin_parent_controllerin an initializer (config/initializers/shiplog.rb). They are read once, when the engine's controller classes load, so changing them later has no effect. - The public pages send an ETag covering the data, locale, gem version,
config.classesandconfig.stylesheet. If your host layout shows per-user content, declareetag { current_user&.id }in the parent controller (config.public_parent_controller); Rails ETags are inherited, so cached responses are not shared between users.
Running the tests
bin/rails db:create db:schema:load # once, creates shiplog_test on local PostgreSQL
bin/rails test
bin/rails test test/system # the roadmap board in headless Chrome
bin/rubocopThe system tests need Chrome; Selenium fetches a matching chromedriver. The database is configured in test/dummy/config/database.yml; set PGHOST, PGUSER and PGPASSWORD if your PostgreSQL needs them.
Tests that call the real GitHub API are skipped by default. To run them:
SHIPLOG_LIVE_GITHUB=1 GITHUB_TOKEN=... SHIPLOG_LIVE_REPO=owner/name bin/rails test test/liveSHIPLOG_LIVE_REPO defaults to Trackberry/shiplog.
License
Released under the MIT License.
Releasing
Releases are published by .github/workflows/release.yml through RubyGems trusted publishing; there is no API key and no manual gem push.
- Open a pull request that bumps
lib/shiplog/version.rband adds a## x.y.z (date)entry toCHANGELOG.md. - Merge it. The workflow reruns the tests, refuses a version that is already on RubyGems, publishes the gem, tags
vx.y.zand creates the GitHub release with that changelog entry as its notes.
The workflow can also be started by hand (Actions → Release → Run workflow), for the first release or to retry one that failed for an unrelated reason.