Skip to content
Signpost

Guides

Adopting Signpost

20 min readRead it on GitHub

A guide to forking this repository and running it for your own organization, on github.com or GitHub Enterprise. Allow an hour, most of which is writing your first few streamlines.

Nothing company-specific lives outside two places:

site.config.ts Your name, branding, lifecycle, categories, impact levels
content/ Your teams and your streamlines

Everything else is the product. If you find yourself editing a component to change a label or a colour, something has gone wrong — open an issue upstream.


1. Fork and run it

git clone https://github.com/<your-org>/<your-fork>.git
cd <your-fork>
nvm use            # the Node version is pinned in .nvmrc
npm install
npm run dev        # http://localhost:4321

GitHub names a fork after the repository it came from, so <your-fork> is usually signpost — but rename it if roadmap or platform-signpost is what your organization will search for. Nothing in the project reads its own directory or repository name: the URL is repository.url in step 2, and the deployed path comes from Pages in step 5.

You now have an empty site. It builds, every page renders, and every page tells you what to add — no teams, no streamlines, nothing on the roadmap. That is the intended starting point, and you fill it in step 4.

To see a populated one while you configure, the sample instance is at anonycoders.github.io/signpost-sample, from Anonycoders/signpost-sample — this same site with invented content left in: four platform teams, thirteen streamlines covering every lifecycle stage. Keeping it open in a tab, or cloned alongside, makes it much easier to see what a setting does.

Node. .nvmrc pins the major version and package.json sets an engines floor; .npmrc turns on engine-strict so an old Node fails the install with an explanation instead of a stack trace. CI reads the same .nvmrc, so there is one version to bump.


2. Configure the site

Open site.config.ts. It is commented throughout; this is the tour.

Identity

name: 'Signpost',
tagline: 'What the platform teams are building, and what changes next.',
description: 'A shared, always-current view of every platform streamline…',
organization: 'Example Organization',

name appears in the header, page titles and feeds. tagline is the home page headline. description is the hero paragraph and the default meta description.

Repository and contact

repository: {
  url: 'https://ghe.example.com/platform/signpost',  // no trailing slash
  branch: 'main',
},
contact: {
  label: '#platform-questions',
  url: 'https://ghe.example.com/platform/signpost/issues',
},

repository builds every Edit this page link, so point it at wherever this repository actually lives — GitHub Enterprise URLs work exactly the same as github.com ones. contact is the footer link for people whose question the site cannot answer; a chat channel URL is a good choice.

The two are independent, and it is normal for them to point somewhere different from each other. The sample instance is the worked example: its repository is its own repository, so Edit this page opens the file the reader is actually looking at, while its contact is left pointing at this project’s issue tracker, because a question about how Signpost behaves belongs upstream rather than in a site full of invented teams. Set repository to where your content lives, and contact to wherever the person asking will get an answer.

Slack workspace

slackWorkspaceUrl: 'https://acmeco.slack.com',
// Enterprise Grid: 'https://acmeorg.enterprise.slack.com'

Optional, and the only setting here that changes what a link does rather than what it says. Leave it out and nothing breaks: an owner’s Slack handle is still printed beside their name, it simply is not clickable.

Set it and any owner carrying a slackId gets a handle that links straight to them in Slack. Both halves are required because Slack will not resolve a display name: this setting says which workspace, the member ID in the content file says which person, and neither is an address on its own. Contributors are told how to find a member ID in CONTRIBUTING.md; they only have to do it once per person.

Set this before you ask anyone for IDs, not after. The validator warns when IDs have been collected into content that has nowhere to point them, which is a courtesy rather than a substitute for doing it in the right order.

Locale

locale: 'en-GB',

Any BCP 47 tag your runtime’s Intl supports. It controls date formatting only. Dates are always rendered in UTC, deliberately: a reader in Bangalore and a reader in Denver see the day the author wrote, not one shifted by their offset.

Lifecycle

The six default stages are a general-purpose platform lifecycle:

proposed → in-development → rolling-out → generally-available → deprecated → retired

Rename them, reorder them, add or remove them. Order in the array is the lifecycle order — it drives the stage stepper, the roadmap bars and the validator’s chronology check. Two flags carry meaning:

Flag Meaning
terminal: true The work is finished and no longer changing. Faded on the roadmap, excluded from “in flight” counts, and the target of the retirement rule
windingDown: true The thing is going away. Anything in this stage must carry a date for a terminal stage, or the build fails

Those two flags are how the anti-surprise rule survives renaming. Call your stages sunsetting and switched-off and the validator will say “A sunsetting streamline must say when it will be switched off” — no code to touch. A lifecycle with a windingDown stage needs at least one terminal stage for it to point at; the validator says so if you forget.

Categories and impact levels

categories: [
  { id: 'developer-experience', label: 'Developer Experience', tone: 'violet' },
  …
],

Categories are yours to define — they are only a filter and a badge. Changing an id means updating every content file that uses it, so pick them before you write much content.

Impact levels carry one extra field:

{ id: 'breaking', label: 'Breaking', description: '…', tone: 'red', weight: 30 },

weight is how the site decides what is worth interrupting someone for. Anything at or above attentionWeight reaches the home page strip and the summary line on streamline cards.

After renaming any of those, run npm run schema

npm run schema     # rewrites schemas/, then commit it

schemas/ is what a contributor’s editor reads to offer them field names and stage names as they type, and it is generated from the three lists above. Regenerate it and commit the result whenever you change them, or everyone writing content keeps being offered a stage that no longer exists. CI checks this for you: the --check step in ci.yml fails on a stale schemas/ and tells you the command to run.

Tuning the noise

attentionWindowDays: 60,   // how far ahead "Needs your attention" looks
attentionWeight: 20,       // minimum impact weight to appear there
recentWindowDays: 30,      // how far back "Recently changed" looks
roadmapQuarters: { past: 1, future: 4 },

With the default impact levels, attentionWeight: 20 means breaking and action-required; raise it to 30 for breaking changes only. If people start ignoring the home page, this is the dial to turn.

Colours

Every stage, category and impact level picks a tone from a fixed palette: gray, blue, violet, green, amber, red, teal, pink. Each tone is defined for light and dark themes in src/styles/global.css, so badges stay legible in both without anyone hand-picking hex codes. The accent colour, surfaces and text tokens live in the same file, under @theme.

To rebrand: change --color-accent* in @theme (and its dark counterpart in the @media screen block below), and replace public/favicon.svg.


3. Wire up ownership

.github/CODEOWNERS is what makes this scale to a dozen teams without a bottleneck. Each team owns its own content directory, so teams merge their own updates and nobody waits on a central reviewer:

/content/streamlines/devops/              @your-org/devops
/content/teams/devops.yaml                @your-org/devops

The shipped file is a skeleton: the pattern is there, but every rule is commented out and every handle is a placeholder, because a handle that does not resolve in your organization silently requires nobody. Uncomment the lines you want and put your own team handles in — GitHub Enterprise handles work the same way. Then, in Settings → Branches, protect main with Require a pull request before merging and Require review from Code Owners. Without branch protection CODEOWNERS is only a suggestion.

Keep the code itself owned by whoever maintains the site. That is the catch-all at the top of the file, and it goes first precisely because the last matching rule wins: a change to src/ needs the platform team, while a change to content/streamlines/devops/ needs only DevOps.


4. Add your first team and streamline

content/ arrives empty — content/teams/ and content/streamlines/ hold nothing but a .gitkeep so that Git carries the directories at all. Add one YAML file per team and one per streamline, exactly as CONTRIBUTING.md describes — that guide is written for your colleagues, and it is the same process for you.

Until you do, nothing is broken: every page falls back to an empty state that tells the reader what to add, the feeds are valid with no entries in them, and the validator passes with a single warning that no teams are defined yet. That is deliberate, so a fresh fork is a usable starting point rather than a wall of zeroes or a stack of errors. Start with one team file — the warning goes, the team page appears, and the shape of the thing becomes obvious.

npm run validate   # content rules
npm run build      # validate + full build

Start small. Three streamlines that are accurate beat twenty that are six months stale. The validator warns about anything active with no update for six months, which is the feedback loop that keeps a fork honest.


5. Deploy to Pages

The repository ships three workflows:

Workflow Runs on Does
ci.yml every pull request validate → schema → type-check → test → build
deploy.yml push to main, nightly, manual build → upload → deploy to Pages
announce.yml a schedule, manual post what changed to Slack — inert unless configured

On github.com, and on GitHub Enterprise with Actions and Pages enabled:

  1. Settings → Pages → Build and deployment → Source: GitHub Actions.
  2. Push to main.
  3. Watch the Deploy workflow. It publishes to the URL shown on the github-pages environment.

That is the whole setup. There is nothing to provision and no runtime to keep patched. The one thing that ever needs a secret is announcing changes in Slack, which is optional and described below; publishing the site itself needs none.

URLs and the base path

You do not hardcode your URL anywhere. actions/configure-pages asks GitHub where the site is about to be published, and the deploy workflow hands that straight to the build as two environment variables:

Variable Comes from
SITE_URL the Pages origin the action reports
BASE_PATH the Pages base path the action reports

deploy.yml is the authoritative copy of that wiring, including which version of the action it pins — read it there rather than trusting a snippet in a guide that nobody bumps.

astro.config.mjs reads both, falling back to http://localhost:4321 and / locally. This is what makes the same build work at a domain root (https://signpost.example.com/) and under a project subpath (https://pages.ghe.example.com/platform/signpost/) without a config change.

Every internal link goes through a helper that prefixes the base path, so a subpath deployment does not produce a site full of 404s. If you add pages, use url('/somewhere') from src/lib/url.ts rather than writing hrefs by hand.

For a custom domain, set it in Settings → Pages as usual; configure-pages picks it up and the build follows.

The nightly rebuild

schedule:
  - cron: '17 3 * * *'

“Today” is baked in at build time — the relative dates (“in 3 weeks”), the today marker on the roadmap, and the line between Still to come and Recently changed are all computed when the site is generated. A repository nobody merges to for a fortnight would otherwise serve a fortnight-old idea of now, and the roadmap’s today line would drift quietly backwards. The nightly run keeps the site honest without anyone doing anything.

Move the time if it collides with something; keep the job.

Announcing changes in Slack

Everything above publishes a site. A site can only tell people what changed if they come and look, and the thing this project exists to prevent — somebody finding out about a shutdown after it happened — still happens to everyone who does not visit.

The site publishes an Atom feed at /feed.xml, and one per team at /teams/<slug>/feed.xml, and most chat platforms can subscribe to a feed directly. If that is enough for you, it costs nothing to maintain and you can stop reading here. Be clear about what it is, though: the feed carries updates only. It says nothing when a streamline reaches a stage, nothing when a phase date moves, and nothing ahead of a retirement date. It has no per-team routing beyond one feed per team, and everybody in the channel gets everything.

The alternative is announce.yml, a scheduled job that reads the content, works out what has changed since it last looked, and posts it to the channels you name. It is off until you configure it.

1. Make a Slack app and let it post. At https://api.slack.com/apps, create an app, add the chat:write bot scope under OAuth & Permissions, install it to your workspace, and copy the bot token — it starts xoxb-.

2. Invite it to the channels. Type /invite @YourAppName in each channel you are going to name below. A bot can post only where it has been invited, and not_in_channel is the one failure everybody hits first.

3. Add the token as a repository secret named SLACK_BOT_TOKEN, under Settings → Secrets and variables → Actions. It never goes in site.config.ts.

4. Turn it on in site.config.ts:

announcements: {
  siteUrl: 'https://acmeco.github.io/signpost',
  channel: '#platform-news',
},

siteUrl is the only required field, and it is written out rather than derived because the announcer is a plain Node script — there is no Astro build around it to ask where the site lives. Every message links back here, so getting it wrong produces announcements nobody can act on. channel is the fallback; a team or a single streamline can name its own, and npm run validate tells you about any streamline that would have nowhere to go. Two more optional fields, lookbackDays and maxPerRun, are documented on the type itself.

The first run says nothing. A roadmap with any history in it holds dozens of dated things, most of them in the future, and a job that announced what it found on day one would arrive as a burst that gets the channel muted for good. So a streamline the job has never seen is recorded silently, and only what changes after that is announced. The same rule makes bulk imports safe: add fifty streamlines in one pull request and the channel stays quiet.

What it remembers, and where. A file called announced.json on a branch named signpost-state, which holds nothing else and is never merged into main. Keeping it off main is deliberate: the job never touches content/, never opens a pull request, and never triggers a rebuild of the site. The branch is created on the first run; you do not need to make it.

Messages name dates, not states. “Retirement moved from 31 March 2027 to 30 June 2027”, never “this is now deprecated”. A streamline’s status and its timeline are written by hand and separately, so a message claiming a state can contradict the page it links to. A date cannot, and a date is what a reader has to plan around.

Before you turn it on for real, see what it would say:

npm run announce -- --dry-run

That prints the messages and writes nothing — no ledger, no posts. It needs no token.

One caveat about the schedule. GitHub disables scheduled workflows on a public repository after 60 days with no activity in it. A roadmap that nobody has touched for two months is exactly the kind that is holding a retirement date somebody has forgotten, so this is worth knowing: if your instance is public and quiet, re-enable the workflow from the Actions tab, or run it by hand from there. Internal repositories and GitHub Enterprise are not affected.

Per-streamline controls — sending one streamline somewhere else, keeping one quiet, or overriding the text of a single announcement — are in CONTRIBUTING.md, because they live in the content files rather than here.


6. If your GHE has Pages disabled

Plenty of Enterprise instances do. The build is a directory of static files with no server-side anything, so it will run on whatever you already have.

Option A — publish the artifact somewhere. Replace the deploy job with a step that ships dist/ to your host. The build job needs no changes beyond setting the URL yourself:

- name: Build site
  run: npm run build
  env:
    SITE_URL: https://signpost.example.com
    BASE_PATH: /

S3 + CloudFront, Azure Blob static hosting, Netlify, an Nginx container, a shared web server — any of them work. Upload the contents of dist/.

Option B — download it. Keep CI, drop the deploy job, and add:

- uses: actions/upload-artifact@v4
  with:
    name: site
    path: dist

Anyone can then download the build from the Actions run and open it. Less convenient, but it gets a fork off the ground on day one while you argue with whoever owns hosting.

Two things to keep whichever route you take: build on a schedule, for the reason above, and set SITE_URL correctly, because it is what the Atom feeds use to build absolute entry links.


7. Optional adjustments

Issue templates. .github/ISSUE_TEMPLATE/ uses YAML issue forms. GitHub Enterprise supports those from 3.4; on anything older, replace each .yml with a plain Markdown template — same directory, same purpose, slightly less structure.

The fonts. Inter is vendored through @fontsource-variable/inter, so nothing is fetched from a third party at runtime — which also means the site works on an air-gapped Enterprise instance. To change it, swap the package and the --font-sans token in global.css.

Search. Filtering and search on the catalog are plain client-side JavaScript over server-rendered cards, with no index to build. That holds comfortably into the hundreds of streamlines. Past that, look at Pagefind.

Analytics. There is none, and adding a tag to BaseLayout.astro takes a line. Consider whether you want it: an internal roadmap that logs who read which deprecation is a different product from one that does not.


Taking updates

A fork is a copy, so an update is a merge. One command does it:

npm run update -- --dry-run

That fetches the template and says what merging would bring: how many commits, across how many files, which releases you are behind, and — the part worth reading — the upgrade note each of those releases carries. Somebody wrote those notes at the moment they made the change, while they still knew what it would cost you. Drop --dry-run and the same report is followed by the merge.

Mind the run. npm update without it is npm’s own command and updates your dependencies, which is a different thing entirely.

If your fork predates the command, that will fail with Missing script: "update", because the command arrives in the very update you are trying to take. Do the first merge by hand and it brings itself with it:

git remote add template https://github.com/Anonycoders/signpost
git fetch template
git merge template/main

Skip the first line if you already have the remote, and point it at whichever repository you actually forked if that was not Signpost itself. Read the **Upgrading:** notes in the template’s CHANGELOG.md before you merge rather than after — that is the one time you have to find them yourself. From the next update on, the command does it for you.

It refuses to start if you have uncommitted changes, because git merge --abort puts back the merge and not work that was never committed. It never pushes: the merge is one local commit, git reset --hard ORIG_HEAD undoes it, and what to run next is printed when it finishes.

Where it fetches from is a git remote named template, or — if you have not got one — the template field in site.config.ts, which it then adds for you. A direct fork of Signpost leaves that field as it is; a fork of a fork points it at whichever repository it actually forked. If you have set the remote yourself and it disagrees with the config, the remote wins and the command says so rather than repointing it behind your back.

On a conflict it stops and names the files. Two are worth expecting:

  • site.config.ts, the likeliest, because it is the file you edit and the file new options arrive in. Read both sides rather than keeping yours wholesale: keeping yours silently drops whatever field the release just added, and no upgrade note can warn you about that, because upstream does not know which lines you changed.
  • Anything under content/, which means the template has started shipping a file where you keep your own. Your content is yours.

What you are signing up for

Signpost has no backend, no database and no scheduled maintenance. The running cost is keeping the dependencies current — Astro, Tailwind and a handful of small libraries — which is a Dependabot PR and a CI run.

The real cost is cultural, and it is the one that decides whether this works: teams have to post. The site makes it cheap (one file, one PR, your own team’s approval) and it makes staleness visible (the validator warns, the roadmap shows the gap). It cannot make anybody care. The organizations where this works are the ones where “did you put it on Signpost?” becomes a normal question in a planning meeting.

Where this is going

Today Signpost is a template. You fork it, you own the whole thing, and you take updates by merging — Taking updates, above. That model earns its place at this size: everything is visible and hackable, there is nothing to publish and no registry to depend on, and a fork can go its own way whenever it wants to without asking anybody. What it costs you is the merges, which is why a release carries a version number and an upgrade note rather than a list of commits.

The direction for 1.0 is the other model — the engine as a dependency. A Signpost repository would then hold content/, site.config.ts and the workflows, and take the rest from a package, the way a MkDocs or Docusaurus site takes its theme. Updating would be npm update and reading a changelog rather than a merge. No date is being promised here, and nothing about the way it works today is deprecated by saying it.

What that means for you now is the line this guide opens with, and it is worth taking literally: edit content/ and site.config.ts, and leave the rest alone. That boundary is not housekeeping — it is the interface a package would have to keep. A fork that has stayed inside it can be handed a dependency one day and lose nothing. A fork that has edited a component to change a label has to unpick that first, and no upgrade note will ever mention it, because nobody upstream knows it happened.

Getting help

Open an issue upstream. If you have adapted it in a way others would benefit from — a different lifecycle model, a deployment target we have not covered — a pull request to this guide is very welcome.