CI linting, dashboard and registry setup
Optional components
The skill is the one thing a project needs. Three components go beyond it, each optional, and each one the agent sets up the same way:
| Component | What it is | Set up by |
|---|---|---|
| The linter in CI | keep-the-why-lint checks the structure of context/ and .keep-the-why on every push |
a workflow, job or hook in the project — below |
| The dashboard on the project's site | keep-the-why-dashboard exported into the project's docs site or its own GitHub Pages workflow, with the live badge |
"The dashboard export", below |
| A listing in the registry | one line in Keep the Why's registry, so the project can be found from the globe of every published dashboard | "The registry", below |
Know, offer, set up on request. The agent knows how each one is set up, from this file. It offers one where it fits — the project wizard asks about the first two with the default no, and the registry is offered once, after the dashboard is published — and it sets one up only when the person says yes or asks for it, at setup or at any time later ("set up the linter in CI", "publish the dashboard", "list us in the registry"). Never on its own initiative, and a "defaults" answer to the wizard is not a yes to any of them. What the agent cannot do itself — a repository setting it has no access to — it names, with the place to do it.
The linter in CI
How the project init wizard (see setup.md) wires keep-the-why-lint into a project's CI, and optionally into pre-commit. The linter validates the structure of .keep-the-why and the context directory — required fields, valid values, index consistency, hidden-content red flags — gated by the project's context-schema. It never judges content; that stays a human call. Consumer-facing documentation, the version scheme, and every finding code: https://keepthewhy.com/linting/
Detect, don't assume
Decide what to offer from what the repository actually shows — the same rule as autostart.md: don't invent or fake a mechanism for a platform the evidence doesn't support.
| Evidence | Offer |
|---|---|
.github/ directory exists, or the origin remote host is github.com |
GitHub Actions workflow |
.gitlab-ci.yml exists, or the origin remote host contains gitlab |
GitLab CI job |
Another CI's config is recognizable (Jenkinsfile, .circleci/, azure-pipelines.yml, .woodpecker.yml, ...) |
The generic pip snippet, shown — not written: don't author config for a CI whose format can't be verified here |
| No CI evidence at all | Nothing to write; mention the generic snippet once in case CI comes later |
.pre-commit-config.yaml exists |
Also offer the pre-commit hook |
No .pre-commit-config.yaml |
Don't offer pre-commit — introducing a new tool into a project that doesn't use it isn't setup, it's a separate decision |
Before writing anything, check that nothing equivalent already exists (a workflow or job that already runs ktw-lint, or references keep-the-why) — if it does, say so and skip; don't add a second copy.
What gets written
- GitHub Actions:
.github/workflows/ktw-lint.yml, the snippet below verbatim. The root of thekeep-the-whyrepository is a composite action that installs the linter its own ref belongs to, referenced via the movinglint-latesttag — which follows linter publishes, not skill releases (the skill's ownlatesttag doesn't carry the action until the next skill release) — so the consumer never pins anything and gets the newest linter. A project that wants a fixed version uses the matchinglint-v<version>tag (or its commit SHA) instead, which pins action and linter together; theversioninput is only for mixing the two (a pinned ref with a rolling linter, or the reverse). - GitLab CI: the
ktw-lintjob below, appended to.gitlab-ci.yml. If the file definesstages:, give the job astage:from that list (testif present, otherwise ask which) — a job without a stage falls back totest, which fails the pipeline when custom stages don't include it. If there's no.gitlab-ci.ymlat all but the remote is GitLab, creating one with only this job makes it the project's first pipeline — say that plainly before doing it. - pre-commit: the hook below, added under an existing
repo: localentry if there is one, otherwise as a new one. The keep-the-why repository root is not a Python package, so the hook pulls the linter from PyPI viaadditional_dependenciesrather than pointingrepo:at the skill repository. strict: "false"/ no--strictby default: warnings (a missingTypeon an old entry, a missing guard file) are "next time touched" material per the skill's own rules and shouldn't block a fresh project's CI. Mention that--strictexists.
None of this is committed by the wizard — same as every other file setup writes (rule 7): staged in the working tree, committed when the user says so.
GitHub Actions — .github/workflows/ktw-lint.yml:
name: ktw-lint
on:
push:
branches: [main]
pull_request:
jobs:
ktw-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oliver-zehentleitner/keep-the-why@lint-latest # rolling; @lint-v<version> pins action and linter together
with:
path: "."
strict: "false" # "true" turns warnings (e.g. missing Type on old entries) into failures
# version: "latest" # only to mix: a pinned ref with a rolling linter, or vice versa — https://keepthewhy.com/linting/#versions-and-pinning
GitLab CI — job for .gitlab-ci.yml:
ktw-lint:
image: python:3.12
script:
- pip install keep-the-why-lint
- ktw-lint .
pre-commit — hook for .pre-commit-config.yaml:
repos:
- repo: local
hooks:
- id: ktw-lint
name: keep-the-why-lint
entry: ktw-lint .
language: python
additional_dependencies: ["keep-the-why-lint"]
pass_filenames: false
Any other CI, or locally:
pip install keep-the-why-lint
ktw-lint . # exit 0 clean, 1 findings, 2 usage error
ktw-lint . --strict # warnings fail too
ktw-lint . --setup # locally only: also ~/.keep-the-why/<id>.md and ~/.keep-the-why/config
The dashboard export
Offered by the project wizard when a docs build exists (a GitHub Pages workflow, mkdocs.yml, a docs/ deploy job) or the remote is on GitHub or GitLab, default no; set up when the person says yes or asks. With a docs build: one step in that build, after the site is generated and before it is uploaded — keep-the-why-dashboard reads the checkout and writes three static files, no server, no external request:
# Keep the Why: the project's own dashboard on /keep-the-why-dashboard/ — index.html,
# state.json and the live badges (badge-entries.svg, badge-entries-flat.svg),
# exported from this checkout. Needs the full
# history (fetch-depth: 0 on the checkout step) to date entries by commit.
- run: |
pip install --quiet keep-the-why-dashboard
ktw-dashboard --export site/keep-the-why-dashboard .
keep-the-why-dashboard/ is the export's directory under the site — named after what is there, so it cannot collide with a dashboard of the project's own; a project that publishes it somewhere else writes that path into dashboard-state and the badge instead. site/ is whatever directory the build uploads (site/ for MkDocs, _site/ for Jekyll, build/ for Sphinx — match the project's own). --anonymize replaces Git author names with author-1, author-2, … for a repository whose contributors did not ask to be listed on a web page. The live badge in the README then points at the project's own export:
[](https://example.org/keep-the-why-dashboard/)
with the site's URL (the docs site's address; for a Pages workflow of its own, derived as below), and dashboard-state: https://example.org/keep-the-why-dashboard/state.json in .keep-the-why — the line another dashboard reads, in its public mode, to show this project's export in place of a checkout (the family web across repositories, docs/dashboard.md). badge-entries.svg is rendered at export time with the project's numbers (and badge-entries-flat.svg shows the same in the flat style badge services draw) ("42 entries · 3 open" — open, needs-review and pending-confirmation count as open), so README → the project's own SVG → the project's own dashboard, and no badge service in between. Staged, not committed, like everything else setup writes.
No docs build, remote on GitHub: a workflow of its own publishes the export to the repository's GitHub Pages — .github/workflows/ktw-dashboard.yml, after checking that no workflow already deploys Pages (one that does gets the step above instead, never a second deploy):
name: ktw-dashboard
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0 # the dashboard dates entries by Git history
- uses: actions/setup-python@v7
with:
python-version: "3.12"
- run: |
pip install --quiet keep-the-why-dashboard
ktw-dashboard --export site/keep-the-why-dashboard .
- uses: actions/upload-pages-artifact@v5
with:
path: site
- id: deployment
uses: actions/deploy-pages@v5
branches: names the repository's default branch. The site is then https://<owner>.github.io/<repo>/, the dashboard <site>/keep-the-why-dashboard/. In the same change, write dashboard-state: <site>/keep-the-why-dashboard/state.json into .keep-the-why and offer the live badge with the same <site> — both derived from the remote now. The URL answers only after the first deploy; that is expected and no reason to wait. A custom domain changes only the host: when the repository's Pages already uses one (a CNAME, or the person names it), use that host instead. A workflow can only deploy once the repository's Pages source is GitHub Actions: tell the person to set it at https://github.com/<owner>/<repo>/settings/pages, under "Build and deployment" → "Source" → "GitHub Actions". If Pages already serves something from a branch, switching the source replaces that site — say so and ask instead of writing the workflow. Changing the setting is the person's; the agent does it (gh api -X POST repos/<owner>/<repo>/pages -f build_type=workflow, or -X PUT when Pages exists) only when asked to. The first deploy runs on the push after the workflow is merged.
No docs build, remote on GitLab: a pages job in .gitlab-ci.yml publishes the export to the project's GitLab Pages, after checking that no job already deploys Pages (one that does gets the export step instead, never a second pages job). If the file defines stages:, add deploy to it; with no .gitlab-ci.yml at all, this job would be the project's first pipeline — say that plainly before writing it:
# GitLab Pages: the Keep the Why dashboard on /keep-the-why-dashboard/ —
# index.html, state.json and the live badges, exported from this checkout.
# GIT_DEPTH 0: the dashboard dates entries by their Git history.
pages:
stage: deploy
image: python:3.12
variables:
GIT_DEPTH: 0
script:
- pip install --quiet keep-the-why-dashboard
- ktw-dashboard --export public/keep-the-why-dashboard .
artifacts:
paths:
- public
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
The site is then https://<namespace>.gitlab.io/<project>/ (a group's or user's namespace, subgroups as further path segments), the dashboard <site>/keep-the-why-dashboard/; write dashboard-state and offer the live badge from it, as above. Three settings on GitLab's side are the person's, and the agent names them: on gitlab.com, CI runs on the shared runners only once the account is verified (a pipeline otherwise fails with the user not being verified, before any job runs); the project's Pages visibility must be Everyone (Settings → General → Visibility, project features, permissions → Pages), or every visitor is sent to a GitLab sign-in; and when Use unique domain is on (Deploy → Pages), the site is served under a generated domain instead, which then is the <site> to use. A custom domain changes the host, as on GitHub.
The registry
The registry lists repositories with a published dashboard export; the dashboard's globe reads it, so a listed project can be found from any published dashboard, including from projects that cite nothing of it. Nothing needs it — the dashboard works without it, and the globe finds repositories by their citations anyway.
Offered once, never done unasked. After the dashboard is published — dashboard-state is on the default branch and the export answers at that URL — the agent may offer the listing in one sentence, once. It opens the pull request only when the person asks for it; an offer left unanswered is a no, and it is not repeated.
On request: a pull request against oliver-zehentleitner/keep-the-why that adds one line to registry/projects.txt — the project's canonical from .keep-the-why, nothing else. The usual way is a fork, a branch, the line, and the pull request (gh repo fork, then gh pr create); the agent says which account it opens it as before it does. A family is listed by its root alone, its members come with it. The registry's workflow then reads the project's .keep-the-why at HEAD, follows dashboard-state to the export, and checks that the export names this repository — a project whose dashboard-state is not merged yet, or whose export does not answer, fails that check, so the listing waits until the export is live.
The local run is a different setting
CI checks everyone's entries after the push, on a runner with no home files. The same linter run locally, after each write and — with --setup — over the developer's own two home files, is the personal local-lint setting: asked by the personal wizard, not the project one, because installing and running a tool is a per-developer, per-machine choice. Everything about it — when it runs, the version floor, how it gets installed, what to do with findings — is "Local linting" in setup.md.
Adding it to an existing project later
Nothing about this is tied to first-time setup: a project that declined, or was set up before the linter existed, adds the same files by hand or by asking the agent to — the detection table above applies just the same. There's no config field recording whether linting is set up; the workflow file's presence is the state.