AgentCI
Run this repository's real CI on this machine, before you push, and get a structured failure back instead of a wall of log. c3_ci reads .github/workflows/*.yml directly β there is no second CI config to keep in sync.
Overview
An agent that only learns whether CI passes after pushing burns a round-trip, some Actions minutes, and a commit on every attempt. AgentCI closes that loop locally:
edit β run CI locally β structured failure β fix β rerun what failed β full CI β push
It parses your existing workflow files, builds the job DAG, and executes each job the same way GitHub would: natively on this host, or in a real Linux container via act, then hands back a verdict, structured failures, and a log per job.
What it is
- A reader of
.github/workflows/*.yml. That file is the only source of truth; nothing about a job's behavior is redefined elsewhere. - Two execution engines:
native(shell steps on this host) andact(a real Linux container, realuses:actions included). - A verdict (
FULL_CI_PASS,PARTIAL_PASS, orFAIL) that tells you honestly whether it is safe to push.
What it is not
container:/services: blocks, reusable workflows, and object/array literals inside expressions are not implemented; a job that needs one is reported unsupported, never approximated.macos-latest cells can never reach FULL_CI_PASS on any host, ever.Quick start
As an agent
c3_ci(action='inspect')
c3_ci(action='run')
c3_ci(action='run', job='lint')
c3_ci(action='failures')
c3_ci(action='rerun')
c3_ci(action='logs', job='CI::lint')
c3_ci(action='runs')
As a human
c3 ci inspect
c3 ci run --job lint --allow-foreign
c3 ci rerun
c3 ci failures
c3 ci logs "CI::lint" --tail 200
c3 ci runs
c3 ci inspect --json
In the Hub
The CI tab shows every registered project's workflows, the job graph (including what cannot run here), run history, live status while a run is in flight, and structured failures inline. Logs open in a viewer.
A real c3 ci inspect against this repository, from Windows:
Workflows in . (host Windows):
CI [pull_request, push, workflow_dispatch] jobs=4
Release [push, workflow_dispatch] jobs=4
Job graph (dependency order):
CI::lint runnable (container)
CI::test (macos-latest, 3.12) other-OS (macos-latest)
CI::test (ubuntu-latest, 3.12) runnable (container)
CI::test (windows-latest, 3.12) runnable here
CI::build runnable (container) needs=lint,jsx,test
Release::verify-ci UNSUPPORTED
Release::publish-pypi runnable (container) needs=build
Runnable here: 12 of 16 (3 native, 9 container)
engines: native + act (act version 0.2.89)
other-OS (3): these target a runner no available engine can reproduce. β¦
unsupported (1):
Release::verify-ci: step 1 env GH_TOKEN has unresolved expression(s): github.token
That last line is the point: an unresolved ${{ secrets.X }} or github.token blocks rather than silently becoming an empty string, because a job that quietly passes locally on a blank secret and fails in real CI is worse than a job that refuses.
Verdicts & job status
FULL_CI_PASS is the only verdict that means "safe to push." Everything else is PARTIAL_PASS, and partial means something did not run here β a different OS, an unsupported construct, or a selection you made yourself.
| Verdict | Meaning |
|---|---|
| FULL_CI_PASS | Every job in the repo ran on this host (native or container) and passed. |
| PARTIAL_PASS | Nothing failed, but something did not run here, or ran only as a cross-OS approximation. |
| FAIL | A job failed, timed out, or a dependency of it did. |
c3 ci run exits non-zero for anything but FULL_CI_PASS, so a pre-push hook or script can gate on the exit code alone.
Job statuses
| Status | Meaning | Counts against a full pass? |
|---|---|---|
| passed | Ran here and succeeded. | No |
| failed | Ran here and did not succeed. | Verdict is FAIL |
| timeout | Killed at the step timeout (default 900s). Effect unknown β not treated as a clean failure. | Verdict is FAIL |
| skipped | A needs: dependency did not pass, so this never ran. | Yes |
| unsupported | Uses something no engine can faithfully reproduce (see Overview). | Yes |
| foreign | runs-on targets a different OS; refused unless allow_foreign. | Yes |
| deselected | Not part of this run's selection (e.g. you passed job=). | Yes |
| skipped_if | Its own if: evaluated false. This is the faithful reproduction of what CI would have done. | No |
| cached | Definition, engine and inputs unchanged since it last passed β reused, not re-executed. | No (noted in the verdict) |
allow_foreign records fidelity cross-os even when it passes, and any cross-OS job caps the whole run at PARTIAL_PASS β permanently, for that job. Only native or container fidelity counts toward FULL_CI_PASS, because a Linux job in a Linux container really is that job, and a Linux approximation of a macOS job is not.Job DAG & conditions
- Order. Jobs run in topological order of
needs. Aneedsedge is scoped to its own workflow, so two workflows that both define a job calledbuildstay unrelated. - Matrix.
strategy.matrixexpands to one job instance per cell, includinginclude/exclude. Each cell resolves its ownruns-on. - Identity. A job's key is
<workflow>::<id>, e.g.CI::test (ubuntu-latest, 3.12). A selector accepts the key, the bare id, or a bare job name (which selects every matrix cell of it). - Skipped on failed dependency. A job whose
needsdid not pass isskippedβ never run, never counted as passed.
if: conditions (v2.80.0+)
Job- and step-level if: are evaluated with GitHub's own semantics, not just parsed and ignored:
- A condition naming no status function is implicitly
success() && (...)β which is whyif: always()is how a step runs after a failure. - A job-level
if:replaces theneedssuccess gate, soalways()andfailure()run even when a dependency failed. - A step with no
if:keeps its implicitsuccess()gate and is skipped once something earlier in the job failed.
Supported operators: == != < <= > >= && || !, parentheses, literals, and success failure always cancelled contains startsWith endsWith format join toJSON fromJSON. Supported contexts: github env matrix runner job needs steps strategy.
A job skipped by its own if: gets status skipped_if and does not cost coverage β CI would have skipped it too, so FULL_CI_PASS stays reachable. Two things still block rather than guess: a condition that cannot be parsed, and one that reads github.event_name with no event declared. There is no event locally, so declare what you are simulating:
c3 ci run --event pull_request
github.ref, ref_name and sha are read from git and always available.
Job outputs & run-time needs.* (v2.130.0+)
A workflow shaped like a changes job feeding a gate (if: needs.changes.outputs.code == 'true') into shards, then an aggregator reading needs.<job>.result, now resolves end to end:
- Every native step gets its own
GITHUB_OUTPUTfile (k=vandk<<EOFforms); a job's declaredoutputs:resolve fromsteps.<id>.outputs.<k>. Underactthe same values come from its::set-output::log lines. - An output that is declared but never written is refused, not blanked β a blank standing in for a real value is exactly the bug this exists to catch.
needs.*andsteps.*references insiderun:text are deferred at DAG-build time and filled in just before each step executes; a leftover at run time still refuses the step.- A job that publishes outputs is never served from the cache β a cache hit replays a verdict, not the values a dependent needs.
github.event.*fields read""when an event is declared but the field is absent from the local (nonexistent) payload β matching what GitHub itself sends for a sparse payload. Without a declared event it still blocks the native engine (act resolves it itself).
Execution engines (v2.81.0+)
Two engines, picked per job. c3 ci doctor says which are available.
| Engine | Runs | Fidelity | uses: actions |
|---|---|---|---|
native | Shell steps directly on this host | native when runs-on matches the host, else cross-os | Shimmed (checkout / setup-*/ cache / artifacts) or blocked |
act | The job in a real Linux container via nektos/act | container | Really executed |
engine='auto' (the default) runs a job natively when runs-on matches the host, hands Linux jobs to act when act and Docker are present, and refuses otherwise. engine='act' forces containers and fails the run if act is unavailable β it does not fall back silently, because you asked for container fidelity and would not have gotten it.
Setup
# Windows
winget install nektos.act
# macOS
brew install act
# plus a running Docker daemon
Images come from catthehacker/ubuntu:act-* (~1 GB on first pull), which mirror much of the GitHub runner's preinstalled toolchain. A bare ubuntu:24.04 does not, and a job assuming a preinstalled tool would fail locally while passing in real CI.
c3 ci doctor β a real run on this repo
Local CI execution engines:
native available β runs jobs whose runs-on matches Windows
act available β act version 0.2.89, docker 29.6.2
runs Linux jobs in a container (catthehacker/ubuntu:act-latest), real `uses:` actions included
a runner failure before any step runs is reported as a container-setup failure, not as 0 parsed failures
macOS jobs cannot run locally on any engine β there are no macOS containers, so a matrix containing them can never reach FULL_CI_PASS here.
Two things worth knowing
--bindis always used. act's default copy-mode workspace arrives empty against a Windows host path, so every step then fails on missing files. Binding means the container writes into your real working tree β the same thing the native engine already does.- Network is on by default (act's own default). Pass
network='none'(c3_ci) or--network none(CLI) to cut egress.
Blockers are per engine
An unknown uses: action is fatal to the native shell and routine for act, so inspect reports both answers separately. A missing ${{ secrets.X }} or ${{ vars.X }} blocks on either engine β no engine can reproduce a job whose input does not exist.
Foreign-OS jobs
runs-on: macos-latest on a Windows or Linux box is not that job β by default it is refused and reported foreign.
c3_ci(action='run', allow_foreign=true)
c3 ci run --allow-foreign
allow_foreign runs it anyway on this host's OS, labels the result cross-os, and caps the verdict at PARTIAL_PASS β permanently, no matter how many times it passes. That is what makes the feature honest rather than merely optimistic: an indicative pass on the wrong OS is not evidence the real job would pass.
act cannot help either. A workflow with any macos-latest (or similar) cell can never reach FULL_CI_PASS on a Windows or Linux machine β that is a property of the world, not a gap C3 will close.Secrets & side effects
Running real uses: actions under act means a publishing job goes from unrunnable to one command away from actually publishing. Two things stand in the way β a mechanism and a policy, and neither is a sandbox.
No secrets, ever
act reads .secrets and .env from the repository by default; C3 points both at an empty file. A publish step therefore runs and fails at authentication rather than silently succeeding with real credentials it should never have had.
The side-effect gate
Jobs using known publishing actions (pypa/gh-action-pypi-publish, softprops/action-gh-release, β¦) or running publishing-shaped commands (twine upload, npm publish, docker push, β¦) are refused unless you pass allow_side_effects=true (--allow-side-effects on the CLI).
The native engine has no isolation
Running this repository's own test job natively once genuinely uninstalled C3 mid-run, because the job's own steps included pip install -e . against the same environment the agent was running in. The native engine now refuses steps that would reconfigure this machine (pip install, npm -g, apt, brew install, and the like) unless you pass allow_host_mutation=true. Prefer engine='act' for jobs that install things β the container absorbs the mutation instead of your machine.
Required mode (v2.83.0+)
By default (mode='full') every runnable job executes. mode='required' narrows the run to the jobs a change could plausibly have broken.
c3 ci plan # what a change requires, and WHY, per job
c3 ci plan --base origin/main # diff against a ref instead of the working tree
c3 ci run --required # execute just those jobs
The planner is conservative: it reasons from changed paths, a workflow's own paths: filters, and an explicit ci.required_map in .c3/config.json. Anything it cannot reason about runs β on a repo with no filters and no map it selects everything, because guessing which jobs a change cannot affect, with no real dependency graph, is how a skipped job becomes a shipped bug.
{ "ci": { "required_map": { "unit": ["src/**"], "docs": ["docs/**", "*.md"] } } }
A narrowed run is always PARTIAL_PASS β required mode is for the inner edit loop, never the final check before a push.
A real c3 ci plan against this repo, with no required_map configured yet:
Required-mode plan (16 run / 0 skip) vs the working tree
changed files: 1
cli/guide/credentials.html
RUN CI::lint
no path filter or required_map rule covers this job β running it, because skipping on a guess risks a false pass
β¦
Caching (v2.84.0+)
A job whose definition, engine and inputs are unchanged since it last passed is reused: status cached, nothing executed, and the verdict note says how many. no_cache=true (--no-cache) forces execution of every selected job regardless.
ci.required_map pays for itself twice: a job that declares its inputs is fingerprinted over only those paths, so unrelated edits stop invalidating it. Without a map, the fingerprint covers the whole tree and any edit invalidates every cached result.
actions/cache is honored for real β restore at the step, save only after the job passes, keys immutable. A job that publishes outputs (v2.130.0+) is never served from cache, since a cache hit replays a verdict, not the values a dependent needs.
c3 ci cache # size + whether required_map narrows invalidation
c3 ci cache --clear # drop every cached result and dependency cache
A real c3 ci cache on this repo, before any run has happened:
Cached results: 0
Dependency cache keys: 0
Location: .c3\ci\cache
A job is reused when its definition, engine and inputs are all unchanged since it last passed.
No ci.required_map: every job is fingerprinted over the whole tree, so any edit invalidates every cached result.
Run history, flakes & publishing (v2.85.0+)
c3 ci history
Per-job pass/fail counts, average duration, and flake detection: a job that both passed and failed on the identical fingerprint (same definition, same inputs) changed its mind without the world changing. That is the only local flake signal that is not a guess. Sample sizes under five executions are labelled (low n) rather than presented as a rate, and cached results are never counted as observations, because reuse is not evidence about behavior.
c3 ci publish
Posts a GitHub commit status through your existing gh authentication β no App to register, no token for C3 to hold. It refuses three things, because a status is a claim other people act on:
- a dirty tree β the status attaches to a commit, and a differing working tree means the thing that ran is not the thing being labelled;
- an unpushed commit, which has nothing to attach to;
- a
PARTIAL_PASS, unless forced β GitHub's states are success/failure/pending/error and none of them honestly mean "we checked some of it," so it refuses, or on--forcepostspendingwith the reason attached.
c3 ci publish --dry-run
c3 ci publish
c3 ci publish --force # posts a PARTIAL_PASS as `pending`, reason included
The context is agentci/local, so nobody mistakes a laptop run for hosted CI.
c3 ci CLI reference
The human mirror of c3_ci β same handler, so the two surfaces can never drift into disagreeing about what a verdict means. Common flags on every subcommand: --path (project directory, default current), --json (machine-readable, same shape the Hub reads), --event (simulate push / pull_request for an if: that reads github.event_name).
| Subcommand | Does |
|---|---|
inspect | Workflows, the job DAG, and what runs on this host. |
run | Execute jobs. --job, --workflow, --allow-foreign, --timeout, --engine {auto,native,act}, --allow-side-effects, --required, --base, --allow-host-mutation, --no-cache, --network. |
rerun | Re-run only the jobs that failed in the last run. --run (run id, default latest), --allow-foreign, --timeout. |
status | Last run's verdict and jobs. --run. |
failures | Structured {file, line, message} failures from the last run. --run. |
logs | Tail one job's log: c3 ci logs "CI::lint" --tail 200. --run, --tail. |
runs | Recent local CI runs. |
plan | Which jobs a change requires, and why. --base. |
publish | Post the last run as a GitHub commit status. --run, --dry-run, --force. |
history | Per-job pass/fail history and flake detection. |
cache | Cached-result store: size, or --clear it. |
doctor | Which execution engines are available on this machine. |
Exit code follows the verdict: c3 ci run / rerun exit non-zero for anything but FULL_CI_PASS, so a pre-push hook can gate on $?. Run python -m cli.c3 ci <subcommand> --help from a checkout to see the live flag list for yourself.
Troubleshooting
| Symptom | Cause & fix |
|---|---|
Everything is UNSUPPORTED |
Read the blocker c3 ci inspect prints for that job β usually an unresolved ${{ secrets.* }}/${{ vars.* }}, a container:/services: block, a reusable workflow, or a third-party uses: the native engine cannot run (try engine='act', which really executes it). |
| A failed job reports "0 parsed failures" (fixed v2.129.1) | Before this fix, an act runner that died before any workflow step ran (registry auth, missing image, dead Docker daemon) was handed to the log parsers like an ordinary failure and came back empty. It is now classified parser: container-setup with the real cause. c3 ci doctor reports whether the runner image is present locally without pulling it, but that is not a guarantee the pull will succeed β only a real run proves that (act sends the stored registry credential on a pull where the plain CLI sends none, issue #173). |
| act run fails immediately with an image "authentication required" even though the image is already on disk (fixed v2.130.0) | Every run used to pass --pull=true to act unconditionally. C3 now checks docker image inspect first and pulls only what is actually missing. |
| Two act runs collide: "bind: Only one usage of each socket address" (fixed v2.130.0) | act's artifact server used to bind a fixed port (34567), so a second concurrent run on the same box failed outright. Each run now gets a free port. |
job= a single job and its own if: needs.<dep>.outputs.* becomes unjudgeable (fixed v2.130.0) |
Selecting one job used to drop its own transitive needs, so a gate reading a dependency's output had nothing to read. A selected job (and a rerun) now brings its transitive needs along automatically; everything else stays deselected and the verdict stays PARTIAL_PASS. |
A job stays foreign / verdict stuck at PARTIAL_PASS |
Expected for any OS this host cannot reproduce β most commonly macos-latest. allow_foreign gives you an indicative cross-OS run, but the verdict is capped at PARTIAL_PASS forever for that job; there is no flag that lifts the cap. |
act unavailable |
Run c3 ci doctor for the exact reason. Install with winget install nektos.act (Windows) or brew install act (macOS), and make sure a Docker daemon is running. |
"No CI runs recorded yet" from status / failures / logs / publish |
Those actions read the last recorded run. Run c3 ci run (or rerun) at least once first. |
Full design detail lives in docs/agent-ci.md and AgentCI_Product_Architecture_PRD_Roadmap.md.