Skip to content

CI & Distribution

After building your lexicon (see Package & Publish), you need to wire it into the monorepo’s CI pipeline, smoke tests, and publish workflow. The chant dev onboard command automates most of this.

Terminal window
chant dev onboard <name>

This patches 6 files (packages/core/src/cli/commands/onboard.ts:305):

FileWhat it adds
package.json (root)@intentius/chant-lexicon-<name>: "workspace:*" dependency
tsconfig.json (root)paths mappings for the package and its subpaths (chant #1614). The whole-repo typecheck runs moduleResolution: node, which never reads exports maps, so a lexicon without this entry fails the check job as soon as an example imports it
.github/workflows/chant.ymlPrepack lines in check/test jobs + validate step
.github/workflows/publish.ymlPrepack line in test job. No publish step is needed; scripts/publish-packages.sh enumerates the workspace
test/Dockerfile.smoke<name> in the for lex in ... lexicon list of the workspace smoke test
test/Dockerfile.smoke-npm<name> in both for lex in ... lexicon lists of the npm tarball smoke test

The command is idempotent. A second run changes nothing and reports each file as already covered.

  1. Create an example workspace

    npm workspace resolution requires at least one dependent for each package. Without an example, import("@intentius/chant-lexicon-<name>") will fail from packages/core.

    lexicons/<name>/examples/basic-deployment/
    ├── package.json
    └── src/
    └── infra.ts

    The package.json must depend on your lexicon:

    {
    "name": "<name>-basic-deployment",
    "version": "0.0.1",
    "type": "module",
    "private": true,
    "scripts": {
    "build": "chant build src --lexicon <name>",
    "lint": "chant lint src"
    },
    "dependencies": {
    "@intentius/chant": "workspace:*",
    "@intentius/chant-lexicon-<name>": "workspace:*"
    }
    }

    Add the example directory to the root package.json workspaces array:

    "workspaces": [
    "packages/*",
    "lexicons/*",
    "lexicons/<name>/examples/*"
    ]
  2. Add smoke tests to test/integration.sh

    Follow the pattern of the existing AWS/GitLab/K8s sections. Each lexicon’s smoke tests should cover:

    • Build a fixture project
    • Build with --output and --format yaml
    • Lint (with diagnostics check)
    • Lint with --format json
    • List entities
    • Doctor check
    • MCP server initialize + tools/list
    • LSP server initialize
    • chant init --lexicon <name> (skill install, build, lint)
  3. Run npm install to update workspace links

  4. Verify locally before pushing:

    Terminal window
    # Unit tests
    npx vitest run lexicons/<name>
    # Full test suite
    npx vitest run
    # Docker smoke tests
    just smoke

    The Docker smoke images (test/Dockerfile.smoke, test/Dockerfile.smoke-npm) and the per-lexicon sections in test/integration.sh run only locally. No CI job builds them. The smoke-npm job in .github/workflows/chant.yml is gated if: false on purpose (chant #1687), because a Docker build per push is too expensive. A new lexicon’s integration section therefore executes for the first time when you run just smoke by hand. Do that before the lexicon’s first release.

Releases go out through just release, which bumps every package in lockstep, commits, and pushes the tag the workflow actually listens for:

Terminal window
just release patch # or: minor / major

To move a single lexicon without a full release:

Terminal window
just release-lexicon <name> patch

The workflow triggers on chant-v* and lexicon-*-v* tags. A bare v<version> tag matches neither and silently does nothing.

Publishing itself is scripts/publish-packages.sh. It enumerates every non-private workspace package, so a new lexicon is picked up with no workflow edit. A package already at its version on the registry is skipped, which makes re-running the workflow a safe way to recover a partial release.

chant init --lexicon <name> generates a package.json depending on the core version it was run from (packages/core/src/cli/commands/init.ts:101):

"@intentius/chant": "^0.57.0",
"@intentius/chant-lexicon-<name>": "^0.57.0"

Under 0.x semver a caret does not float across minors: ^0.57.0 resolves to >=0.57.0 <0.58.0. A lexicon published at a different minor from core is unreachable from a scaffolded project. Every package in the monorepo is versioned together, which is what just release does; bump them in lockstep before tagging.

chant dev onboard adds your lexicon to the root package.json dependencies so that packages/core can dynamically import it:

import(`@intentius/chant-lexicon-${name}`)

Resolution does not actually depend on that entry. The root workspaces: ["lexicons/*"] glob symlinks every lexicon into node_modules whether or not it is listed, which is why three lexicons sat unlisted for months without anything breaking. The entry is an explicit declaration of what core may import, not the mechanism that makes the import work.

It is kept complete anyway, and packages/core/src/codegen/lexicon-wiring.test.ts fails if a lexicon is missing from it — a hand-maintained list with no gate is how this drifted in the first place.

Generated files (lexicons/<name>/src/generated/) are gitignored. When Docker builds from a clean checkout, these files don’t exist. The prepack script runs the generation pipeline to produce them before any tests or builds.

Generating a pipeline from the component graph

Section titled “Generating a pipeline from the component graph”

A CI-provider lexicon — gitlab, github, forgejo — can turn a discovered component graph into pipeline YAML, behind chant build --components --generate <name>. Core owns the generic half (discovery, dependency waves, job ordering) and dispatches to the lexicon for the emit:

plugin.ts
generateComponentPipeline: (components, options) => generateGitlabPipeline(components, options),

You receive DriverComponent[] plus an optional ComponentPipelineOptions (env, runCommand, extraScript, beforeScript, image, variables) and return a ComponentPipelineResult. That result carries the YAML alongside the wave-ordered stages, the jobs in emit order, and the env you defaulted to, so core can report what it generated without re-parsing it. All three types are in packages/core/src/lexicon.ts:441.

generateOpPipeline is the cron-triggered sibling for scheduled Ops (chant #927), with the same “CI providers only” rule. It receives ScheduledOpSpec[] rather than components, and each spec can carry three things the options block cannot, because they are per Op: setup, an ordered list of { uses, with?, env? } / { run, env? } steps emitted ahead of the beforeScript lines, permissions, a map your generator merges additively over whatever the finding mode already grants (chant #2242), and environment, a { name, url? } naming the forge deployment environment the job runs in (chant #2257). A provider with no equivalent degrades by name at build time rather than dropping the option: gitlab refuses a uses entry outright, and refuses every additive scope except id-token: write, which it maps onto GitLab’s own id_tokens: declaration (chant #2256); forgejo emits the steps and drops the permissions the way it already drops the mode’s own.

environment is the one of the three where a silent drop would be worst, because the option exists so a human can hold a job. A provider without environments therefore both drops the key and says so in the file it generates; forgejo writes a header comment naming the environment and stating that nothing on the forge enforces it. GitLab does have environments, and maps the option onto its own environment: key. If your provider has the concept, emit it and let the protection rules stay where they live, in the provider’s own configuration. If it does not, be loud.

Each job is a thin trigger: it runs the component’s deploy command. The deploy logic stays in the component, not in the generated YAML — the whole point is that a cross-cutting change is one edit to the generator rather than an edit per pipeline file.

Omit this member entirely unless your lexicon is a CI provider. It is the only member with a hard “this kind of lexicon only” rule.

chant dev onboard <name> Patch CI, Dockerfiles, and workflows for a new lexicon

The command takes no meaningful flags. --verbose parses but changes nothing today. After patching, it prints the remaining manual steps and then runs chant dev check-lexicon against lexicons/<name> if that directory exists, so you see the completeness report without a second command.