Running it in CI¶
Two commands check a corpus against its schema on every pull request, so a broken cross-reference fails CI. A corpus is one repository of knowledge records kept in git, and a record is one Markdown document in it, filed under a type. Every command here runs from inside the corpus.
A corpus that ships an agent plugin runs two more, so a change that breaks either one fails the pull request too. The plugin is the corpus packaged so an AI agent can install it, and the export is the corpus written out as data for that packaging to read.
| Command | Fails when |
|---|---|
kac validate |
a record breaks a check the schema declares |
kac generate --check |
a generated file no longer matches the records and schema it was built from |
kac export |
the corpus can no longer be written out as data |
kac bundle |
that export can no longer be assembled into a plugin |
An error fails the build. A warning and an info are printed and leave the exit code alone. Under GitHub Actions or Azure
Pipelines, kac validate also writes each error and warning as an annotation. Where the pipeline gives it a token that
can write to the pull request, it posts them there as a comment too.
A pull request comment says what each platform needs.
What the checks ask says what they cover.
Pin the tool first¶
Run the version the corpus was written against. CI restores kac from the tool manifest the corpus commits, so every
machine runs the same version:
dotnet tool restore # the kac that .config/dotnet-tools.json pins
dotnet tool run kac validate
kac new writes .config/dotnet-tools.json at the version of the kac that ran it:
{
"version": 1,
"isRoot": true,
"tools": {
"knowledgeascode.tool": {
"version": "0.43.0",
"commands": [
"kac"
],
"rollForward": false
}
}
}
A corpus without one writes it once, on your own machine, and commits it:
dotnet new tool-manifest
dotnet tool install KnowledgeAsCode.Tool
dotnet tool update KnowledgeAsCode.Tool moves the pin. On GitHub, Dependabot's nuget ecosystem reads the manifest
and proposes each new kac as a pull request.
The pull request comment¶
kac validate posts its findings to the pull request as a comment where the step has a token that can write to it.
Give the token only to the step running validate, and only once kac is pinned, so a job with a write permission runs
no release you did not choose. The examples below pass it, and so do the starter pipelines kac new writes.
A pull request comment lists the token and the permission each platform
needs.
Read-only permission¶
CI never commits. generate --check recomputes every generated file, lists the ones that differ, and exits 1. It
writes nothing. Give the job contents: read, so it cannot commit either. Run kac generate on your own machine and
commit what it writes.
generated files are stale. These differ from the schema/frontmatter:
glossary/_index.md
run: kac generate
Checking out with git¶
Check out with git, not a tarball. kac lists a corpus with git ls-files, so .gitignore and the other exclude files
count. A working tree with no .git/ falls back to a directory walk, which obeys none of them.
Check out the full history as well. On a pull request, kac validate --since compares each record with the branch the
pull request targets, and a shallow clone has no copy of that branch. Set fetch-depth: 0 in GitHub Actions and
fetchDepth: 0 in Azure Pipelines, as both examples below do. A push to main has no base branch, so it passes no
--since. Comparing with a base revision says what the comparison
checks.
GitHub Actions¶
This is the .github/workflows/kac.yml that kac new --ci github writes, with most of its comments cut:
name: kac
on:
pull_request:
branches: [main]
push:
branches: [main]
permissions:
contents: read
jobs:
validate:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write # lets `kac validate` post its findings
packages: read # lets `kac` read a private GitHub Packages feed
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: 10.0.402
- name: Install the pinned kac
run: dotnet tool restore
- name: Restore the corpora this one consumes
run: dotnet tool run kac restore
env:
KAC_REGISTRY_TOKEN: ${{ github.token }}
# On a pull request, compares each record with the branch it targets.
- name: Validate frontmatter, links and structure
run: dotnet tool run kac validate ${BASE_REF:+--since "origin/$BASE_REF"}
env:
GITHUB_TOKEN: ${{ github.token }}
KAC_REGISTRY_TOKEN: ${{ github.token }}
BASE_REF: ${{ github.base_ref }}
- name: Check generated output is fresh
run: dotnet tool run kac generate --check
# Delete both steps if this corpus publishes no plugin.
- name: Build the export
run: dotnet tool run kac export
- name: Assemble the plugin bundle
run: dotnet tool run kac bundle
Each action is pinned to a commit, because a tag can be moved to other code. The .github/dependabot.yml that
kac new writes moves each pin and the version comment beside it, under github-actions. Under nuget it moves the
kac version that .config/dotnet-tools.json pins. It proposes a release only once it is seven days old, so a
compromised release has time to be withdrawn. The checkout sets persist-credentials: false, so no later step can push
with the job's token.
The SDK is pinned to one release, because the job has a write permission. Any .NET 10 SDK runs kac, so the pin picks
a release and sets no minimum. Dependabot does not read dotnet-version, so move it by hand.
Azure Pipelines¶
This is the azure-pipelines.yml that kac new --ci azure-devops writes, with most of its comments cut:
trigger: none # no CI on branch pushes; PR validation is via branch policy
pr:
branches:
include:
- main
pool:
vmImage: ubuntu-latest
variables:
DOTNET_NOLOGO: 'true'
DOTNET_CLI_TELEMETRY_OPTOUT: 'true'
steps:
- checkout: self
fetchDepth: 0
- task: UseDotNet@2
displayName: Install the .NET 10 SDK
inputs:
packageType: sdk
version: 10.0.402
- script: dotnet tool restore
displayName: Install the pinned kac
workingDirectory: $(Build.SourcesDirectory)
- script: dotnet tool run kac restore
displayName: Restore the corpora this one consumes
workingDirectory: $(Build.SourcesDirectory)
env:
KAC_REGISTRY_TOKEN: $(System.AccessToken)
# Posting the findings needs the build service to have Contribute to pull requests. The target branch
# is set on a pull request alone, as refs/heads/main, and the comparison wants origin/main.
- script: |
target="${SYSTEM_PULLREQUEST_TARGETBRANCH#refs/heads/}"
dotnet tool run kac validate ${target:+--since "origin/$target"}
displayName: Validate frontmatter, links and structure
workingDirectory: $(Build.SourcesDirectory)
env:
SYSTEM_ACCESSTOKEN: $(System.AccessToken)
KAC_REGISTRY_TOKEN: $(System.AccessToken)
- script: dotnet tool run kac generate --check
displayName: Check generated output is fresh
workingDirectory: $(Build.SourcesDirectory)
# Delete both steps if this corpus publishes no plugin.
- script: dotnet tool run kac export
displayName: Build the export
workingDirectory: $(Build.SourcesDirectory)
- script: dotnet tool run kac bundle
displayName: Assemble the plugin bundle
workingDirectory: $(Build.SourcesDirectory)
Azure Repos ignores the pr: trigger in this file
Wire the pipeline up as a branch policy, or it never runs on a pull request and nothing says so.
Project settings, then Repositories, then your repository, then Policies, then Branch Policies, then main, then
Build Validation, then +. Select this pipeline and mark it Required.
The pr: block states the intent, and it works as written if the repository is ever mirrored to GitHub.
Restoring what a corpus consumes¶
A corpus that reads another corpus's records declares them in consumes:, and fetches them before anything is
validated:
dotnet tool run kac restore # each consumed corpus, into .imports/<shortcode>/
.imports/ is not committed, so run restore as the first step of the job.
A private feed needs a token, which kac reads from KAC_REGISTRY_TOKEN. Set it on the restore step and on the
validate step, because validate asks the feed whether each lock in consumes: is current. Without the token,
validate reports import-unreachable and leaves the lock unchecked.
- GitHub Actions. Pass
${{ github.token }}, and give the jobpackages: read. The token reads a GitHub Packages package linked to this repository. For a package linked to another repository, add this repository to the package's settings, in Manage Actions access, with the Read role. - Azure Pipelines. Map
$(System.AccessToken)onto each step, because a script sees the token only where its step maps it. The token belongs to the project's build service, which reads an Azure Artifacts feed in the same project. For a feed in another project, add that identity to the feed as a Feed Reader, under the feed's permissions.
A source: naming a folder needs no token. Run export and pack in the producer before this step.
Restore before you validate. A declared import that is not on disk fails validate. Where
consumes: is empty, restore prints that it has nothing to restore and succeeds. The starter pipelines kac new
writes run it on every corpus.
Building the plugin¶
A corpus that publishes an agent plugin runs two more commands, in this order:
dotnet tool run kac export # the corpus as data, into .dist/export/
dotnet tool run kac bundle # that export plus .plugin/, into .dist/plugin/
Each replaces its own directory under .dist/, which the corpus's .gitignore lists. Running them proves the corpus
still exports and still assembles, and publishes nothing.
bundle validates nothing it assembles, so validate both the plugin and the marketplace above it. The
Claude Code CLI has to be on the runner first:
npm install -g @anthropic-ai/claude-code
claude plugin validate ./.dist/plugin --strict
claude plugin validate ./.dist --strict
Installing a corpus plugin says how a reader installs the plugin from the marketplace you publish.
Publishing the corpus as a package¶
A corpus another corpus consumes runs one more command, and it reads the same export:
dotnet tool run kac pack # the export, sealed into .dist/package/
pack writes a .nupkg, which GitHub Packages and Azure DevOps Artifacts both store. Run pack in
the gate. Push it from a separate job, which has the credential the gate does not.
A registry refuses a version it already has, so a push fails where somebody forgot to move content-version:
VERSION=$(jq -r '.contentVersion' .dist/export/manifest.json)
ID=$(jq -r '.corpus' .dist/export/manifest.json)
dotnet nuget push ".dist/package/$ID.$VERSION.nupkg" \
--source "https://nuget.pkg.github.com/OWNER/index.json" \
--api-key "$GITHUB_TOKEN"
The job needs packages: write in GitHub Actions. A re-run whose version has not moved fails on the push.
publish-corpus.yml
asks the registry what it has first, and skips a version already there.
What the checks ask¶
Every check reads the corpus, and none reads the estate the corpus describes. A service deleted last month still validates cleanly, so a green build says the corpus is consistent, not that it is right.
validate asks four questions of each record:
- Does it parse, and does it declare itself as its type and folder say?
- Does every reference resolve, and does each reciprocal pair agree in both directions?
- Does it do what its tier asks, such as citing a source or stating when it was last rehearsed?
- Is it safe to publish, with nothing reading as a credential or as real data?
kac checks lists every check the validator can report, and Checks says where each one comes
from. Discovery says which files are records, and types: in
.corpus.yaml bounds both validation and generation.