Ops
Deciding whether Ops is the right deployment model for your project? See Choosing Your Deployment Model.
Ops (chant’s named-workflow abstraction, not “operations” in general) are named, phased workflows defined in *.op.ts files. They turn ad-hoc deployment scripts into declared, observable ones: progress renders as the run goes, gate steps stop for a human approval that lives on the gate ledger, and onFailure phases handle compensation automatically.
When to reach for an Op
Section titled “When to reach for an Op”- Long-running deploys — see current phase without grepping logs.
- Gates — stop for human approval or an external-system event (DNS delegation, change windows).
- Automatic rollback —
onFailurephases compensate on failure instead of hand-rolled scripts.
chant run <name> executes an Op in this process, with nothing installed and nothing running. --on <lexicon> hands the same Op to a runtime that hosts it instead, and chant run <op> --on fountain is the one chant ships: a steward runs it on a machine of its own and keeps the record on a thread. See Ops as teammates for what each half is for.
Defining an Op
Section titled “Defining an Op”Create a *.op.ts file anywhere in your project:
import { Op, phase, shell } from "@intentius/chant/op";import { kubectlApply } from "@intentius/chant-lexicon-k8s/op/builders";
export default Op({ name: "alb-deploy", overview: "Deploy the ALB infra then the application layer", phases: [ phase("Infra", [ kubectlApply("dist/infra.yaml", { profile: "longInfra" }), ]), phase("App", [ kubectlApply("dist/k8s.yaml"), shell("kubectl rollout status deployment/api"), ]), ],});Try it locally
Section titled “Try it locally”Before reaching for real infrastructure, verify your toolchain with a shell-only Op. Save as hello.op.ts anywhere in your project:
import { Op, phase, shell } from "@intentius/chant/op";
export default Op({ name: "hello", overview: "Smallest possible Op — one shell step", phases: [ phase("Greet", [shell("echo hello from chant")]), ],});Then run it.
chant run helloThere is no server to start and no worker to build. Once this works end-to-end, swap shell() for kubectlApply(), helmInstall(), or any other step builder to start deploying real resources.
Phases and steps
Section titled “Phases and steps”Each phase() has a name and an ordered list of steps. All steps in a phase run sequentially by default. Set parallel: true to run them concurrently via Promise.all.
A reconcile activity is also available via activity("reconcilePr", args): given the change-set entries that triggered it, it regenerates TypeScript with chant import --from <env> and opens a reviewable PR (modes: pull-request, issue, comment, report). It never commits to the main branch. It is the building block for the reconcile workflow (cloud -> code).
comment mode is the one that constrains its trigger. The body lands as a single comment on the pull request that triggered the run. Every re-run edits that same comment instead of adding another; the activity finds it again by a hidden marker written as the comment’s first line. A branch pushed to five times then carries one current body instead of five stale ones. The pull request itself comes out of the run’s own event payload (GITHUB_EVENT_PATH, with GITHUB_REF as the fallback). So the mode requires a pull_request trigger and fails by name on a run without one. Its generated workflow gets exactly contents: read and pull-requests: write.
Gate steps
Section titled “Gate steps”A gate is a fact on the gate ledger: a run that reaches one with no resolution records that it is waiting and ends there. Typical gate scenarios:
- Human approval inside a change window
- Manual QA sign-off before promoting to prod
- Upstream ticket or external-team handoff
- External system readiness (DNS delegation, TLS cert issuance, vendor provisioning)
The example below stops for DNS delegation before applying the app layer:
import { Op, phase, gate } from "@intentius/chant/op";import { kubectlApply } from "@intentius/chant-lexicon-k8s/op/builders";
export default Op({ name: "dns-delegation", overview: "Deploy, then wait for DNS delegation before continuing", phases: [ phase("Deploy", [kubectlApply("dist/infra.yaml")]), phase("Await DNS", [gate("gate-dns-delegation", { timeout: "72h" })]), phase("Post-delegation", [kubectlApply("dist/k8s.yaml")]), ],});The run reaches the gate, records that it is waiting, and exits 3. Nothing is held open. Record the resolution when the external action completes, then run the Op again:
chant approve dns-delegation gate-dns-delegation --actor alexchant run dns-delegationThe name lives on the step’s gate field, which is the same argument chant approve takes on the command line, and that is what gate() writes there. Through 0.58.0 the field was called signalName, so a step or a composite option still spelling it that way is read exactly as before while warning once at build, and the old key is removed in 0.60.0 (#2202).
The second run reads the resolution, walks through the gate carrying the approver onto the step record, and finishes. chant run approve <op> <gate> --on <lexicon> does the same write and then tells the hosting runtime, so a runtime that can wake a gated run of its own does it now rather than on its next tick. See Gate-as-fact for the ordering rules.
Compensation on failure
Section titled “Compensation on failure”onFailure phases run in order if the run terminates with an unhandled error:
export default Op({ name: "alb-deploy", phases: [ phase("Infra", [kubectlApply("dist/infra.yaml", { profile: "longInfra" })]), phase("App", [kubectlApply("dist/k8s.yaml")]), ], onFailure: [ phase("Rollback", [shell("kubectl delete -f dist/infra.yaml --ignore-not-found")]), ],});A gate is not a failure, so a run that stops at one runs no onFailure phase. Nothing failed, so there is nothing to compensate for.
Continuous observation
Section titled “Continuous observation”The WatchOp composite puts a cron on the Op itself, so chant lifecycle snapshot and chant lifecycle diff --live run on a recurring cadence — periodic drift detection between change windows, not just at the next deploy.
See Watching Lifecycle for the full walk-through: composite shape, the cadence, filtering by labels (Watch = "true" / Drift = "true"), smoke-testing drift end-to-end, and how to triage each diff category. For the conceptual background on what drift means and why chant snapshots are observational, see Drift Detection.
Reconcile (cloud -> code)
Section titled “Reconcile (cloud -> code)”The ReconcileOp composite takes the next step on the dial: when live drifts from declarations, open a PR that regenerates the affected TypeScript. Phases are snapshot -> plan -> regenerate -> open PR — the plan is chant lifecycle plan, and the regenerate-and-PR step is the reconcilePr activity.
import { ReconcileOp } from "@intentius/chant/op";
// `schedule` lands on the Op as its cadence. Omit it for a one-shot// `chant run prod-reconcile`.const { op } = ReconcileOp({ name: "prod-reconcile", env: "prod", schedule: "0 * * * *", // hourly scope: { owned: true }, onDrift: "pull-request", // or "issue" | "comment" | "report"});
export default op; // discovered by `chant run prod-reconcile`Run chant run prod-reconcile for a one-shot reconcile here; give it a schedule and hand it to a reader that fires it — the github generator, chant operator, or a fountain Steward — for the continuous form.
Apply (code -> cloud)
Section titled “Apply (code -> cloud)”The ApplyOp composite is the other direction: compute the plan, then apply it. Authority stays with the platform; chant hosts no state file.
target picks the mechanism, and every one of them is native — no cloud CLI in the path: kubectl and kustomize are a server-side apply through the k8s lexicon’s typed client, arm is the azure lexicon’s per-resource azApply, cloudformation is the aws lexicon’s awsApply, which speaks the CloudFormation API directly, gcp is the gcp lexicon’s per-resource gcpApply, which maps each CNRM kind to its GCP REST API, and fly is the fly lexicon’s flyApply, which speaks the Fly Machines API (flaps) directly. Each is loaded from its lexicon at apply time, so a project using a target lists that lexicon in chant.config.ts.
Whatever the target, the apply reports one result shape: the count projection of core’s apply contract (#1446) — applied, pruned, and notAttempted (declared resources no provider call was made for, including kinds an owned-only prune could not consider). An Op gates on those counts regardless of target; the target-specific detail — the field manager on kubectl, the stack status on cloudformation — stays on the step record.
chant also ships four native per-resource appliers that speak each provider’s own API directly, with no CLI in the path and an endpoint override so the same step targets a real account or an emulator: awsApply, azApply and gcpApply (typed step builders, like the ones in Ops Reference), and flyApply (an activity the fly lexicon contributes, reached by name). Each of the four is exactly what ApplyOp runs for the cloudformation, arm, gcp and fly targets (#1449); the step forms remain for hand-written Ops that need per-step control.
import { ApplyOp } from "@intentius/chant/op";
const { op } = ApplyOp({ name: "prod-apply", env: "prod", target: "kubectl", delete: "gated", // "never" | "owned-only" | "gated" gate: { gate: "approve-apply", description: "Approve prod apply with deletes" },});
export default op;Field ownership on the kubectl target
Section titled “Field ownership on the kubectl target”The Kubernetes apply is a server-side apply as the field manager chant:<stack> (or the bare chant when the project sets no ownership.stack), so the API server records per field which of your tools wrote it. If something else already owns a field the apply would set, it refuses and names the owner and the paths:
k8s: server-side apply of apps/v1 Deployment prod/web was refused — 1 field isowned by another field manager.
"kubectl-client-side-apply" owns: .spec.replicas…chant never resolves that for you. Set forceConflicts: true on the ApplyOp to take ownership deliberately — adopting objects a previous kubectl apply created is the usual reason. See the k8s lexicon’s API client page for the full surface.
Gates and compensation on a destructive apply
Section titled “Gates and compensation on a destructive apply”A destructive apply is where the two safety features earn their place:
- Approval gate —
delete: "gated"(or an explicitgate) inserts an Approve phase before the apply. The run stops there and exits 3 untilchant approve prod-apply approve-applyrecords the resolution. Nothing is held open in between, so a decision that takes three days costs nothing while it is being made. - Compensation — a destructive apply defaults to an
onFailureRollback phase (acompensateApplyactivity), so a partial failure unwinds instead of leaving the cloud half-applied. Compensation is total or refused (#1449): it needs a rollback to run, which is either the target’s mapped native one (cloudformationrolls back through the aws lexicon’srollbackStack— the only mapped rollback today) or an explicitcompensate: { command }(e.g.kubectl rollout undo …). Asking forcompensate: trueon a target with neither fails when the Op is built, not with a warning when the rollback is already needed, and the default only wires the phase where a rollback path exists. Setcompensate: falseto opt out. - Audit — every run appends one record to the run ledger on the
chant/lifecyclebranch, carrying the run id, the status, the Op’slabels, and each phase’s per-step status. The gate’s resolution lands beside it on the same branch.
An ungated, additive apply needs none of this. See Ops as teammates for where each kind of durability an apply needs actually lives.
Audit (supply-chain drift)
Section titled “Audit (supply-chain drift)”Some CI/CD security checks can only be answered against a moving external truth: whether a pinned action SHA still corresponds to a real upstream tag, whether a referenced commit still exists, whether a new advisory now covers a dependency already in use, whether an upstream was archived since you pinned it. The reference was correct when authored, and the external world moved. That is drift, not a synthesis error, so it cannot live in the pure chant build; it belongs in the operational layer, exactly like lifecycle observation. On the dial it sits at observe, with a finding-mode as the reconcile step.
The github and gitlab lexicons each ship an audit Op for this. The deterministic post-synth checks (GitHub GHA029–058, GitLab WGL029–048) own everything answerable from the build; the audit Op owns only what needs live resolution.
import { WorkflowAuditOp } from "@intentius/chant/op";
// One-shot: chant run actions-auditexport const { op } = WorkflowAuditOp({ name: "actions-audit" });
// Daily, and opening a PR that bumps a stale pinexport const { op: scheduledOp } = WorkflowAuditOp({ name: "actions-audit-daily", schedule: "0 6 * * *", onFinding: "pull-request", // "report" (default) | "issue" | "pull-request"});PipelineAuditOp is the GitLab counterpart — it audits .gitlab-ci.yml include: / component: / image: references, and its finding-modes are report | issue | merge-request. Both run one-shot with chant run <name>; report mode needs no external services at all. See the github and gitlab lexicon docs for the per-platform reference lists each audit resolves.
Scheduling an Op
Section titled “Scheduling an Op”schedule above is data on the Op: { cron, overlap: "skip" }, and nothing about it names a runtime. Three readers render it.
generateOpsPipeline (@intentius/chant/op) validates that every named Op exists, then delegates to the target lexicon’s generateOpPipeline (gitlab, github, forgejo), which synthesizes one CI file per Op (on gitlab, one document holding a job apiece, since a trigger there is job-scoped) — chant run <name> in the job, with only the token/permission scope its finding-mode needs (read-only for report; scoped write for issue/comment/pull-request/merge-request). This is the Op counterpart to generate mode’s component pipeline.
Finding-modes and the scopes they cost on GitHub:
| Mode | Trigger it needs | permissions: |
|---|---|---|
report | any | contents: read |
issue | any | contents: read, issues: write |
comment | pull_request | contents: read, pull-requests: write |
pull-request / merge-request | any | contents: write, pull-requests: write |
Any non-report mode on a pull_request trigger also gets pull-requests: write, which is why comment costs nothing beyond what that trigger already grants. comment is the one mode that constrains the trigger as well as the scope: the github and gitlab generators both refuse it by name on a cron or push trigger, since a run triggered by neither a pull request nor a merge request has nothing to post on. On GitLab the finding is a merge-request note written through the REST API with a GITLAB_TOKEN, read out of the pipeline’s own CI_MERGE_REQUEST_IID (#2256); GitLab has no permissions: block at all, so the table’s scopes are a GitHub column. The forgejo generator still refuses the mode outright, since the GitHub half of the activity shells to gh and chant carries no Forgejo client.
chant operator is the second. A timer and a ref lease around the local executor, ticking every discovered ConvergeOp from your own machine or from a CronJob.
A fountain Steward is the third: every op it lists that carries a cadence becomes a Schedule on the teammate, in-thread, and a fire that lands while a turn is running is dropped rather than queued.
Three per-Op options sit beside the finding mode, two of them widening what the generated job can do without loosening that set (#2242) and the third deciding who may let the job start at all (#2257). setup is an ordered list of steps emitted between the checkout and the beforeScript lines, each either { uses, with?, env? } or { run, env? }. It is the only way a generated job reaches a marketplace action, and aws-actions/configure-aws-credentials is the motivating one. permissions is a map merged over the finding mode’s own set, additively: it can add a scope no mode grants, id-token: write above all, and the github generator refuses at build time any entry that touches a scope the mode already granted, names write-all, names a scope GitHub does not define, or asks for pull-requests: write on a trigger with no pull request. A setup action reference has to be pinned to something other than the action repository’s own default branch. A generated workflow is committed once and then re-run unattended over whatever role it assumes.
Those two land differently per provider. GitHub emits both. Forgejo emits the steps, since its runner runs uses: steps, and drops permissions: along with the mode’s own scopes, because the Forgejo runner ignores the section and issues no OIDC token off a workflow permission. GitLab refuses both by name at build time: a uses: step has no GitLab equivalent (jobs run script lines, and a { run } entry is emitted as one), and GitLab’s OIDC surface is a separate id_tokens: declaration chant does not generate, so ignoring the map would emit a job that reads as having OIDC and runs with no credentials.
The third option points the other way. environment, a { name, url? }, emits environment: on that Op’s job, which is how a GitHub environment’s protection rules (required reviewers, a wait timer, a branch restriction) come to hold a generated apply. It costs no token scope, since environment protection is repository configuration rather than something GITHUB_TOKEN carries. Nothing about the environment itself is validated, because GitHub creates an unprotected one on first use rather than failing, so whether reviewers exist cannot be answered from workflow content. What the generator refuses is only the shape that could never bind, meaning a blank name or a url that is neither absolute nor an expression the run resolves. GitLab has the same key with the same two fields and a protected environment’s approval rule behind it, so it maps across, and the generated header names the environment to protect since the rule is a project setting this file cannot declare. Forgejo Actions has no environments at all, so the key is dropped and the generated file gains a header comment naming the environment that was asked for and stating that nothing on the forge enforces it. That is the one option where a silent drop would be worst, because the whole point of it is a human holding an apply.
A ScheduledOpSpec isn’t limited to cron. Its trigger also takes { kind: "pull_request", branches? } and { kind: "push", branches? }, for an Op whose CI trigger is a PR or a push rather than a clock (a Terraform plan on pull_request, posted as a PR comment, paired with apply on push to the default branch, is the motivating case). GitHub and Forgejo support all three trigger kinds; GitLab, which has no pull_request/push event model, throws a clear error if given anything but cron.
The push-to-main apply, and its gate
Section titled “The push-to-main apply, and its gate”A push job is the one that has to survive a gate. GitHub Actions has no neutral conclusion for a run: step, so the apply that stops at its gate exits 3 and the merge shows as a failed workflow run until the approve commit lands. The github generator handles that in three pieces (#2243):
- the invocation on a
pushtrigger ischant run <name> --gated-exit 0 --json, so a pending approval is a green run. Only the gated outcome is mapped: a run that fails for any other reason still exits 1 and the job is still red. A cron watch and apull_requestplan keep the plain one-line invocation, since nobody is waiting on a merge for either; chant runwrites the gate, thechant approveline and the_gates/<op>.jsonlledger path toGITHUB_STEP_SUMMARY, so the run page says what is pending. That is an environment variable and nothing more, so Forgejo Actions and any other CI that sets it gets the same block;- one follow-up job
needs:the apply and runs when itsgatedoutput is set. A push event carries no pull request, so the job asksrepos/{repo}/commits/{sha}/pullsfor the one the commit belongs to and posts a sticky comment there through the same hidden-marker recipecommentmode uses, editing that one comment on every re-merge. A commit with no pull request, such as a direct push to the branch, gets an issue instead. Itspermissions:arecontents: read,pull-requests: writeandissues: write, set on the job so the apply beside it keeps its own read-only set.
Forgejo takes the first two and drops the third: the notice job shells to gh, which a Forgejo runner neither ships nor can point at its own instance, the same reason comment mode is refused there. GitLab takes none of it, because its Op generator has no push pipeline to attach them to.
Two gates, and how they compose
Section titled “Two gates, and how they compose”An Op with an environment on a forge that has environments is behind two gates, and they stop different things at different moments.
The environment reviewer stops the job before it starts. Nothing is checked out and no credentials are minted; the run sits in the forge’s own queue showing “waiting for review” until somebody with access to that environment releases it. It is the forge’s gate, configured on the forge, and it applies to whatever the job does, chant included.
chant’s own gate (#2119) stops the apply inside a run that already started. The Op plans and reaches its Gate phase. Finding no resolution on the gate ledger, it records a pending fact on the chant/lifecycle branch and ends there. Nothing is held open in between. chant approve <op> <gate> --approver you records the answer as another fact on that branch, and the next run reads it and applies. Because it is a fact rather than a held connection, the decision survives the run that asked for it, and the approver is reading a plan that already exists rather than a promise of one.
That difference is the reason to want both. The environment reviewer answers whether this job may run at all, cheaply and before any cost is incurred. chant’s gate answers whether this exact plan should be applied, which nothing can answer before the job has run far enough to produce the plan. Either alone is coherent. The reviewer alone is the forge-native shape a team already knows, with gate: "never" on the root; chant’s gate alone needs no forge features and works identically on Forgejo, where there are none. Neither, for a root whose merges should apply unattended, is also a choice. The choice is per environment, so a staging environment with no protection and gate: "never" sits happily beside a production environment with two reviewers and a gated apply.
The follow-up job that reports a pending chant gate stays outside the environment. It exists to say an approval is waiting, and behind the same reviewer it would only become readable after somebody had already acted.
Replay (policy drift)
Section titled “Replay (policy drift)”PolicyReplayOp is the same shape applied to authorization policy. A dogwood .dw policy set carries time-window clauses — formerly within 1h … — so what a policy decides depends on a history the build cannot see. Replaying the declared set against a recorded event trace is how that gets checked, and the answer moves as the history does.
import { PolicyReplayOp } from "@intentius/chant-lexicon-cedar";
export const { op } = PolicyReplayOp({ name: "policy-replay", tracePath: "trace/session.log", expect: [ { timestamp: 10, verdict: "allow", determiningRules: [0] }, { timestamp: 7200, verdict: "deny" }, ], onFinding: "report", // "issue" | "pull-request"});Phases are artifacts -> replay -> report: chant build emits the .dw bundle, the dogwoodReplay activity runs it through upstream’s CLI and writes a divergence report, and dogwoodReplayReport acts on the finding mode. It ships from the cedar lexicon because the replay step is cedar’s; give the Op a schedule for the recurring form. The replay step needs the dogwood binary; without one the step fails rather than reporting nothing found.
Op dependencies
Section titled “Op dependencies”depends declares Ops that must succeed before this one starts. chant build validates all referenced names exist at build time.
export default Op({ name: "app-deploy", depends: ["infra-bootstrap"], // ...});View the dependency graph:
chant graphRunning Ops
Section titled “Running Ops”# Run an Op here, in this processchant run alb-deploy
# Run the same Op on a fountain stewardchant run alb-deploy --on fountain
# List all Ops with the run state the runtime reportschant run list
# Show the latest run's statechant run status alb-deploy
# Record a gate's resolution, then wake the hosting runtimechant run approve alb-deploy gate-dns-delegation --approver alex
# Cancel an active runchant run cancel alb-deploy --force --on fountain
# View run historychant run log alb-deploySee chant run for the full CLI reference, and Running an Op on fountain for the hosted path.
See Ops Reference for step builders, retry profiles, the labels contract, per-target delete-path bounds, and what chant build emits.