| name | write-nanci-pipeline |
| description | Write or update a Nanci CI pipeline (nanci_ci.py) for a repository |
| allowed-tools | Read Glob Grep Bash Write Edit |
You are writing a CI pipeline for Nanci, a Python-based CI system. The pipeline lives in nanci_ci.py at the repository root.
Core concepts
Imports
from nanci import ci, job, autocache, have_files_changed, sh, no_shell, bash
from nanci import Artifact, AsArtifact
from nanci import trigger, PushTrigger, PullRequestTrigger
import asyncio
Jobs
Every CI unit of work is an async function decorated with @job. Jobs run inside Docker containers.
@job
async def build():
"make build"
f"echo {variable}"
await ci.exec("make test")
print("Done")
asyncio.run(build())
- Jobs can accept arguments and return values.
- Jobs can be called concurrently with
asyncio.gather.
- Jobs can call other jobs (nested jobs).
@job
async def parent():
result = await child_job("arg1")
asyncio.run(parent())
Shell integration (stateful shell)
String literals inside a @job body are syntactic sugar and run in a stateful shell, meaning state persists across commands in the same job:
@job
async def the_job():
"VAR=123"
"export EXPORTED=abc"
"cd /tmp"
assert (await ci.exec("pwd")) == "/tmp\n"
'''
function greet() {
echo "Hello $1"
}
'''
"greet World"
Choosing the shell
By default jobs use bash. To use a different shell, pass shell= to @job:
from nanci import sh, no_shell, bash
@job(shell=sh)
async def posix_job():
...
@job(shell=no_shell)
async def raw_job():
...
Using a custom Docker image
By default Nanci uses its own default image. To specify a different image:
@job(image="rust:latest")
async def build_rust():
"cargo build --release"
@job(image="postgres")
async def db_job():
...
The image is pulled automatically if not already present locally.
Uploading and downloading files
Transfer files between the host (where nanci_ci.py runs) and the container:
@job
async def the_job():
await ci.upload("src/", "/app/src")
await ci.upload("config.toml", "/etc/cfg")
await ci.download("/app/dist", "./dist")
await ci.download("/tmp/report.txt", ".")
- If the target is an existing directory, the source is placed inside it.
- If the target path does not exist (or is a new name), the source is placed/renamed there.
- Files matching patterns in
.nanciignore are excluded from uploads.
Artifacts (passing files between jobs)
Use AsArtifact() to download into a temporary artifact instead of the host filesystem, then pass the artifact to another job:
from nanci import Artifact, AsArtifact
@job
async def build():
"cargo build --release"
artifact = await ci.download("/app/target/release/mybin", AsArtifact())
return artifact
@job
async def deploy(artifact: Artifact):
await ci.upload(artifact, "/deploy/mybin")
"systemctl restart myservice"
@job
async def pipeline():
bin_artifact = await build()
await deploy(bin_artifact)
asyncio.run(pipeline())
Autocache (automatic job skipping)
Decorate a job with @autocache (below @job) to skip it automatically when nothing relevant has changed. The cache key is derived from:
- The job's function body
- The bodies of functions the job calls
- The job's arguments
- Files uploaded inside the job (via
ci.upload)
@job
@autocache
async def install_deps():
await ci.upload("requirements.txt", "/app/requirements.txt")
"pip install -r /app/requirements.txt"
Caveat: If the job returns a value and the job is skipped on a subsequent run, the return value cannot be used — accessing it raises an error. Design cached jobs to be side-effect-only (or ensure downstream jobs are also cached).
The cache state is stored in .nanci_autocache at the project root.
Manual cache (have_files_changed)
For finer-grained control, use have_files_changed to check whether specific files changed since the last run:
from nanci import have_files_changed
@job
async def test():
if have_files_changed(["src/", "tests/"]):
"pytest"
else:
print("No changes, skipping tests")
Cache state is stored in .nanci_manualcache.
Trigger context
Read the trigger that launched the pipeline (push, pull request, or none):
from nanci import trigger, PushTrigger, PullRequestTrigger
if isinstance(trigger, PushTrigger):
print(f"Push to branch: {trigger.branch}")
elif isinstance(trigger, PullRequestTrigger):
print(f"Pull request #{trigger.pr_number}")
else:
print("No trigger (local run)")
Use this to run different jobs depending on the event (e.g., deploy only on push to main).
Full example
from nanci import ci, job, autocache, trigger, PushTrigger
import asyncio
@job(image="node:20")
@autocache
async def install():
await ci.upload("package.json", "/app/package.json")
await ci.upload("package-lock.json", "/app/package-lock.json")
"cd /app && npm ci"
@job(image="node:20")
@autocache
async def build():
await install()
await ci.upload("src/", "/app/src")
"cd /app && npm run build"
await ci.download("/app/dist", "./dist")
@job(image="node:20")
@autocache
async def test():
await install()
await ci.upload("src/", "/app/src")
await ci.upload("tests/", "/app/tests")
"cd /app && npm test"
@job
async def deploy():
if isinstance(trigger, PushTrigger) and trigger.branch == "main":
await ci.upload("dist/", "/deploy/app")
"systemctl restart myapp"
@job
async def pipeline():
await asyncio.gather(build(), test())
await deploy()
asyncio.run(pipeline())
Running the pipeline (validation)
If the project is initialised as a uv project (i.e. pyproject.toml + uv.lock exist at the root), you can do a trial run in headless mode to check for errors:
NANCI_HEADLESS=true uv run python nanci_ci.py
You must ask the user for permission before running this command. The pipeline actually executes — it spins up containers and runs all jobs.
In headless mode stdout emits one JSON object per line. Each object has an "at" (ISO-8601 timestamp) and "type" field. The full set of event types:
| type | extra fields | meaning |
|---|
MainThreadStartMessage | — | pipeline script started |
MainThreadTerminateMessage | — | pipeline script finished normally |
JobStartMessage | job_id, name | job began |
JobEndMessage | job_id, exception (null or string) | job finished; non-null exception means failure |
JobImagePullStartMessage | job_id, image | pulling Docker image |
JobImagePullEndMessage | job_id | image pull done |
JobExecStartMessage | job_id, cmd | shell command started |
JobExecOutputMessage | job_id, chunk (base64 bytes) | stdout/stderr output from command |
JobExecEndMessage | job_id | shell command finished |
JobStartMoveMessage | job_id, direction ("upload"/"download"), src, dst | file transfer started |
JobEndMoveMessage | job_id | file transfer finished |
JobAutocacheHitMessage | job_id | job was skipped due to autocache hit |
StdStreamMessage | text | output from print() inside a job |
UncaughtExceptionMessage | exception (string) | unhandled exception in the pipeline script |
A pipeline is successful when all JobEndMessage events have exception: null and there is no UncaughtExceptionMessage. Use this output to diagnose failures and fix the pipeline before presenting it to the user.
Task
$ARGUMENTS
Explore the repository first to understand its structure, language, build system, and test setup. Then write an appropriate nanci_ci.py that covers building, testing, and (if applicable) deploying the project. Place the file at the repository root.