Skip to content
Signpost

Guides

Developing Signpost

29 min readRead it on GitHub

For someone changing the code. If what you want to change is what your organization publishes — its name, its lifecycle stages, its categories, its colours, its deploy target — none of that is code, and docs/adopting.md is the guide you want. If you want to publish or correct content, that is CONTRIBUTING.md.

What follows assumes you have cloned the repository and want to know where things are before you change one of them.


Contents


Setup

nvm use        # reads .nvmrc — Node 26
npm install
npm run dev    # http://localhost:4321

package.json declares "engines": { "node": ">=22.12.0" } and .npmrc sets engine-strict=true, so an install on an older Node stops with a sentence about the version instead of a stack trace from inside Astro or vitest. .nvmrc pins 26, which is also what CI installs (node-version-file: .nvmrc), so “works on my machine” and “works in CI” mean the same Node. The two numbers say different things on purpose: engines is the oldest Node a fork can run on, .nvmrc is the one this repository is built and tested against.

Script What it runs What it gates
npm run dev astro dev Nothing — hot reload, content included.
npm run validate tsx scripts/validate-content.ts The content rules. Fast, no build, and the first thing CI reports.
npm run schema tsx scripts/json-schema.ts Nothing — rewrites schemas/ from the Zod schemas. Add -- --check and it gates instead: CI fails if what is committed is stale.
npm run check astro check TypeScript and Astro diagnostics, including inside .astro files.
npm run test vitest run The unit tests in src/lib/*.test.ts and scripts/*.test.ts.
npm run build npm run validate && astro build The content rules, then a real production build into dist/. It does not run astro check or the tests, so a green build is not a green gate.
npm run preview astro preview Serves dist/ as it will be deployed.
npm run sync astro sync Nothing — regenerates .astro/types.d.ts after a schema change, if your editor is showing stale content types.

The full gate, which is what CI runs and what you should run before opening a pull request:

npm run validate && npm run schema -- --check && npm run check && npm run test && npm run build

npm run test:watch is the same vitest in watch mode while you work.

TypeScript stays on 6.x. astro check drives the compiler through its programmatic API, and TypeScript 7’s native compiler does not expose that API yet — install 7 and npm run check does not report errors, it refuses to run, which takes type checking out of the gate. Astro tracks the work in roadmap discussion 1321; raise the dependency when @astrojs/check widens its peer range past ^6.


The data flow

Every page is a pure function of content/. The path from a file on disk to rendered HTML goes through exactly these modules:

content/teams/<slug>.yaml
content/streamlines/<team>/<slug>.yaml
docs/*.md
   │
   │  src/content.config.ts     glob loaders; the id is the path, so
   │                            content/streamlines/devops/secret-scanning.yaml
   │                            becomes "devops/secret-scanning"
   ▼
src/lib/schema.ts               Zod shapes for one file
   ▼
src/lib/content.ts              the single cached data layer
   ▼
src/lib/roadmap.ts              lanes, segments, markers, the today line
src/lib/changes.ts              upcoming/recent grouping, "needs attention"
src/lib/timeline.ts             which stage a date falls in
src/lib/feeds.ts  atom.ts       feed entries and the XML around them
src/lib/doc-links.ts            where a link in a guide should point
   ▼
src/pages/*                     routes; src/components/* render

docs/ is content too. The guides you are reading are a third collection, and adding a doc covers what that means for anyone writing one.

src/lib/content.ts is the only place that reads the collections

It is the boundary between Astro and everything else. It builds the Team, Streamline and Update objects the rest of the site uses, and it does all the derivation once: stage and category ids become LifecycleStage and Category objects, hrefs are built with url(), editUrl with blobUrl(), updates are sorted newest-first so authors can append them wherever is natural in the file, and an update with no explicit status: is labelled with the stage the streamline was actually in on the day it was written (via stageOn()), so advancing a streamline does not relabel years of its history.

It caches:

let cache: Promise<{ teams: Team[]; streamlines: Streamline[]; docs: Doc[] }> | null = null;

function loadOnce() {
  cache ??= load();
  return cache;
}

Every accessor — getTeams, getStreamlines, getTeam, getStreamline, getStreamlinesForTeam, getSuperseded, getSupersededBy, getAllUpdates, getDocs — goes through loadOnce(). A page with twenty routes parses the content once. Do not call getCollection anywhere else. If you need a new shape of the data, add an accessor here.

Derived logic lives in src/lib, free of Astro

roadmap.ts, changes.ts, timeline.ts, feeds.ts, atom.ts and date.ts import nothing from astro:content and nothing from a component. That is what makes them unit-testable: vitest.config.ts runs them directly, with only the @config and @/ aliases that tsconfig.json also declares.

They achieve it by taking structural types rather than the content types. roadmap.ts accepts a RoadmapSubject, changes.ts a ChangeSubject, feeds.ts a FeedUpdate — each the minimum set of fields that function needs. A Streamline from content.ts satisfies them structurally, and a test fixture satisfies them with four lines of object literal. As feeds.ts puts it:

Structural types rather than the content types, so this stays testable without a content collection behind it.

The practical rule for a change: if it is a decision, it goes in src/lib and gets a test; if it is markup, it goes in a component. A .astro file that starts calculating dates or percentages is a sign the calculation belongs one layer down. Tests live next to their module — roadmap.test.ts beside roadmap.ts — and there is no separate tests/ directory.


The two validation layers

Content is checked twice, by two things that share one set of schemas.

Layer 1 — src/lib/schema.ts. Zod shapes for a single file in isolation: required fields, string lengths, valid dates, enum membership against the ids in site.config.ts. It is imported by src/content.config.ts (so the Astro build enforces it) and by scripts/load-content.ts (so the CLI validator enforces the same thing). One definition, so CI and your editor cannot disagree.

Layer 2 — scripts/content-rules.ts. Everything a per-file schema cannot see: does that team file exist, does the team: field match the directory the file is in, does the supersedes: target exist, do the timeline dates run in lifecycle order, is effective actually after date, is a winding-down streamline carrying an end date. It also warns — never errors — about an active streamline with no update in 180 days.

Neither of those reads the disk. scripts/load-content.ts does, and it is the only thing outside Astro that does: it walks content/, parses each file, and hands back one entry per file with either the parsed data or the reason there is none. A streamline’s id comes from where its file sits, and that derivation lives there once — two copies of it, drifting, would mean two ids for one streamline and nothing anywhere to notice.

scripts/validate-content.ts is the thin CLI wrapper: it calls validateContent(), groups problems by file, prints warnings then errors, and exits non-zero if there are errors.

Where a new rule goes

  • Can you decide it while looking at one file, one field, with no knowledge of anything else in content/? → src/lib/schema.ts.
  • Does it need another file, another field, or Date.now()? → scripts/content-rules.ts.

A schema rule gets enforced by the Astro build for free. A cross-file rule does not — only npm run validate and npm run build (which runs it first) catch it.

Rules read config, not stage names

The rule that gave this project its reason to exist — a deprecation must carry a retirement date — knows no stage names at all:

const WINDING_DOWN_STAGES = siteConfig.lifecycle.filter((s) => s.windingDown === true);
const TERMINAL_STAGES = siteConfig.lifecycle.filter((s) => s.terminal === true);

and its message is built from the labels it found:

A deprecated streamline must say when it will be retired. Add a `retired` date
to the timeline so the teams depending on it know their deadline.

Rename the stages in site.config.ts and the same sentence comes out reading “A sunsetting streamline must say when it will be switched off”. The configuration itself is validated too: a lifecycle with a windingDown stage and no terminal stage is an error against site.config.ts, because the silent failure mode would be deprecations quietly passing validation.

The messages are the product

A contributor meets this repository through a validator message. That makes the wording a user-facing surface, not a diagnostic string — any change to a message needs a test asserting the new wording.

The model to copy is scripts/content-rules.config.test.ts. It swaps the configuration and asserts on the sentence:

vi.mock('../site.config', () => { /* an "Atlas" config with renamed stages */ });

const { validateContent } = await import('./content-rules');
// ...
expect(messagesOf(result.errors)).toContain(
  'A sunsetting streamline must say when it will be switched off',
);
expect(messagesOf(result.errors)).not.toContain('retired');

Two things to copy from it: the vi.mock plus top-level await import(), which is necessary because the rules read the config once at import; and the separate file, because one module cannot see two configurations. scripts/content-rules.test.ts covers the rules under the real configuration.

One import gotcha

src/lib/schema.ts imports '../../site.config', and everything under scripts/ imports '../site.config' and '../src/lib/…' — relative paths, not the @config and @/ aliases the rest of src/lib uses. That is the convention on the validator’s import path, because those modules are loaded three different ways: by Vite during the build, by vitest, and by tsx from the command line. Relative paths resolve identically in all three; @config does not, because under tsx it is resolved against the current directory rather than the repo root. Keep them relative when you edit anything in scripts/, and remember that a change there has to satisfy npm run validate as well as npm run build.

The third reader is the editor

Both layers tell a contributor what is wrong after they have written it. scripts/json-schema.ts converts the layer-1 Zod schemas to JSON Schema in schemas/, which is what tells them while they are typing. npm run schema regenerates it. A content file reaches its schema through the # yaml-language-server: $schema=… comment on its own first line, which works in any editor running that language server; .vscode/settings.json maps the same schemas by path as a backstop for a file written without it. Both routes go through the same language server, so in VS Code both need redhat.vscode-yaml — which is why it is the first entry in .vscode/extensions.json, ahead of astro-build.astro-vscode, which matters for working on the site but not for writing content. Neither of those two files carries comments: they are committed JSON and the rest of the repository’s JSON parses as JSON.

Four things about it are deliberate, and each one is load-bearing:

It is generated. The interesting fields — status, category, impact — enumerate ids from site.config.ts. A fork that renames deprecated to sunsetting gets a schema offering sunsetting. A hand-written schema would have gone on suggesting a value the validator rejects, in a tooltip that looks authoritative, which is worse than offering nothing at all.

The output is committed. An editor reads files from the working tree the moment a repository is opened; nothing runs a build step first. A schemas/ that only existed after npm run schema would be missing exactly when it is wanted. So CI runs npm run schema -- --check and fails on drift — that step exists because the generated-and-committed pair is otherwise only as fresh as whoever last remembered.

The objects are closed. Every object in src/lib/schema.ts goes through the local strict() helper, so an unrecognised key is an error and the generated schema carries additionalProperties: false. The point is less the strictness than the agreement: an editor underlining a field that CI then accepts teaches a contributor to ignore the editor. What it catches is the failure with no symptom — timelien: on an open object parses, validates, builds and deploys, and the only trace is dates missing from a page nobody is looking at. The one key that is allowed everywhere without meaning anything is $schema, because the same language server reads it and a closed object would otherwise reject a file for pointing at its own schema. A misspelled key inside a timeline gets the list of stages rather than the generic message, which is the second argument to strict().

Dates are collapsed to one string. dateSchema is a union of Date and string, because YAML hands over an unquoted 2026-01-15 already parsed. Emitting that union tells an editor a date field accepts any string at all, which is the one thing it must not say. So dateSchema carries .meta({ id: 'calendar-date' }) and the generator’s override swaps the whole union for a YYYY-MM-DD pattern. Two things to know if you touch that callback: z.toJSONSchema throws on a Date before override ever runs, hence unrepresentable: 'any'; and what override is handed is Zod’s core schema, which has no .meta() — read the id back with z.globalRegistry.get().

If you add a field to src/lib/schema.ts, give it a .describe(). That string is the hover text a contributor reads, and a test in scripts/json-schema.test.ts fails if a field arrives without one. Then run npm run schema and commit schemas/ alongside it.


Styling

Tailwind CSS v4, configured in CSS. There is no tailwind.config.js; the plugin is wired in astro.config.mjs and everything else lives in src/styles/global.css.

Tokens. An @theme block defines the surfaces, text, borders, accent and radii — --color-canvas, --color-surface, --color-ink, --color-ink-secondary, --color-border-strong, --color-accent and friends. Those names become Tailwind utilities (bg-surface, text-ink-muted, border-border).

Tones. A second block, @theme static, defines eight palettes — gray, blue, violet, green, amber, red, teal, pink — each with -bg, -border, -ink and -solid variants. static matters: components read some of these directly as var(--color-tone-*) in inline styles, which Tailwind’s scanner cannot see, and without it a tone added in site.config.ts would render colourless.

The Tone → class mapping is in src/lib/tone.ts, written out in full rather than interpolated, for the same scanner reason: a constructed name like `bg-tone-${tone}-bg` is never generated. If you add a tone, add it to TONES in site.config.ts, to both @theme blocks in global.css, and to every record in tone.ts.

What the scanner reads. global.css imports Tailwind with source(none) and then names its sources: every .astro file, plus tone.ts. Left to itself Tailwind reads the whole repository and takes any word that looks like a class name, prose included — the word “collapse” in a comment once put a .collapse rule in the stylesheet, and since the file is content-hashed, one word changed the markup of every page. Docs, tests and YAML cannot produce a class that reaches a browser, so nothing is lost by not reading them. If you add components in another format — .tsx, .vue, .svelte — add an @source line for them. A class nothing reads is a class nothing generates, and the symptom is an unstyled element rather than an error.

Dark mode. An attribute, not a class:

@custom-variant dark (&:where([data-theme='dark'], [data-theme='dark'] *));

:root[data-theme='dark'] then redefines the same variable names with dark values, so every utility and component switches theme without a single dark: class. The overrides sit inside @media screen on purpose — paper is white whatever your theme is, so print falls through to the light values.

The attribute is seeded before first paint by an inline, un-deferred script in BaseLayout.astro, which reads localStorage.getItem('signpost-theme') and falls back to prefers-color-scheme. That script must stay inline and un-deferred or the page flashes the wrong colours. ThemeToggle.astro only handles the click: it flips document.documentElement.dataset.theme, updates its own aria-label, and writes the choice to localStorage inside a try/catch (blocked storage just means the choice does not persist).

The rule for components: consume tokens, never raw colours. No hex value belongs in a component, because the same component renders in both themes and on paper. If you need a colour that no token provides, add a token.

Long-form Markdown — streamline bodies, update bodies, the guides in docs/ — all render inside .prose, a component layer at the bottom of global.css covering headings, lists, links, tables, blockquotes and code. It is built from the same tokens, so it follows the theme like everything else.

Two renderers produce that Markdown, and which one runs depends only on where the text came from. Anything inside a content file — a streamline’s body, an update’s body — goes through src/lib/markdown.ts, which is marked with raw HTML dropped and heading ids added. The guides in docs/ are the only thing that goes through Astro’s own pipeline, because they are the only thing that needs a table of contents and the cross-reference plugin. The feeds reuse the first of those, so a reader sees the same HTML in their feed reader as on the page.


Adding a page

A route is a file in src/pages/. astro.config.mjs sets build.format: 'directory' and trailingSlash: 'always', so src/pages/foo.astro is served at /foo/.

---
import BaseLayout from '@/layouts/BaseLayout.astro';
import Container from '@/components/Container.astro';
import PageHeader from '@/components/PageHeader.astro';
import { url } from '@/lib/url';
---

<BaseLayout title="Foo" description="One sentence for the meta description.">
  <PageHeader title="Foo" lede="One sentence for the reader." />
  <Container>
    <a href={url('/roadmap')}>Roadmap</a>
  </Container>
</BaseLayout>

BaseLayout takes title (the site name is appended), description, and optionally feed — { href, title } for a page-specific feed, which is emitted before the site-wide one so a browser offers the more specific feed first. It also supplies the canonical link, the Open Graph tags, “Skip to content” and the header and footer. Container takes width="wide" for the roadmap’s full-width grid.

Always build internal links with url(). The site may be served from a subpath — a project Pages site at https://pages.example.com/signpost/ — and url() is what prefixes BASE_URL. It also adds the trailing slash unless the path looks like a file, so url('/roadmap') gives /roadmap/ and url('/feed.xml') stays exactly as written. A hand-written href="/roadmap/" works in development and 404s on every subpath deployment, which is the kind of bug that only shows up after someone else has adopted the project. isActive() from the same module is what the header uses to mark the current section.

For a dynamic route, export getStaticPaths() and pull data from content.ts accessors — see src/pages/streamlines/[...id].astro and src/pages/teams/[slug].astro.

For a non-HTML route, write a .ts file exporting GET(context: APIContext): Promise<Response> — see src/pages/feed.xml.ts.

If the page is top-level, add it to the nav in src/components/Header.astro, which currently lists Roadmap, Changes, Streamlines, Teams, Guide and About. One array drives both the desktop nav and the mobile menu, so that is a single edit.


Adding a doc

Drop a Markdown file into docs/. There is no list to register it in: the collection is a flat glob over docs/*.md, the page title is the file’s own first # heading, and the card on /docs/ uses its opening paragraph. It appears at /docs/<filename>/ on the next build.

Three things follow from these files being read in two places at once — here, and on GitHub, from the same single copy.

No frontmatter. The docs collection deliberately has no schema. GitHub renders a frontmatter block as a table of raw keys at the top of the file, which is the first thing a reader would see. Title and summary are read out of the body instead, in src/lib/doc-links.ts.

The page is built around the document, not added to it. Write the file as you would write it for GitHub; the page supplies the furniture. The first # heading becomes the title band every other section page on this site has, and is taken out of the body so the page has one <h1>. The ## and ### headings become the contents rail beside the text, which marks the section you are in and gives each heading a permalink. The opening paragraph is the lede, both on the page and on the card on /docs/, and the length of the file is the “N min read”. Nothing in that list is configured per document, so a new guide arrives with all of it.

A guide may also write its own ## Contents list, as two of these do, because a reader on GitHub has no other way to see the shape of a long file. On the site that section is hidden: the rail is the same list, in a place where it stays while you read. Nothing else in a guide is ever hidden.

Write links as repository paths. ../site.config.ts, adopting.md, #a-section — whatever is correct for someone reading the file on GitHub. The site rewrites them on the way out, by four rules:

A link like Becomes
#a-section itself, untouched — heading ids match GitHub’s
adopting.md#5-deploy-to-pages /docs/adopting/#5-deploy-to-pages
../src/pages/roadmap/index.astro /roadmap/ — the view, not its source
anything else a GitHub blob URL for that file

Only index.astro counts as naming a view, because only it stands for exactly one URL: a link to [slug].astro or feed.xml.ts is a link to code, and resolves to the blob like any other file. CONTRIBUTING.md is not in docs/, so it is never a page and always resolves to GitHub — which is right, since its reader is on their way to opening a pull request.

The rules are a pure function in doc-links.ts, with the cases pinned down in doc-links.test.ts. They are attached to the Markdown pipeline by src/lib/doc-links-plugin.ts. That pipeline is shared with streamline bodies, so the plugin is written as a factory: Sätteri calls it once per document with the file being compiled, and it returns false for anything that is not a guide — leaving itself out of that document’s pipeline entirely rather than running and doing nothing.

The same plugin also gives every heading its id, with the same slugger Astro uses, so a guide’s own #anchor links keep working and keep matching GitHub’s. It has to: Astro assigns ids in a later pass, so a permalink written here cannot read the id it should point at. Astro keeps an id that is already set, and reports it in the headings it hands the page, so the heading, its permalink and the rail agree without anything having to be kept in step by hand.

Tables and code blocks are handled for you. The same plugin wraps each table so it scrolls in its own box on a phone instead of dragging the page sideways, and .prose in global.css styles both with the site’s tokens. Syntax highlighting is off (astro.config.mjs): the default highlighter ships one fixed palette, which reads as a dark rectangle dropped into a light page.

After changing repository in site.config.ts, build with --force. Those blob URLs are baked in when the Markdown is rendered, and Astro caches a rendered doc against that file’s own digest, under node_modules/.astro. Change the repository and the Markdown has not changed, so the guides are served from the cache and keep the old URLs while the rest of the site updates — which looks exactly like a bug in the link rules. astro build --force and astro dev --force clear that cache. CI installs from scratch and never sees it.


Feeds

src/lib/atom.ts writes Atom 1.0 by hand. That is a deliberate choice over a dependency: the document is small, RFC 4287 is specific, and a feed library is a lot of surface for buildAtomFeed() and escapeXml(). src/lib/feeds.ts turns updates into entries, shared by the site-wide feed and the per-team ones so both are built the same way.

The part to be careful with is entry identity. An entry’s id is its permalink — the streamline’s URL plus the update’s anchor from src/lib/anchor.ts, which is update-YYYY-MM-DD-<slug-of-title> built from the posting date, because that is the one date about an update that never changes afterwards. The site rebuilds nightly; if an id moved, every subscriber’s reader would mark old announcements unread every night. Do not derive an id from anything that can change — not the effective date, not the stage, not the position in the file.

A malformed feed fails silently: readers simply stop updating and nobody finds out for months. In place of that missing feedback, src/lib/atom.test.ts parses every generated document with a real XML parser (@xmldom/xmldom, which throws on anything not well-formed) and then asserts the elements RFC 4287 requires. If you touch the feed writer, add a case there rather than a string comparison.

Note the double escaping in feeds.ts: entry content is escaped once as HTML and again as XML by the writer. That is correct, not a bug — the reader unwraps one layer when it parses the document and the other when it renders the markup.


CI

.github/workflows/ci.yml runs on every pull request and on pushes to main: checkout, setup-node with node-version-file: .nvmrc and npm caching, npm ci, then validate → schema → check → test → build, in that order. Content is validated first on purpose, so that a content mistake is reported as a content mistake rather than surfacing as a type error or a build failure three steps later. The schema step is npm run schema -- --check, and it fails if schemas/ no longer matches what site.config.ts and src/lib/schema.ts would produce — see the third reader is the editor. Concurrency is keyed on the ref with cancel-in-progress: true, so pushing again supersedes the previous run.

Deploy

.github/workflows/deploy.yml builds and publishes to GitHub Pages on merges to main. The only part that concerns code: actions/configure-pages@v5 supplies origin and base_path, which the build receives as the SITE_URL and BASE_PATH environment variables, and astro.config.mjs reads them into site and base (falling back to http://localhost:4321 and / locally). That is the whole mechanism behind the url() rule above. The Pages setup itself — permissions, enabling Pages, GitHub Enterprise, and the artifact-based alternative for an instance with Pages disabled — is in docs/adopting.md, not here.

The same workflow runs on a nightly schedule, because “today” is baked in at build time — relative dates, the today marker on the roadmap, and the split between upcoming and recent changes — so without it a repository nobody merges to for a fortnight serves a fortnight-old idea of now.

Announcements

.github/workflows/announce.yml posts what has changed to Slack on a schedule. It does nothing at all unless an adopter fills in announcements in site.config.ts and adds a SLACK_BOT_TOKEN secret, so a fork that merges the file and ignores it is unaffected.

Three modules, split along the line that decides what is testable:

File Does Touches
scripts/announcements.ts works out what is news and writes the sentences nothing — no disk, no network, no clock
scripts/slack.ts one chat.postMessage call the network
scripts/announce.ts the CLI that joins them up the disk, the config, the content

collectAnnouncements takes today, the ledger, the lifecycle and the locale as arguments and returns a list of messages. That is not fastidiousness: the question that decides whether this feature is usable is “would this run have sent fifty messages?”, and it can only be asked cheaply if the answer does not depend on the calendar or on a Slack workspace.

The rule everything else follows from: a streamline the ledger has never seen announces nothing. Its keys are recorded and the run moves on. A roadmap of any age holds dozens of dated things and most of them are in the future, so a lookback window cannot save a first run — only seeding can. It also makes bulk imports safe and turns a lost ledger into one quiet day rather than a burst.

The ledger is announced.json on the signpost-state branch. Keys are <streamlineId>#<subject>#<reason>, three parts so that a later pass can add deadline reminders (#t-30, #t-7) without re-keying anything that exists. The stored value is a fingerprint of what was true when the message went out, which is what produces “moved from X to Y” rather than a second “added”.

The ordering matters and is easy to get backwards. A run writes every key it intends to use into the ledger and pushes that before it posts anything. If the push fails, nothing has been said: a red workflow and a day’s delay. The other order risks saying something and then losing the record of having said it, which sends the same messages again tomorrow. The cost is that a message which then fails to post is already written down, so --send releases exactly those keys and the workflow pushes that correction under if: always().

Two things not to change without thinking them through. Messages name dates rather than states, because status and timeline are authored separately and a state claim can contradict the page it links to. And announce is z.boolean().optional() rather than defaulted to true, because Astro caches parsed content entries by file digest — a schema default never re-runs for an unchanged file, so a defaulted boolean is true for files somebody has edited and undefined for the rest.

To see what a run would say, without a token and without writing anything:

npm run announce -- --dry-run

Releases

.github/workflows/release.yml runs on a v* tag, takes that version’s section out of CHANGELOG.md, runs the full gate, and creates the GitHub Release with those notes as the body. Nothing generates the notes; a person wrote them as the changes landed.

scripts/changelog.ts is the parser, split the same way the announcer is: pure functions over the text of the file, a main() that reads from disk, and no third dependency. It is the piece that decides what the format is — ## [1.2.3] - 2026-09-21, the six Keep a Changelog categories, a link definition per heading, and an **Upgrading:** note in every section including Unreleased.

All of that is asserted in scripts/changelog.test.ts, against the real committed file, rather than as a step in ci.yml. The distinction is the same one the validator draws: a contributor who forgets a changelog line should hear that from a reviewer, because a CI gate on it would block a typo fix and teach people to write “no changelog needed” in every description. A contributor who breaks the format should hear it from the machine immediately, because the workflow that reads it runs once, on a tag nobody can take back.

To see a release exactly as it would be published:

npm run --silent release-notes -- v0.1.0

scripts/release.ts is the other half, and the one run on release day: npm run release -- 1.3.0 closes Unreleased under a dated heading, opens a fresh one, updates the link definitions, sets the version in package.json and package-lock.json, and commits them. It imports changelog.ts rather than repeating any of it, and holds its own output to changelogProblems before writing — producing a file the release workflow would reject is a bug here, and it says so in those words. Pushing and tagging are not automated, because the commit it makes is the last reversible step.

Who releases, when, and what the numbers promise a fork are in docs/releasing.md.

The far end: npm run update

scripts/update.ts is the only thing here written for somebody else’s repository. It fetches the template, works out which releases the fork has no section for, prints each one’s upgrade note, and then merges — report first, always, because the decision it exists to support is whether to merge at all, and that decision is worth nothing afterwards. It is the first thing that reads those notes back; until now they were written and never consumed.

“Behind” is membership, not arithmetic: a fork has a release when its changelog has that release’s section. Comparing the highest version on each side would be a guess about a history we cannot see, and it would tell a fork that cherry-picked, or forked mid-version, something confidently wrong.

The pure halves — releasesBehind and updateReport — are unit-tested in scripts/update.test.ts. The git is not, and is proven by running the thing in a scratch clone; anything that changes the refusals or the conflict advice should be run that way before it ships, because those sentences are read by somebody whose working tree is already in an unfamiliar state.


What not to hardcode

This repository is forked and rebranded. Every one of these has a helper or a config field, and reaching past it is the bug class that breaks somebody else’s instance without breaking yours.

Never write Use instead
A stage id ('deprecated', 'retired') siteConfig.lifecycle, and the terminal / windingDown flags for meaning
A category or impact id siteConfig.categories, siteConfig.impactLevels; getStage / getCategory / getImpact in taxonomy.ts
“the most severe impact level” Compute it from weight — see topWeight in ChangeRow.astro
A hex colour or a dark: colour class A token from global.css, or a tone via tone.ts
A leading-slash internal href url() from url.ts
https://github.com/... blobUrl() / profileUrl() from content.ts, which follow siteConfig.repository.url to a GitHub Enterprise host — the docs link rules are handed the same config for the same reason
A hardcoded window (60, 30, quarters) attentionWindowDays, attentionWeight, recentWindowDays, roadmapQuarters
A locale-specific date format The formatters in date.ts, which render in UTC at siteConfig.locale
A stage label in a sentence Build the sentence from stage.label — the validator’s messages are the worked example

When a rule needs to know what a stage means, ask the flag (terminal, windingDown), never the name. That is the difference between a fork that can rename its lifecycle and a fork that has to rewrite your code.


Opening a pull request

Run the full gate, keep the diff to one concern, and — if you changed anything a contributor reads when they get a file wrong — add the test that asserts the new wording. If the change is one a person merging would want to know about, add its line under ## [Unreleased] in CHANGELOG.md, and say in that section’s **Upgrading:** note what a fork has to do about it. CONTRIBUTING.md has the rest, and the Code of Conduct applies.