From b9a20ed0a2808f51a49e772f10b94954e0bd5f1a Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Fri, 28 Aug 2026 15:24:47 +0200 Subject: [PATCH 1/5] fix(seo): restore the homepage OpenGraph image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `getOgImageUrl` strips the leading and trailing slashes off the pathname to build the image filename. For the homepage that pathname is `/`, so stripping left an empty string and the lookup missed — every docs page had an OpenGraph image and the homepage shipped `` with no content. The homepage's collection id is `index`, which is what `getStaticPaths` names its image, so fall back to that when the slug comes out empty. Covered by a regression test that fails against the old expression. The generated-image set comes from the content collection and needs the Astro build pipeline, so the test stubs it and exercises the derivation, which is the half that was wrong. Change-Id: Ifb9a23ea2caa20d28489a4f21363d85ed5e3342c --- src/util/getOgImageUrl.test.ts | 30 ++++++++++++++++++++++++++++++ src/util/getOgImageUrl.ts | 6 +++++- 2 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 src/util/getOgImageUrl.test.ts diff --git a/src/util/getOgImageUrl.test.ts b/src/util/getOgImageUrl.test.ts new file mode 100644 index 0000000000..a189aace58 --- /dev/null +++ b/src/util/getOgImageUrl.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it, vi } from 'vitest'; +import { getOgImageUrl } from './getOgImageUrl'; + +// `getOgImageUrl` looks a derived filename up in the set of images +// astro-og-canvas actually generated, which comes from the content collection +// and so needs the Astro build pipeline. Stub that set and test the derivation +// — the half where the homepage bug was. +vi.mock('../pages/open-graph/[...path]', () => ({ + getStaticPaths: async () => [ + { params: { path: 'index.png' } }, + { params: { path: 'merge-queue.png' } }, + ], +})); + +describe('getOgImageUrl', () => { + it('resolves the homepage to the index image', () => { + // Regression: stripping the slashes off `/` left an empty slug, so the + // homepage was the one page that shipped an empty `og:image`. + expect(getOgImageUrl('/')).toBe('/open-graph/index.png'); + }); + + it('resolves a normal page, with or without a trailing slash', () => { + expect(getOgImageUrl('/merge-queue')).toBe('/open-graph/merge-queue.png'); + expect(getOgImageUrl('/merge-queue/')).toBe('/open-graph/merge-queue.png'); + }); + + it('returns undefined when no image was generated', () => { + expect(getOgImageUrl('/not-a-page')).toBeUndefined(); + }); +}); diff --git a/src/util/getOgImageUrl.ts b/src/util/getOgImageUrl.ts index b4987ea872..ed43b9046a 100644 --- a/src/util/getOgImageUrl.ts +++ b/src/util/getOgImageUrl.ts @@ -20,6 +20,10 @@ const paths = new Set(routes.map(({ params }) => params.path)); * @returns Path to the OpenGraph image if found. Otherwise, `undefined`. */ export function getOgImageUrl(path: string): string | undefined { - const imagePath = path.replace(/^\//, '').replace(/\/$/, '') + '.png'; + // The homepage's collection id is `index`, so stripping its slashes leaves an + // empty string and the lookup misses — which is why the homepage shipped with + // an empty `og:image` while every other page had one. + const slug = path.replace(/^\//, '').replace(/\/$/, '') || 'index'; + const imagePath = slug + '.png'; if (paths.has(imagePath)) return '/open-graph/' + imagePath; } From 0ac1500183e0a137ac2d99488f40e96f5eeb0f81 Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Fri, 28 Aug 2026 15:24:54 +0200 Subject: [PATCH 2/5] fix(a11y): stop docset grids skipping a heading level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docset cards rendered their title as `h4`. Almost every grid sits directly under an `##`, so the outline jumped h2 to h4 — including on the homepage, whose whole body is the "Products" grid. Screen readers and anything parsing the document outline read that as a missing level. Default the card heading to `h3` and make it a prop, because one grid does belong at h4: the "Components" grid in `ci-insights.mdx` is nested under an `### Components`, where h3 would make the cards siblings of their own section heading instead of children. Change-Id: Ie01f4c2b03f1f2b7f798ae89b056135a5b00800e --- src/components/DocsetGrid/Docset.astro | 15 ++++++++++++--- src/content/docs/ci-insights.mdx | 3 +++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/components/DocsetGrid/Docset.astro b/src/components/DocsetGrid/Docset.astro index aedf5868f4..76af8d5c2d 100644 --- a/src/components/DocsetGrid/Docset.astro +++ b/src/components/DocsetGrid/Docset.astro @@ -9,15 +9,23 @@ interface Props { icon?: string; /** Render a product icon in the neutral text color instead of its brand color. */ neutral?: boolean; + /** + * Heading level for the card title. Grids almost always sit directly under an + * `##`, so `h3` is the default; pass 4 for the handful nested under an `###`. + * Skipping a level breaks the document outline that assistive tech and + * agents read the page structure from. + */ + headingLevel?: 3 | 4; } -const { title, path, icon, neutral } = Astro.props; +const { title, path, icon, neutral, headingLevel = 3 } = Astro.props; +const Heading = `h${headingLevel}` as 'h3' | 'h4'; const productIconName = parseProductIcon(icon); const productKey = neutral ? null : (productIconName ?? (path === '/workflow' ? 'workflow' : null)); --- -

+ {productIconName && (
@@ -29,7 +37,7 @@ const productKey = neutral ? null : (productIconName ?? (path === '/workflow' ?
)} {title} -

+
@@ -69,6 +77,7 @@ const productKey = neutral ? null : (productIconName ?? (path === '/workflow' ? border-color: var(--color-rose-700); } + h3, h4 { display: flex; align-items: center; diff --git a/src/content/docs/ci-insights.mdx b/src/content/docs/ci-insights.mdx index 3973d5d08e..36da559ed3 100644 --- a/src/content/docs/ci-insights.mdx +++ b/src/content/docs/ci-insights.mdx @@ -22,6 +22,7 @@ GitHub and covers basic configuration steps. Date: Tue, 1 Sep 2026 13:32:48 +0200 Subject: [PATCH 3/5] feat(diagrams): resolve every diagram color from tokens, at paint time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The diagrams were not ugly because Graphviz is ugly. They were ugly because 63 hand-typed colors across 18 pages had no palette to be consistent with, and their dark mode was a hardcoded list of six strings the plugin string-matched. Four dialects had grown — queue-green, emoji-pastel, nineties-pastel and near-white-blueprint — and `enterprise/architecture` rendered as a stack of white slabs on the dark page, because the plugin deliberately skips a cluster that has a fill and every fill on that page is near-white. Graphviz supports a `class` attribute on graphs, nodes, edges and clusters and copies it verbatim into the SVG. So the plugin stops handling color entirely. It injects shape and spacing defaults, drops the opaque canvas, tags each element with a *role*, strips the inline paint, and one stylesheet resolves surface, border and label at paint time. Dark mode then arrives through the same `:root.theme-dark` block as every other surface on the site — no second render, no flash, no string matching — and the built SVGs now contain no color at all. The 19 fences are not touched. A table in the plugin maps the colors the docs were drawn with onto roles, so the whole corpus is recolored with zero MDX edits and this reverts in one commit. The table maps by hue family, so elements drawn alike still read alike; it is lossy the other way — PostgreSQL and Redis both land on `datastore` — and that is paid back one page at a time as each fence names its own roles. It is a shim with a known end: when no fence spells a color, nothing reaches it. Three things change that are worth knowing before reviewing screenshots: - A graph-level caption moves above the figure. Graphviz's default is below; `labelloc="t"` is injected, so every fence with a `label=` that does not set `labelloc` itself now captions on top. - Diagram text is painted in Inter rather than the browser's default sans. Graphviz still measures in Helvetica, so widths drift by about 3%; the injected node margins absorb it. - Borders lift toward white in dark mode. The product accents themselves are untouched, as DESIGN.md requires — what changes is a value derived from them, because a #347d39 outline on the dark page surface is too dim to read. Two roles deliberately share an accent (`queued` and `mergify` are both Merge Queue teal): the same color under two names, because "waiting in the queue" and "a service we run" are the same idea on two kinds of diagram, and a role name that lies is worse than a duplicated accent. `--color-green-700` is not a new color. It is the green nine diagrams already had hardcoded, promoted so it has a name. Verified by rendering all 35 diagrams on the site in both themes and comparing them against the same page before the change. Change-Id: I4100df168b1a98d79cd2f0eca15697cb2a9db1cb --- DESIGN.md | 15 +- plugins/remark-graphviz.test.ts | 144 +++++++++++++ plugins/remark-graphviz.ts | 352 +++++++++++++++++++++----------- src/styles/index.css | 159 +++++++++++++-- src/styles/theme.css | 40 ++++ src/styles/tokens.css | 7 + 6 files changed, 578 insertions(+), 139 deletions(-) create mode 100644 plugins/remark-graphviz.test.ts diff --git a/DESIGN.md b/DESIGN.md index 57dc3d9d02..91bfd296b3 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -127,8 +127,8 @@ can bypass the semantic layer. ## Product Accent Colors -Product colors are verbatim from `mergify.com/DESIGN.md`. The docs site reuses the same palette -without adding new tokens. +Product colors are verbatim from `mergify.com/DESIGN.md`. The docs site reuses the same palette and +adds nothing to it; the two extra tokens below are not product colors. | Product | Dark / Label | Light / Accent | Hex (label) | | --- | --- | --- | --- | @@ -139,9 +139,14 @@ without adding new tokens. | Stacks | `--color-coral-700` | `--color-coral-400` | #E53935 | | Workflow Automation | `--color-rose-700` | `--color-rose-400` | #E61E71 | -One docs-only addition: `--color-blue-800` (#237caf) for link hover. It is a darker shade of -`blue-700` and exists only because docs is text-heavy with many cross-references; the marketing -site uses product colors for links far less often. +Two docs-only additions: + +- `--color-blue-800` (#237caf) for link hover. It is a darker shade of `blue-700` and exists only + because docs is text-heavy with many cross-references; the marketing site uses product colors for + links far less often. +- `--color-green-700` (#347d39) for diagrams. Not a product color and never used as one: it is the + green nine diagrams had hardcoded before the diagram surface had a palette, promoted so it has a + name. It backs the diagram role `merged`. ### Section-accent body classes diff --git a/plugins/remark-graphviz.test.ts b/plugins/remark-graphviz.test.ts new file mode 100644 index 0000000000..10109f461a --- /dev/null +++ b/plugins/remark-graphviz.test.ts @@ -0,0 +1,144 @@ +import type * as mdast from 'mdast'; +import { describe, expect, it } from 'vitest'; +import { remarkGraphvizPlugin } from './remark-graphviz'; + +type Transformer = (tree: mdast.Root) => Promise; + +/** The attacher takes a unified processor as `this`; the transformer needs none. */ +const attach = (): Transformer => + (remarkGraphvizPlugin() as unknown as () => Transformer).call(undefined); + +/** Run the plugin over a single fence and return the SVG it produced. */ +async function render(value: string, meta: string | null = null): Promise { + const node: mdast.Code = { type: 'code', lang: 'dot', meta, value }; + const tree: mdast.Root = { type: 'root', children: [node] }; + await attach()(tree); + // Without this, a fence that failed to render hands every assertion below the + // raw DOT source instead of an SVG — and every negative assertion passes on + // it. The one regression this suite exists to catch would report as working. + expect(node.type).toBe('html'); + return (node as unknown as mdast.Html).value; +} + +/** The class list of the `` whose `` names `id`. */ +function classesOf(svg: string, id: string): string[] { + for (const m of svg.matchAll(/<g[^>]*class="([^"]*)"[^>]*>\s*<title>([^<]*)<\/title>/g)) { + if (m[2].replace(/-/g, '-').replace(/>/g, '>') === id) return m[1].split(/\s+/); + } + throw new Error(`no element titled ${id} in ${svg}`); +} + +describe('remarkGraphvizPlugin', () => { + it('renders a fence to an SVG carrying the dg class plus the fence classes', async () => { + const svg = await render('digraph { A -> B; }', 'class="queue"'); + expect(svg).toMatch(/^<svg[^>]*class="dg queue"/); + }); + + it('leaves no colour in the output at all', async () => { + const svg = await render(`digraph { + node [style=filled, fillcolor="#347D39", fontcolor="white"]; + subgraph cluster_b { fillcolor="#1CB893"; style="rounded,filled"; label="Batch"; A; } + A -> B [color="#374151"]; + }`); + expect(svg).not.toMatch(/#[0-9a-f]{3,6}/i); + expect(svg).not.toMatch(/\bfill="/); + expect(svg).not.toMatch(/\bstroke="/); + }); + + it('maps the colours the docs were drawn with onto roles', async () => { + const svg = await render(`digraph { + node [style=filled]; + A [fillcolor="#347D39"]; + B [fillcolor="#6B7280"]; + C [fillcolor="#FFF4ED"]; + subgraph cluster_b { style="rounded,filled"; fillcolor="#1CB893"; label="Batch"; A; } + A -> B [color="#9CA3AF"]; + }`); + expect(classesOf(svg, 'A')).toContain('queued'); + expect(classesOf(svg, 'B')).toContain('muted'); + expect(classesOf(svg, 'C')).toContain('pending'); + // Teal is a container on a cluster and a value on a node. + expect(classesOf(svg, 'cluster_b')).toContain('batch'); + expect(classesOf(svg, 'A->B')).toContain('muted'); + }); + + it('reads a cluster drawn with a stroke and no fill', async () => { + const svg = await render(`digraph { + subgraph cluster_w { style="rounded"; color="#6B7280"; label="Waiting"; A; } + }`); + expect(classesOf(svg, 'cluster_w')).toContain('muted'); + }); + + it('lets an authored role win over the substitution table', async () => { + const svg = await render(`digraph { + node [style=filled]; + A [fillcolor="#347D39", class="failed"]; + }`); + expect(classesOf(svg, 'A')).toContain('failed'); + expect(classesOf(svg, 'A')).not.toContain('queued'); + }); + + it('marks a borderless node as plain, so it reads as a caption', async () => { + // `style=filled` reaches a plaintext node and puts a box behind the label; + // the class is what lets CSS take it back off. + const svg = await render(`digraph { + node [style=filled]; + A [shape=plaintext, label="main"]; + B; + A -> B; + }`); + expect(classesOf(svg, 'A')).toContain('plain'); + expect(classesOf(svg, 'B')).not.toContain('plain'); + }); + + it('drops the opaque canvas a fence paints behind itself', async () => { + const svg = await render('digraph { bgcolor="#FAFBFC"; A; }'); + expect(svg).not.toMatch(/<g id="graph0"[^>]*>\s*<polygon/); + }); + + it('injects defaults a fence can still override', async () => { + const rounded = await render('digraph { A [label="x"]; }'); + // The injected `shape=box, style="rounded,filled"` draws a path, not a + // polygon; asking for a plain box gets the polygon back. + expect(rounded).toMatch(/<g id="node1"[^>]*>\s*<title>A<\/title>\s*<path/); + const square = await render('digraph { node [style=filled]; A [label="x"]; }'); + expect(square).toMatch(/<g id="node1"[^>]*>\s*<title>A<\/title>\s*<polygon/); + }); + + it('does not let a border speak for the shape it outlines', async () => { + // An unmapped fill must fall back to the element default, not to whatever + // role the node's darker border happens to match. + const svg = await render(`digraph { + node [style=filled]; + A [fillcolor="#ABCDEF", color="#374151"]; + }`); + expect(classesOf(svg, 'A')).toEqual(['node']); + }); + + it('does not guess a colour for an element whose author named a class', async () => { + // Even an unrecognised class means the author said something; appending a + // guessed role would contradict them with no warning. + const svg = await render(`digraph { + node [style=filled]; + A [class="Queued", fillcolor="#DC2626"]; + B [shape=plaintext, class="plain", fillcolor="#347D39"]; + }`); + expect(classesOf(svg, 'A')).toEqual(['node', 'Queued']); + expect(classesOf(svg, 'B')).toEqual(['node', 'plain']); + }); + + it('strips the alpha Graphviz emits alongside a colour', async () => { + const svg = await render(`digraph { + node [style=filled]; + A [fillcolor="#347d3980"]; + }`); + expect(svg).not.toMatch(/opacity="/); + }); + + it('leaves a fence it cannot render as a code block', async () => { + const node: mdast.Code = { type: 'code', lang: 'dot', meta: null, value: 'digraph { --- }' }; + const tree: mdast.Root = { type: 'root', children: [node] }; + await attach()(tree); + expect(node.type).toBe('code'); + }); +}); diff --git a/plugins/remark-graphviz.ts b/plugins/remark-graphviz.ts index 464dceb332..ee877d6f5e 100644 --- a/plugins/remark-graphviz.ts +++ b/plugins/remark-graphviz.ts @@ -4,149 +4,263 @@ import type * as mdast from 'mdast'; import type * as unified from 'unified'; import { CONTINUE, visit } from 'unist-util-visit'; +/** + * Render `dot` / `circo` / `neato` fences to inline SVG, and hand every colour + * decision to CSS. + * + * Graphviz supports a `class` attribute on graphs, nodes, edges and clusters + * and copies it verbatim into the SVG (`class="node queued"`). So this plugin + * never resolves a colour: it injects shape and spacing defaults, drops the + * opaque canvas, tags each element with a *role*, and strips the inline paint + * so the `.dg` rules in `index.css` resolve surface, border and label at paint + * time from the role accents in `theme.css`. Dark mode then arrives through the + * same `:root.theme-dark` block as every other surface on the site, with no + * second render and no string matching. + */ + const viz = await instance(); -const validLanguages = [`dot`, `circo`, `neato`]; - -// Dark neutral colors used for structural chrome (edges, arrowheads, connector -// labels, cluster borders/labels). These vanish on the dark-mode surface, so we -// swap them for `currentColor` and let the page drive the value via -// --theme-diagram-edge. Saturated/product colors are intentional highlights and -// are left untouched. Compared case-insensitively. -const DARK_NEUTRALS = new Set([ - '#374151', // gray-700 - '#4b5563', // gray-600 - '#6b7280', // gray-500 - '#000000', - 'black', - '#999999', -]); - -/** Replace an element's stroke/fill with currentColor when it is a dark neutral. */ -function themeNeutral($el: ReturnType<ReturnType<typeof load>>): void { - for (const attr of ['stroke', 'fill'] as const) { - const value = $el.attr(attr); - if (value === undefined) { - // Graphviz omits `fill` on default-black text (edge/cluster labels), so an - // absent fill on a <text> is an implicit dark neutral that must be themed. - if (attr === 'fill' && $el.is('text')) { - $el.attr('fill', 'currentColor'); - } - continue; - } - if (DARK_NEUTRALS.has(value.toLowerCase())) { - $el.attr(attr, 'currentColor'); - } - } -} +const VALID_LANGUAGES = ['dot', 'circo', 'neato']; /** - * Make the neutral structural chrome of a rendered Graphviz SVG theme-aware so - * it stays legible in dark mode. Content nodes (their fills and labels) and - * saturated edge colors are deliberately left alone. + * Metrics font. Graphviz sizes every box with its own Helvetica tables; the + * page then paints the text in Inter via `.dg text`. The two differ by about + * 3% in width, which the node margins below are generous enough to absorb. + * + * The node defaults assume a shape whose size is derived from its label. A + * shape with fixed geometry — `shape=point` above all — is inflated by them and + * needs its own `width`/`height` back. */ -function themeDiagram($: ReturnType<typeof load>): void { - // Drop the opaque canvas background so the page surface shows through. - $('svg > g.graph > polygon').first().attr('fill', 'none').attr('stroke', 'none'); - - // Edges: lines, arrowheads, and connector labels all sit on the page surface. - $('g.edge').each((_, edge) => { - $(edge) - .find('path, polygon, text') - .each((__, el) => themeNeutral($(el))); - }); - - // Clusters: only re-theme the border and label when the cluster has no fill, - // so labels that sit on a tinted cluster background keep their color. - $('g.cluster').each((_, cluster) => { - const $cluster = $(cluster); - const clusterFill = $cluster.find('> polygon').first().attr('fill'); - if (!clusterFill || clusterFill.toLowerCase() === 'none') { - $cluster.find('polygon, path, text').each((__, el) => themeNeutral($(el))); - } - }); - - // Graph-level captions (text rendered directly under the graph group). - $('svg > g.graph > text').each((_, el) => themeNeutral($(el))); -} +const METRICS_FONT = 'Helvetica'; + +/** + * Injected into every fence, immediately after the opening brace, so anything + * the author writes afterwards overrides it. `labelloc="t"` puts a graph-level + * caption above the figure, where a figure caption belongs; Graphviz's own + * default is below. + */ +const BASE = ` + graph [bgcolor="transparent", fontname="${METRICS_FONT}", fontsize=13, + labelloc="t", pad="0.12", nodesep=0.45, ranksep=0.55]; + node [fontname="${METRICS_FONT}", fontsize=13, shape=box, + style="rounded,filled", penwidth=1.4, margin="0.24,0.15", height=0.42]; + edge [fontname="${METRICS_FONT}", fontsize=10, penwidth=1.3, arrowsize=0.7]; +`; + +/** + * The classes Graphviz puts on an element itself. Anything else on the element + * came from the fence, and means the author named the element's role. + */ +const STRUCTURAL_CLASSES = new Set(['graph', 'node', 'edge', 'cluster']); + +/** + * Transitional: the colours the docs were drawn with, mapped onto roles. + * + * Four independent dialects grew here — queue-green, emoji-pastel, + * nineties-pastel and near-white-blueprint — because there was no palette to be + * consistent with. This table maps by the hue family each dialect used, so two + * elements drawn in the same colour still read alike. It is lossy in the other + * direction: where one dialect used two shades of a hue for two meanings, both + * land on one role — PostgreSQL and Redis both become `datastore`, and + * "tests passed" and "merged to main" both become `merged`. That is the price + * of recolouring the whole corpus without editing a single fence, and it is + * paid back one page at a time as each fence names its own roles. + * + * It is a migration shim with a known end: once every fence names its own role, + * nothing reaches this table and it goes away. Keys are lowercase hex. + */ +const LEGACY_ROLES: Record<string, string> = { + // Queue dialect — batches, performance, stacks, queue-modes, scopes, + // direct-merge, gha, buildkite. + '#347d39': 'queued', // queue green: a pull request in the queue + '#1cb893': 'config', // Merge Queue teal, as a node: a scope or a config value + '#6b7280': 'muted', // skipped, waiting, not selected + '#9ca3af': 'muted', // cascaded out, dashed side-links + '#111827': 'external', // CI, ci-gate, main + '#0b1120': 'external', + '#2563eb': 'pending', // the detect-scopes step, mid-run + '#dc2626': 'failed', + '#374151': 'chrome', // the edge colour the old plugin string-matched + '#4b5563': 'chrome', + '#5b21b6': 'chrome', // stacks: edges and their labels + + // Emoji-pastel dialect — lifecycle, two-step. + '#f3f4f6': 'external', // dequeued: out of the queue + '#fff4ed': 'pending', // queueing, validating, testing + '#ede9fe': 'queued', + '#f3e8ff': 'queued', // the queue command + '#dbeafe': 'config', + '#d1fae5': 'merged', + '#ddd6fe': 'merged', // merged to main + '#fee2e2': 'failed', + '#10b981': 'merged', // the "passed" edge + '#ef4444': 'failed', // the "failed" edge + '#7c3aed': 'chrome', // the default edge colour on both pages + + // Nineties-pastel dialect — flaky-test-detection. + '#c9e7f8': 'config', // the commit under test + '#b7f5c1': 'merged', // tests passed + '#f8c9c9': 'failed', // tests failed + '#d8f0ff': 'external', // "consistent (not flaky)" + '#ffe9b3': 'pending', // "flagged as flaky" + '#999999': 'muted', // the dashed commit clusters -// Layered style defaults injected into dot blocks based on their CSS classes. -// Diagrams can override any of these by redeclaring the same attributes. -const themes: Record<string, string> = { - // Base theme: applied to all class="graph" blocks - graph: ` - fontname="sans-serif"; - node [fontname="sans-serif", style=filled, fontcolor="white"]; - edge [fontname="sans-serif", color="#374151"]; - `, - // Git commit graph: circles on a left-to-right line - 'git-commits': ` - rankdir="LR"; - splines=line; - node [shape=circle, width=0.5, height=0.5, fixedsize=true]; - `, + // Near-white-blueprint dialect — enterprise/architecture. + '#f6f8fb': 'external', // the default node fill + '#ffffff': 'external', // GitHub + '#24292e': 'external', + '#fff3d6': 'config', // the reverse proxy: the entry point + '#e6f0ff': 'mergify', // dashboard and workers + '#f0ecfe': 'mergify', // the subscription API + '#f4fbff': 'mergify', // the on-premise cluster + '#e4f5ed': 'datastore', // PostgreSQL + '#fce3e8': 'datastore', // Redis + '#fdfeff': 'batch', // the customer-infrastructure cluster + '#8892bf': 'chrome', }; -function injectDefaults(source: string, attrString: string | null): string { - if (!attrString?.includes('graph')) return source; +/** A cluster reads its colour differently: teal is a container, not a value. */ +const LEGACY_CLUSTER_ROLES: Record<string, string> = { + ...LEGACY_ROLES, + '#1cb893': 'batch', +}; - const braceIndex = source.indexOf('{'); - if (braceIndex === -1) return source; +/** Inject the base defaults immediately after the opening brace. */ +function injectDefaults(source: string): string { + const brace = source.indexOf('{'); + if (brace === -1) return source; + return `${source.slice(0, brace + 1)}\n${BASE}\n${source.slice(brace + 1)}`; +} - // Build the defaults string by layering matching themes - let defaults = ''; - for (const [key, value] of Object.entries(themes)) { - if (attrString.includes(key)) { - defaults += value; - } - } +/** The classes already on an element, as a list. */ +function classesOf(value: string | undefined): string[] { + return (value ?? '').split(/\s+/).filter(Boolean); +} - return `${source.slice(0, braceIndex + 1)}${defaults}${source.slice(braceIndex + 1)}`; +/** + * Tag each node, edge and cluster with a role, and remove the inline paint so + * `.dg` owns every colour. + * + * An element that names any class of its own is left alone — the author's + * intent always wins over the substitution table, including when the class is + * one this plugin does not recognise, because guessing a colour for an element + * whose author already said something would silently contradict them. + */ +function applyRoles($: ReturnType<typeof load>): void { + const tag = ( + selector: string, + shapeSelector: string, + table: Record<string, string>, + colorAttr: 'fill' | 'stroke', + fallbackAttr?: 'fill' | 'stroke' + ) => { + $(selector).each((_, element) => { + const $group = $(element); + const existing = classesOf($group.attr('class')); + const $shape = $group.children(shapeSelector).first(); + + const extra: string[] = []; + + // Graphviz draws no border for `shape=plaintext` / `shape=none`, so a + // shape with no stroke is a caption rather than a box — even when the + // node inherited `style=filled` and so came out with a fill behind it. + // A node that asks for a fill and `color=none` is read the same way; use + // `penwidth=0` to keep the fill. + if ($shape.attr('stroke') === 'none' && !existing.includes('plain')) extra.push('plain'); + + if (!existing.some((name) => !STRUCTURAL_CLASSES.has(name))) { + const primary = $shape.attr(colorAttr); + // A cluster drawn with `style=rounded` and no fill carries its colour + // on the stroke instead. Only an absent or explicitly-none primary + // falls through: an unmapped fill must not let a border speak for the + // shape it merely outlines. + const color = + !primary || primary === 'none' + ? ((fallbackAttr && $shape.attr(fallbackAttr)) ?? '') + : primary; + const role = table[color.toLowerCase()]; + if (role) extra.push(role); + } + + if (extra.length > 0) $group.attr('class', [...existing, ...extra].join(' ')); + + // Only direct children are painted by `.dg` in index.css; anything deeper + // keeps whatever Graphviz gave it. + $group + .children() + .removeAttr('fill') + .removeAttr('stroke') + .removeAttr('fill-opacity') + .removeAttr('stroke-opacity'); + }); + }; + + tag('g.node', 'path, polygon, ellipse', LEGACY_ROLES, 'fill'); + tag('g.cluster', 'path, polygon', LEGACY_CLUSTER_ROLES, 'fill', 'stroke'); + tag('g.edge', 'path', LEGACY_ROLES, 'stroke'); } -export function remarkGraphvizPlugin(): unified.Plugin<[], mdast.Root> { - const codeNodes = []; +/** Post-process one rendered Graphviz SVG into a themeable `.dg` diagram. */ +function themeDiagram($: ReturnType<typeof load>, classes: string[]): void { + // Graphviz paints an opaque canvas as the first child of the graph group + // whenever a fence sets its own `bgcolor`. Drop it so the page shows through. + $('svg > g.graph > polygon').first().remove(); + + // The graph-level caption sits directly under the graph group. + $('svg > g.graph > text').removeAttr('fill'); + applyRoles($); + + $('svg').attr('class', ['dg', ...classes].join(' ')); +} + +export function remarkGraphvizPlugin(): unified.Plugin<[], mdast.Root> { const transformer: unified.Transformer<mdast.Root> = async (tree) => { + const codeNodes: { node: mdast.Code; lang: string; attrString: string | undefined }[] = []; + visit(tree, `code`, (node) => { - // Only act on languages supported by graphviz - if (validLanguages.includes(node.lang) && !node.value?.includes('<svg')) { - codeNodes.push({ node, attrString: node.meta }); + // Only act on languages supported by graphviz. A node that already holds + // an `<svg>` has been transformed on an earlier pass. + const lang = node.lang ?? ''; + if (VALID_LANGUAGES.includes(lang) && !node.value?.includes('<svg')) { + codeNodes.push({ node, lang, attrString: node.meta ?? undefined }); } return CONTINUE; }); await Promise.all( - codeNodes.map(async ({ node, attrString }) => { - const { value, lang } = node; - /** This transformer can try to re-transform nodes which are now SVG element, we need to prevent that */ - if (node.value?.includes('<svg')) return node; + codeNodes.map(async ({ node, lang, attrString }) => { try { - // Inject theme defaults for class="graph" blocks - const source = injectDefaults(value, attrString); - // Perform actual render - const svgString = viz.renderString(source, { format: 'svg', engine: lang }); - // Add default inline styling. `color` drives `currentColor` for the - // neutral chrome re-themed in themeDiagram(). + const attrs = attrString ? load(`<element ${attrString}></element>`)(`element`) : null; + const classes = classesOf(attrs?.attr('class')); + + const svgString = viz.renderString(injectDefaults(node.value), { + format: 'svg', + engine: lang, + }); const $ = load(svgString); - $(`svg`).attr( - `style`, - `max-width: 100%; height: auto; color: var(--theme-diagram-edge);` - ); - // Make structural chrome track the theme so diagrams read in dark mode. - themeDiagram($); - // Merge custom attributes if provided by user (adds and overwrites) - if (attrString) { - const attrElement = load(`<element ${attrString}></element>`); - $(`svg`).attr(attrElement(`element`).attr()); - } - // Mutate the current node. Converting from a code block to - // HTML (with svg content) - node.type = `html`; - node.value = $.html(`svg`); + + // Merge the fence's own attributes first — `class` is then recomputed + // from them, so a fence can add a kind without losing `dg`. + const fenceAttrs = attrs?.attr(); + if (fenceAttrs) $(`svg`).attr(fenceAttrs); + themeDiagram($, classes); + + // Rewrite the fence in place: it stops being a code block and becomes + // the rendered SVG. mdast has no in-place conversion, so the node + // itself is retyped — asserting the string into `Code['type']` would + // claim `'html'` is `'code'` and leave the node lying about itself. + const htmlNode = node as unknown as mdast.Html; + htmlNode.type = `html`; + htmlNode.value = $.html(`svg`); } catch (error) { - console.log(`Error during viz.js execution. Leaving code block unchanged`); - console.log(error); + // The fence survives as a code block rather than taking the build + // down, so name it loudly: a diagram silently becoming a wall of DOT + // is easy to miss in a 391-page build log. + console.error( + `remark-graphviz: leaving a ${lang} fence unrendered — ${node.value.split('\n')[0]}` + ); + console.error(error); } return node; diff --git a/src/styles/index.css b/src/styles/index.css index f3f551a097..93a1670a65 100644 --- a/src/styles/index.css +++ b/src/styles/index.css @@ -803,26 +803,155 @@ html { } } -svg.graph { - margin-left: auto; - margin-right: auto; - margin-bottom: 1em; +/* ── Diagrams ──────────────────────────────────────────────────────────────── + Graphviz copies a `class` attribute straight into the SVG, so the plugin + renders shape and layout and nothing else; every colour is resolved here, at + paint time, from the role tokens in theme.css. That is what makes dark mode + free — the same `:root.theme-dark` block as every other surface, no second + render — and what lets a diagram be written without naming a colour. + + Each role carries one accent. These lines turn it into the surface, the + border and the label, by mixing it against the page background. A role that + resolves to nothing — a typo in a fence — falls back to `external` rather + than to an invalid `var()`, which `fill` would inherit as black. */ +.dg, +.dg * { + --dg-accent: var(--dg-a, var(--dg-a-external)); + --dg-surface: color-mix(in oklab, var(--dg-accent) var(--dg-tint), var(--dg-canvas)); + --dg-border: color-mix( + in oklab, + var(--dg-accent) calc(100% - var(--dg-edge-lift)), + var(--dg-edge-base) + ); + --dg-label: color-mix(in oklab, var(--dg-accent) var(--dg-ink), var(--dg-ink-base)); + /* A container is a wash, not a card: mixed at a fraction of a node's tint so + a node of the same role still reads as sitting on top of it rather than + dissolving into it. */ + --dg-container: color-mix(in oklab, var(--dg-accent) var(--dg-tint-cluster), var(--dg-canvas)); +} + +/* Defaults by element kind. Graphviz puts the role class on the same element as + the kind class, so `:where()` drops these to a lower specificity and a role + always wins — regardless of the order the rules end up in. */ +.dg :where(.node) { + --dg-a: var(--dg-a-external); +} +.dg :where(.edge) { + --dg-a: var(--dg-a-chrome); +} +.dg :where(.cluster) { + --dg-a: var(--dg-a-batch); +} + +.dg .chrome { + --dg-a: var(--dg-a-chrome); +} +.dg .muted { + --dg-a: var(--dg-a-muted); +} +.dg .batch { + --dg-a: var(--dg-a-batch); +} +.dg .external { + --dg-a: var(--dg-a-external); +} +.dg .queued { + --dg-a: var(--dg-a-queued); +} +.dg .pending { + --dg-a: var(--dg-a-pending); +} +.dg .merged { + --dg-a: var(--dg-a-merged); +} +.dg .failed { + --dg-a: var(--dg-a-failed); +} +.dg .config { + --dg-a: var(--dg-a-config); +} +.dg .mergify { + --dg-a: var(--dg-a-mergify); +} +.dg .datastore { + --dg-a: var(--dg-a-datastore); +} + +.dg { + display: block; + margin: 1.75em auto; + /* Graphviz's intrinsic `width="NNNpt"` is usually narrower than the prose + column, so without a width the diagrams would all shrink. 80% matches what + the diagrams shipped at before they were tokenised, and keeps a fence's own + inline `max-width` meaningful. */ width: 80%; - background: none !important; + max-width: 100%; + height: auto; +} + +/* Graphviz measures text in its own Helvetica tables and the page paints it in + Inter. The node margins the plugin injects absorb the ~3% width difference. */ +.dg text { + font-family: var(--font-body); +} + +/* Only direct children are painted: the plugin strips their inline fill and + stroke, and leaves anything deeper alone. `:where()` again, so `plain` below + wins on specificity rather than on source order. */ +.dg :where(.node) > path, +.dg :where(.node) > polygon, +.dg :where(.node) > ellipse { + fill: var(--dg-surface); + stroke: var(--dg-border); +} +/* box3d and note shapes draw their folded edges as polylines. */ +.dg :where(.node) > polyline { + fill: none; + stroke: var(--dg-border); +} +.dg .node > text { + fill: var(--dg-label); } -/* Graphviz draws a background polygon as the first child of g.graph; make it transparent */ -svg.graph > g.graph > polygon { - fill: none !important; - stroke: none !important; +.dg :where(.cluster) > path, +.dg :where(.cluster) > polygon { + fill: var(--dg-container); + stroke: var(--dg-border); +} +.dg .cluster > text { + fill: var(--dg-label); + font-weight: 600; + letter-spacing: 0.02em; +} + +.dg .edge > path { + fill: none; + stroke: var(--dg-border); +} +.dg .edge > polygon, +.dg .edge > ellipse { + fill: var(--dg-border); + stroke: var(--dg-border); +} +.dg .edge > text { + fill: var(--dg-label); +} + +/* The graph-level caption sits directly under g.graph, on the page surface. */ +.dg > .graph > text { + fill: var(--dg-title); + font-weight: 600; + letter-spacing: 0.04em; } -/* The graph-level label (e.g. "Merge Queue") is a <text> directly under g.graph, - sitting on the transparent page background. Graphviz bakes it as black, which is - unreadable in dark mode. Use the theme text color so it adapts. Cluster and node - labels live in their own <g> and keep the colors set in the diagram. */ -svg.graph > g.graph > text { - fill: var(--theme-text); +/* A node marked `plain` is a caption, not a box. Graphviz still emits a filled + shape behind a `shape=plaintext` node when it inherited `style=filled`. */ +.dg .plain > path, +.dg .plain > polygon, +.dg .plain > ellipse, +.dg .plain > polyline { + fill: none; + stroke: none; } hr { diff --git a/src/styles/theme.css b/src/styles/theme.css index d5007f97a0..273283be0b 100644 --- a/src/styles/theme.css +++ b/src/styles/theme.css @@ -109,6 +109,33 @@ so structural lines and captions stay legible over the dark surface. */ --theme-diagram-edge: var(--color-gray-700); + /* Diagrams — role accents. A diagram names a role, never a colour; this is + the whole palette, one line per role. The paint rules that consume them + are the `.dg` block in index.css. */ + --dg-a-chrome: var(--color-gray-700); /* edges, arrowheads, captions */ + --dg-a-muted: var(--color-gray-400); /* skipped, dashed, de-emphasised */ + --dg-a-batch: var(--color-gray-500); /* a grouping container */ + --dg-a-external: var(--color-gray-500); /* GitHub, CI — not us */ + --dg-a-queued: var(--color-teal-700); /* in the queue, waiting its turn */ + --dg-a-pending: var(--color-orange-700); /* running now, validating */ + --dg-a-merged: var(--color-green-700); /* merged, passed, done */ + --dg-a-failed: var(--color-coral-700); /* failed, dequeued, cascaded out */ + --dg-a-config: var(--color-blue-700); /* configuration and inputs */ + --dg-a-mergify: var(--color-teal-700); /* a component we run */ + --dg-a-datastore: var(--color-purple-700); /* Postgres, Redis, storage */ + + /* Diagrams — how an accent becomes a surface, a border and a label. The + surface mixes toward the page background, which is why dark mode needs no + second palette: only these numbers change below, never the accents. */ + --dg-canvas: var(--theme-bg-content); + --dg-tint: 10%; /* accent -> surface */ + --dg-tint-cluster: 4%; /* accent -> container surface */ + --dg-ink: 38%; /* accent -> label */ + --dg-ink-base: var(--color-gray-900); + --dg-edge-lift: 0%; /* accent -> border */ + --dg-edge-base: var(--color-white); + --dg-title: var(--theme-text-secondary); + /* Links + accents */ --theme-link: var(--color-blue-700); --theme-link-hover: var(--color-blue-800); @@ -181,6 +208,19 @@ /* Diagrams — brighten the neutral chrome so edges and labels read on dark. */ --theme-diagram-edge: var(--color-gray-400); + /* Diagrams — every dark value for a diagram lives here and nowhere else. + The product accents are untouched: what changes is how much of one is + mixed into the surface, and how far the border is lifted off it. A + --color-green-700 outline on the dark page surface is too dim to read. */ + --dg-a-chrome: var(--color-gray-400); + --dg-a-muted: var(--color-gray-600); + --dg-a-batch: var(--color-gray-500); + --dg-tint: 22%; + --dg-tint-cluster: 9%; + --dg-ink: 30%; + --dg-ink-base: var(--color-gray-50); + --dg-edge-lift: 18%; + /* Links + accents */ --theme-link: var(--color-blue-400); --theme-link-hover: var(--color-blue-700); diff --git a/src/styles/tokens.css b/src/styles/tokens.css index 0b2e85299c..5aed8b5f4c 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -39,4 +39,11 @@ --color-coral-700: #e53935; /* Stacks label */ --color-rose-400: #f485b3; /* Workflow Automation accent */ --color-rose-700: #e61e71; /* Workflow Automation label */ + + /* Diagram green — the value that was hardcoded in nine diagrams before they + had a palette, promoted so it has a name. It is not a product accent: it + backs the diagram role "merged" (done, passed). That is not what those nine + diagrams used it for — they meant "queued", which remark-graphviz now says + on their behalf, so the value moves roles as it gains a name. */ + --color-green-700: #347d39; } From bd960d69504bec7a13cd09af02d72f8d245245e3 Mon Sep 17 00:00:00 2001 From: Mehdi ABAAKOUK <sileht@sileht.net> Date: Wed, 2 Sep 2026 10:30:32 +0200 Subject: [PATCH 4/5] docs(enterprise): document trusting a private CA The enterprise docs never said how to make an on-premise deployment trust a private or self-signed certificate authority, and installation.mdx told operators to disable Redis TLS verification instead, which is the anti-pattern this was raised about. Add an enterprise page for MERGIFYENGINE_EXTRA_CA_BUNDLE: what belongs in the bundle, how to mount it, how to confirm it took effect, and the places where the setting alone is not enough. PostgreSQL needs sslmode=verify-full before libpq consults the roots at all, REDIS_SSL_VERIFY_MODE_CERT_NONE overrides the bundle, SSL_CERT_DIR reaches only part of the engine, and the bundle is read once per process so rotating the CA needs a restart. The PostgreSQL and Redis caveats come before the verification steps on purpose: a connectivity check against a connection that verifies nothing reports ok either way. installation.mdx now leads its self-signed Redis section with the bundle and keeps REDIS_SSL_VERIFY_MODE_CERT_NONE only as the fallback for a certificate you cannot obtain, while still telling a truly self-signed deployment to put the server certificate itself in the bundle, and pointing managed-Redis users at the CA their provider publishes. Its vague PostgreSQL "configure the connection options accordingly" becomes the concrete sslmode. The section heading is unchanged so its existing anchor keeps resolving. Troubleshooting and requirements link the page from the two places an operator hits the wall: a connectivity check that fails on certificate verification, and an egress proxy that re-signs the subscription calls. Fixes MRGFY-8831 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018XJzBXXpAFeRk3pLsADvjw Change-Id: Ib0ef33d61614b688512cfe24897e121790072193 --- src/content/docs/enterprise.mdx | 3 + .../docs/enterprise/advanced-features.mdx | 3 + src/content/docs/enterprise/custom-ca.mdx | 148 ++++++++++++++++++ src/content/docs/enterprise/installation.mdx | 27 +++- .../enterprise/manual-installation-legacy.mdx | 4 +- src/content/docs/enterprise/requirements.mdx | 7 +- .../docs/enterprise/troubleshooting.mdx | 3 + src/content/enterpriseNavItems.ts | 1 + 8 files changed, 187 insertions(+), 9 deletions(-) create mode 100644 src/content/docs/enterprise/custom-ca.mdx diff --git a/src/content/docs/enterprise.mdx b/src/content/docs/enterprise.mdx index ac5a874f71..63a5131c74 100644 --- a/src/content/docs/enterprise.mdx +++ b/src/content/docs/enterprise.mdx @@ -12,6 +12,9 @@ Enterprise, from requirements to installation, integrations, maintenance, and tr [Manual installation](/enterprise/manual-installation-legacy) if you create the GitHub App yourself instead of using the installer. +- Read [Trusting a Private CA](/enterprise/custom-ca) if any of your infrastructure presents + certificates that are not publicly trusted. + - Explore [Advanced features](/enterprise/advanced-features) such as Datadog telemetry, CI Insights, and Slack integrations. diff --git a/src/content/docs/enterprise/advanced-features.mdx b/src/content/docs/enterprise/advanced-features.mdx index dca067f7c2..c2798d2dce 100644 --- a/src/content/docs/enterprise/advanced-features.mdx +++ b/src/content/docs/enterprise/advanced-features.mdx @@ -118,6 +118,9 @@ MERGIFYENGINE_CI_TRACES_DONE_BUCKET="<mycompany>-mergify-ci-traces-done" MERGIFYENGINE_AWS_ENDPOINT_URL_S3=https://my-s3-domain.example.com:1234/ ``` +A self-hosted endpoint whose certificate is not publicly trusted needs its certificate authority +too: see [Trusting a Private CA](/enterprise/custom-ca). + #### Option A: IAM role discovery (recommended) Leave `MERGIFYENGINE_AWS_ACCESS_KEY_ID` and `MERGIFYENGINE_AWS_SECRET_ACCESS_KEY` unset and Mergify diff --git a/src/content/docs/enterprise/custom-ca.mdx b/src/content/docs/enterprise/custom-ca.mdx new file mode 100644 index 0000000000..67a62c6938 --- /dev/null +++ b/src/content/docs/enterprise/custom-ca.mdx @@ -0,0 +1,148 @@ +--- +title: 'Trusting a Private CA' +description: 'Make an on-premise deployment trust certificates issued by your own certificate authority.' +--- + +Mergify checks the TLS certificates it is presented against a set of public certificate authorities +(CAs). When a server presents a certificate that traces back to none of them, verification fails and +the connection is refused. + +Point the `MERGIFYENGINE_EXTRA_CA_BUNDLE` environment variable at a PEM file and Mergify trusts what +it holds on top of the public certificate authorities it already ships. It is the trust store for +every outbound connection the engine opens. You need it when: + +- Your GitHub Enterprise Server presents a self-signed certificate, or one issued by your internal + PKI. This covers the API calls as well as the `git` clones and pushes the merge queue makes. + +- Your Redis, PostgreSQL, or object storage endpoints do the same. Object storage means both Amazon + S3 and Google Cloud Storage. Managed providers usually publish their certificate authority as a + downloadable PEM file, and that file is what goes here. + +- An egress proxy intercepts outbound TLS and re-signs it with a corporate root. + +Your certificates are added to the public ones rather than replacing them, so calls to Mergify's own +[subscription endpoints](/enterprise/requirements/#external-network-access) keep working. + +Two dependencies need one more setting before they check anything against the bundle: +[PostgreSQL](#postgresql-needs-sslmode-too), which verifies no certificate at all by default, and +[Redis](#redis-and-certificate-verification). + +## Prepare the Bundle + +The file is a PEM bundle: one or more `-----BEGIN CERTIFICATE-----` blocks in a single file. What +belongs in it depends on how the server certificate was issued: + +- **Issued by a certificate authority**, whether your internal PKI or a managed provider's. Use the + root certificate and any intermediates, obtained from whoever runs that authority rather than + harvested from the connection you are trying to verify. + +- **Self-signed.** The certificate is its own issuer, so there is no separate authority to ask for. + Put the server certificate itself in the bundle. + +If what you were given is DER-encoded, convert it first: + +```sh +openssl x509 -inform der -in mycompany-ca.crt -out mycompany-ca.pem +``` + +## Configure the Engine + +Mount the file into the container and name it in the environment. Add these two options to your +[normal-mode command](/enterprise/installation/#start-mergify-in-normal-mode): + +```sh +-v /etc/pki/mycompany-ca.pem:/etc/mergify/ca.pem:ro \ +-e MERGIFYENGINE_EXTRA_CA_BUNDLE=/etc/mergify/ca.pem \ +``` + +The container runs as an unprivileged user, so the mounted file has to be readable by it: + +```sh +chmod 644 /etc/pki/mycompany-ca.pem +``` + +Mergify loads the bundle while it starts and refuses to boot when the file is missing, unreadable, +or not valid PEM, so a wrong path is a startup error naming the setting rather than a handshake +failure hours later. + +If you split the deployment across several containers, mount the file into each one and set the +variable on all of them. Every process reads the bundle for itself. + +## PostgreSQL Needs `sslmode` Too + +The bundle gives PostgreSQL the certificates to check against, but it does not decide whether they +get checked. That is `sslmode` in the DSN, and its default, `prefer`, encrypts the connection +without verifying the certificate at all. Ask for verification explicitly: + +```ini +MERGIFYENGINE_DATABASE_URL=postgresql://postgres:password@db.mycompany.com:5432/postgres?sslmode=verify-full +``` + +`verify-full` also requires the certificate to name the host you connect by. When it does not, a +container alias for instance, use `sslmode=verify-ca`, which checks the certificate against the +bundle without pinning the hostname. Either way the engine opens the database while it starts, so a +mismatch is a boot failure rather than something you find later. + +A `?sslrootcert=` already in the DSN takes precedence, and PostgreSQL then verifies against that +file alone. + +## Redis and Certificate Verification + +Connect with `rediss://` and Mergify verifies the Redis certificate against the bundle like any +other. A `?ssl_ca_certs=` in `MERGIFYENGINE_REDIS_URL` points Redis at that file instead. + +:::caution + `MERGIFYENGINE_REDIS_SSL_VERIFY_MODE_CERT_NONE=1` turns Redis certificate verification off + entirely and overrides the bundle. The connection stays encrypted, but Mergify can no longer tell + your Redis apart from anything else answering at that address. Keep it only while you cannot + obtain the certificate, and unset it once you can; Mergify logs a line when both are configured. +::: + +## Verify It Took Effect + +Finish the two sections above first. A connectivity check against a PostgreSQL or Redis that is not +verifying anything reports `ok` without a certificate having been checked. + +Restart the container and look for the bundle in the startup logs: + +```sh +docker logs mergify-engine | grep "Extra CA bundle" +``` + +Each service logs one line naming the bundle it loaded. If the grep finds nothing, read the logs +unfiltered before suspecting your `-e` flag: a bundle the engine cannot read or parse stops the +container before that line, with a different error naming the setting. + +Then check that Mergify reaches each dependency: + +```sh +docker exec -u root -it mergify-engine /bin/bash +mergify-admin connectivity-check +``` + +Every configured dependency should report `ok`; `skipped` means it is not configured at all. A check +that fails on certificate verification means that server's issuer is still missing from the bundle. +See [Troubleshooting](/enterprise/troubleshooting/#checking-third-party-connectivity) for the full +output format. + +## Rotating the Certificate Authority + +The bundle is read once, when the process starts. Replacing the file on disk changes nothing until +the containers restart. + +To rotate without downtime, add the incoming certificate to the bundle alongside the outgoing one, +restart Mergify, switch your servers over, and only then drop the old certificate and restart again. + +## Other Trust Store Variables + +`SSL_CERT_FILE` and `SSL_CERT_DIR` replace the public certificate authorities Mergify would +otherwise use, and `MERGIFYENGINE_EXTRA_CA_BUNDLE` is added on top of whichever you set. + +Use `SSL_CERT_FILE` with a PEM bundle. `SSL_CERT_DIR` reaches the GitHub and Redis connections, but +not the ones that take a single bundle file: PostgreSQL, object storage, Sentry and `git` fall back +to the public authorities Mergify ships, plus your bundle. Your replacement trust store is then only +partly in effect, and Mergify logs a line saying so at startup. + +`AWS_CA_BUNDLE`, if you already have one set, keeps object storage on that file and the bundle is +not applied there. `REQUESTS_CA_BUNDLE` and `CURL_CA_BUNDLE` do nothing at all: Mergify clears them +before it builds any client. diff --git a/src/content/docs/enterprise/installation.mdx b/src/content/docs/enterprise/installation.mdx index ff33321ff4..1b9367b421 100644 --- a/src/content/docs/enterprise/installation.mdx +++ b/src/content/docs/enterprise/installation.mdx @@ -163,21 +163,32 @@ Redis server provides TLS termination. To connect to such an instance you need t #### Optional: use Redis with self-signed TLS certificate -Some Redis server providers set it up with self-signed certificates. In that case, to connect to -such an instance you need to: +Some Redis server providers set it up with self-signed certificates, or with certificates from their +own certificate authority. In that case, to connect to such an instance you need to: - Use `rediss://` instead of `redis://` in the `MERGIFYENGINE_REDIS_URL` environment variable. -- Set `MERGIFYENGINE_REDIS_SSL_VERIFY_MODE_CERT_NONE=1` in your environment variables. Mergify will - connect to Redis using encryption but will not verify the server certificate. +- Add the certificate that signed it, or the server certificate itself when it is self-signed, to + the bundle described in [Trusting a Private CA](/enterprise/custom-ca). Managed providers usually + publish theirs as a downloadable PEM file. Mergify then verifies the Redis certificate like any + other. + +:::caution + `MERGIFYENGINE_REDIS_SSL_VERIFY_MODE_CERT_NONE=1` is the fallback for when you cannot obtain the + certificate at all. Mergify keeps encrypting the connection but stops verifying the server + certificate, so it can no longer tell your Redis apart from anything else answering at that + address. It also overrides the bundle, so unset it once you hold the certificate. +::: ### PostgreSQL Provision a PostgreSQL instance, create a database/user for Mergify, and ensure the container can reach it over the network. Capture the DSN you will pass through `MERGIFYENGINE_DATABASE_URL` (for example `postgresql://postgres:password@postgres:5432/postgres`). -If your provider enforces TLS, configure the connection options accordingly. The first start also -creates a few extensions in that database, so check the +If your provider enforces TLS, add the matching `sslmode` to the DSN; a certificate issued by your +own certificate authority needs `sslmode=verify-full` and the bundle described in +[Trusting a Private CA](/enterprise/custom-ca). The first start also creates a few extensions in +that database, so check the [PostgreSQL requirements](/enterprise/requirements/#postgresql-requirements) before you lock down its privileges. @@ -206,7 +217,9 @@ each authentication mode. ::: The example below includes the CI Insights and Test Insights environment variables. Omit them if you -skipped the previous step. +skipped the previous step. If any of the services Mergify connects to present certificates that are +not publicly trusted, see [Trusting a Private CA](/enterprise/custom-ca) for the two options to add +to this command. ```sh docker run \ diff --git a/src/content/docs/enterprise/manual-installation-legacy.mdx b/src/content/docs/enterprise/manual-installation-legacy.mdx index c98fdb89e6..bd4ff7e89d 100644 --- a/src/content/docs/enterprise/manual-installation-legacy.mdx +++ b/src/content/docs/enterprise/manual-installation-legacy.mdx @@ -128,7 +128,9 @@ docker pull registry.mergify.com/enterprise:@@ENTERPRISE_VERSION@@ > A subscription token is required. Contact Mergify if you do not have one. > -> Ensure Redis (with persistence/TLS if needed) and Postgres are ready. +> Ensure Redis (with persistence/TLS if needed) and Postgres are ready. If they, or your GitHub +> Enterprise Server, present certificates that are not publicly trusted, see +> [Trusting a Private CA](/enterprise/custom-ca). ```sh docker run \ diff --git a/src/content/docs/enterprise/requirements.mdx b/src/content/docs/enterprise/requirements.mdx index c688f79ac3..0283932831 100644 --- a/src/content/docs/enterprise/requirements.mdx +++ b/src/content/docs/enterprise/requirements.mdx @@ -56,6 +56,9 @@ docker run --name some-redis-with-tls -d \ bitnami/redis ``` +That example makes Redis present a certificate signed by a CA you created. Give that CA to Mergify +as described in [Trusting a Private CA](/enterprise/custom-ca), so it can verify the connection. + ## Mergify Requirements The Mergify container needs at least: @@ -90,4 +93,6 @@ Two outbound HTTPS calls must be allowed so Mergify can validate and report lice - `POST https://subscription.mergify.com/on-premise/report` Both calls carry your subscription token and are made by the Mergify container itself, so no other -component needs egress to that host. +component needs egress to that host. If a proxy intercepts outbound TLS and re-signs it with your +own certificate authority, Mergify has to trust that authority for these calls to succeed: see +[Trusting a Private CA](/enterprise/custom-ca). diff --git a/src/content/docs/enterprise/troubleshooting.mdx b/src/content/docs/enterprise/troubleshooting.mdx index 75639ad177..b126a3b0f5 100644 --- a/src/content/docs/enterprise/troubleshooting.mdx +++ b/src/content/docs/enterprise/troubleshooting.mdx @@ -37,6 +37,9 @@ object_storage: skipped github_server: ok ``` +A check that fails on certificate verification means the server presents a certificate Mergify does +not trust. See [Trusting a Private CA](/enterprise/custom-ca). + ### API healthcheck Add a shared token: diff --git a/src/content/enterpriseNavItems.ts b/src/content/enterpriseNavItems.ts index 5f12a316ee..4ff52376b8 100644 --- a/src/content/enterpriseNavItems.ts +++ b/src/content/enterpriseNavItems.ts @@ -5,6 +5,7 @@ const enterpriseNavItems: NavItem[] = [ { title: 'Architecture', path: '/enterprise/architecture', icon: 'lucide:layout-grid' }, { title: 'Requirements', path: '/enterprise/requirements', icon: 'lucide:clipboard-list' }, { title: 'Installation', path: '/enterprise/installation', icon: 'lucide:wrench' }, + { title: 'Private CA', path: '/enterprise/custom-ca', icon: 'lucide:shield-check' }, { title: 'Advanced Features', path: '/enterprise/advanced-features', From e3165b78a221dbc3296183e5874d944489763f5a Mon Sep 17 00:00:00 2001 From: mergify-ci-bot <mergify-ci-bot@users.noreply.github.com> Date: Thu, 3 Sep 2026 05:16:48 +0000 Subject: [PATCH 5/5] docs(agent): document the `mut_` user token the API now accepts --- src/content/docs/api/usage.mdx | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/src/content/docs/api/usage.mdx b/src/content/docs/api/usage.mdx index e0d9558bec..23e275d797 100644 --- a/src/content/docs/api/usage.mdx +++ b/src/content/docs/api/usage.mdx @@ -17,9 +17,9 @@ The API is entirely documented in the [API Reference](/api). ## Authentication -The Mergify API supports two authentication methods, both using Bearer -tokens: **Application Keys** (generated from your dashboard) -and **GitHub Personal Access Tokens**. +The Mergify API supports three authentication methods, all using Bearer +tokens: **Application Keys** (generated from your dashboard), **GitHub +Personal Access Tokens**, and **Mergify User Tokens**. ### Creating an Application Key @@ -118,13 +118,23 @@ curl -H "Accept: application/json" \ organizations. ::: +### Using a Mergify User Token + +Mergify also issues user tokens of its own, recognizable by their `mut_` +prefix and obtained through the OAuth 2.0 device authorization grant. Send one +as a Bearer token, the same way as the credentials above. + +A user token identifies the person it was issued to. It reaches exactly what +its owner's dashboard session reaches, and nothing on GitHub directly, so +holding one grants no access its owner does not already have. + :::caution - Four endpoints refuse a PAT and need an application key. `GET /application` - describes the key it was called with, so it accepts either scope. The `PUT` on - `/repos/{owner}/{repository}/commits/{sha}/scopes` and the `PUT` and `POST` - on `/repos/{owner}/{repository}/pulls/{number}/scopes` need a `ci` key. - Everything else takes a PAT or an `admin` key; each endpoint in the [API - Reference](/api) lists the keys it accepts. + Four endpoints refuse a PAT and a user token alike, and need an application + key. `GET /application` describes the key it was called with, so it accepts either + scope. The `PUT` on `/repos/{owner}/{repository}/commits/{sha}/scopes` and the + `PUT` and `POST` on `/repos/{owner}/{repository}/pulls/{number}/scopes` need a + `ci` key. Everything else takes a PAT, a user token, or an `admin` key; each + endpoint in the [API Reference](/api) lists the credentials it accepts. ::: ### Revoking an Application Key