Skip to content

CI & Distribution

llms.txt lists every page for an agent
Optional: hand this page to your coding agentThe steps work by hand too.
Show the whole prompt
Read https://intentius.io/chant/lexicon-authoring/ci-integration/.
In the chant repository, run `npx tsx packages/core/src/cli/main.ts dev onboard <name>` for the lexicon I name, then `npm install`. Do the manual steps the page lists after onboard, including an example that imports the lexicon.
Run `npx vitest run lexicons/<name>` and `npx vitest run --project unit`, and open a pull request with the result.
Leave `just smoke`, `just release`, `just release-lexicon` and the first npm publish to me; list them in the pull request description.
Never deploy to a real cloud account or shared environment (with `chant run`, `chant components` or a platform's own CLI), approve (a pull request review, `chant approve` or `chant run approve`), override a policy denial (`chant approve policy-override`), or merge; never cut or publish a release (`chant release cut --yes` or `chant release publish`), push or delete a `ci/*` tag (`chant ci tick` pushes them), or change the value of an existing flag; never touch `.chant/allowed_signers` or `chant/lifecycle`.

After building your lexicon (see Package & Publish), wire it into the monorepo’s CI, smoke tests and publish workflow. chant dev onboard does most of this.

Terminal window
chant dev onboard <name>

This patches up to six files (packages/core/src/cli/commands/onboard.ts):

FileWhat it adds
package.json (root)@intentius/chant-lexicon-<name>: "workspace:*" dependency
tsconfig.json (root)paths mappings for the package and its subpaths, written ./lexicons/<name>/... like the existing entries (chant #1614, #3227). 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. With no baseUrl, a path without the leading ./ makes tsx’s tsconfig loader throw. scripts/tsconfig-lexicon-paths.test.ts fails when a lexicon directory has no entry or an entry names a removed one, and npm run generate:tsconfig-paths rewrites the block (chant #2376)
.github/workflows/chant.ymlPrepack lines in any check/test run: block that lists them, and a validate step
test/Dockerfile.smoke<name> in the for lex in ... lexicon list of the workspace smoke test
test/smoke-npm-lexicons.txt<name> on its own line. The npm tarball smoke (test/Dockerfile.smoke-npm) and smoke.sh both read this list

Onboard leaves some files alone and names them in its “Not patched” output:

  • .github/workflows/publish.yml has no per-lexicon lines to patch. scripts/ci-lexicon-artifacts.sh builds every workspace lexicon and scripts/publish-packages.sh publishes them, so a new lexicon needs no edit.
  • test/Dockerfile.smoke-npm reads test/smoke-npm-lexicons.txt, which is the file onboard patches.
  • test/smoke-e2e-lexicons.txt is opt-in. It lists the lexicons an e2e example uses and the lexicons they depend on, so add yours there by hand when an example needs it.

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>
    # The unit suite, the one a change merges on
    npx vitest run --project unit
    # 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 never run on a push or a pull request (chant #1687), because a Docker build per change costs more than it catches. They run only when a person starts them: just smoke at a terminal, which asks you to type run, or the smoke-npm job of .github/workflows/large-suites.yml, a workflow_dispatch workflow. A new lexicon’s integration section therefore executes for the first time when someone runs just smoke. Do that before the lexicon’s first release.

Releases go out through just release, which cuts chant with its own release command (#4040):

Terminal window
just release patch # or: minor / major
just release minor --dry-run # run the cut without --yes, change nothing
just release patch <commit> # release a named commit on main

The recipe picks the newest green commit on main, not main’s HEAD, and runs chant release cut <version> --at <commit> from release/, the workspace member npm. No version-bump commit is made: the version is the release’s label, one past the highest version in any package.json at the commit or in a chant-v* or release/npm/* tag. The cut pushes chant/lifecycle, and the recipe pushes the release/npm/<version> tag.

just release-when-green runs the steps around it (#4218). For main’s head, or a named commit, it starts each required ci.green phase that has no check run on the commit (release-suites.yml, and chant.yml when it takes a dispatch; a commit behind the head goes through chant ci run --at), waits on the check runs and stops on the first one that fails, starts the tick once every phase passed, waits for ci/green/<sha>, runs just release, and waits on publish.yml at the new tag. A run finishing, a job failing or a tag appearing ends each wait; nothing times out into a verdict. A commit that already has a release/npm/* tag is reported and left alone.

Terminal window
just release-when-green patch # main's head
just release-when-green patch <commit> # a named commit on main
just release-when-green patch --dry-run # say what it would start; dispatch and cut nothing

On that tag, publish.yml’s release-publish job runs scripts/release-pack.sh <version>. It sets every package to the version, with the @intentius/* ranges at ^<version>, packs each one into .release/packs/, and puts the package.json files back. Then chant release publish <version> --yes publishes the tarballs release/chant.config.ts names, core first, straight to latest (stage: false). A new lexicon needs a line in that list, and scripts/release-member.test.ts fails until it has one.

just release-legacy is the path before #4040. It commits the version bump on top of the green commit, tags it chant-v<version>, and merges the tag back into main. It stays until two releases have gone through just release. The gate and untag jobs below belong to that path.

The publish workflow’s test job is the CI gate. Instead of running the suite again, scripts/publish-verify-ci.sh looks up the chant run through the API. A bump commit that changes only version fields is checked through its parent, the green commit the recipe picked; any other commit needs a green run of its own. Because the tag push also starts that run, the gate polls until the run finishes rather than failing on a queued one. A successful run lets the release through. The gate fails when every run has completed without success, or when no run has appeared after 5 minutes.

Beside the gate, the audit job runs scripts/audit-trusted-publishers.sh, which asks npm for an OIDC token for every publishable package. It fails when any package has no working trusted-publisher record, including a package that is not on npm yet, and publish waits for it, so a missing record stops the release before any package ships. The run summary lists the packages to fix. When the job gets no id-token from GitHub, as on a fork, the audit can check nothing; it prints a warning and passes.

When the gate, the audit or the publish fails, the untag job runs scripts/release-untag.sh. If none of the packages the release bumped is on npm, it deletes the tag, so a tag exists only for a release that reached npm. After an audit failure that is always the case: add the missing records, then push the tag again at the same commit.

The tag survives two cases:

  • Part of the release reached npm. Deleting the tag then would leave npm ahead of git with no tag, so untag keeps it, fails, and its summary lists which packages published and which did not. Fix the cause and run gh run rerun <publish-run-id> --failed; packages already on npm are skipped.
  • The chant run is still going after 45 minutes. The gate ends with outcome timeout and nothing is published. Wait for that run to go green, then re-run the gate and the publish with gh run rerun <publish-run-id> --failed.

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. By default it publishes to latest in dependency order, core first. 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.

Setting the repository variable CHANT_STAGED_PUBLISH to 1 switches on staged publishing. Every package is published under the next dist-tag, the run waits until each one can be installed, and then it moves latest, core first and the lexicons after it. Until latest moves, npm install with a version range keeps resolving the previous release. If anything fails before the tag move, latest stays where it was, and a re-run only moves the tags. Staged publishing needs every package’s trusted-publisher record on npmjs.com to have “Allow npm dist-tag” turned on, including a new package’s. Without it, that package’s latest move fails and the run fails with the setting named.

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 when it packs every package at the release’s version.

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 works without 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 declares what core may import.

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 it drifted before.

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 or 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, say so in the generated file.

Each job is a thin trigger: it runs the component’s deploy command. The deploy logic stays in the component, so a cross-cutting change is one edit to the generator rather than one 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.

chant