Early development. This project is a work in progress. APIs, DSL syntax, and behaviour may change between versions. Feedback and contributions are welcome.
A local CI runner for Elixir projects. Define your build pipeline as code — stages, steps, conditions, hooks — and run it from the command line. No YAML, no cloud dependency.
- Create a
tiny_ci.exsfile in your project root:
name :my_pipeline
on_success :notify, cmd: "echo 'Build passed on branch $TINY_CI_BRANCH'"
on_failure :alert, cmd: "curl -s -X POST $SLACK_WEBHOOK_URL -d '{\"text\":\"Build failed on $TINY_CI_BRANCH\"}'"
stage :test, mode: :parallel do
step :unit, cmd: "mix test", timeout: 120_000
step :lint, cmd: "mix credo"
step :format, cmd: "mix format --check-formatted"
end
stage :deploy, mode: :serial, when: branch() == "main" do
step :release, cmd: "mix release"
end- Run it:
mix tiny_ci.runmix tiny_ci.run [pipeline] [options]
| Flag | Short | Description |
|---|---|---|
--file PATH |
-f |
Path to a pipeline file (skips discovery) |
--root DIR |
-r |
Project root for pipeline discovery |
--dry-run |
Show what would execute without running anything | |
--list |
List all available pipelines in .tiny_ci/ |
|
--filter STAGES |
Run only the named stage(s) — see below | |
--output FORMAT |
Output format: json for machine-readable output |
|
--no-cache |
Bypass all cache lookups for this run | |
--artifacts-dir DIR |
Override the base directory for artifact storage | |
--list-artifacts |
Show artifacts from the most recent run and exit |
The optional pipeline argument selects a named pipeline from .tiny_ci/:
mix tiny_ci.run # discovers tiny_ci.exs or .tiny_ci/pipeline.exs
mix tiny_ci.run ci # runs .tiny_ci/ci.exs
mix tiny_ci.run jobs/release # runs .tiny_ci/jobs/release.exs
mix tiny_ci.run --list # prints all available pipelinesRun only specific named stages without editing the pipeline file — useful for debugging a single stage locally.
# Run only the :test stage
mix tiny_ci.run --filter :test
# Run :build and :test, skip everything else
mix tiny_ci.run --filter :build,:test
# Preview which stages would run (without executing)
mix tiny_ci.run --dry-run --filter :deployStages not in the filter list are silently omitted — they do not appear as skipped in the output.
If a filtered-in stage declares needs: pointing to a stage that was filtered
out, a warning is printed and the stage runs without that dependency:
Warning: ":test" needs ":build" which was filtered — running :test without it
Passing an unknown stage name to --filter is an error; the available stage
names are listed in the error message.
Add --output json to get a JSON object on stdout instead of human-readable
text. All ANSI output, stage headers, and progress lines are suppressed — only
the JSON object is printed.
# Run and capture JSON
mix tiny_ci.run --output json
# Pipe into jq
mix tiny_ci.run --output json | jq '.status'
# → "passed" or "failed"
# Check per-stage results
mix tiny_ci.run --output json | jq '[.stages[] | {name, status, duration_ms}]'Output shape:
{
"status": "passed",
"duration_ms": 1234,
"stages": [
{
"name": "test",
"status": "passed",
"duration_ms": 500,
"steps": [
{
"name": "unit",
"status": "passed",
"output": "...",
"duration_ms": 200,
"attempts": 1,
"allowed_failure": false
}
],
"matrix_runs": []
}
]
}Matrix stages include a matrix_runs array instead of top-level steps. Each
run has a combination map (e.g. {"elixir": "1.17", "otp": "26"}), its own
status, duration_ms, and a steps array.
Exit codes are unchanged: 0 on success, 1 on failure — the JSON status
field and the exit code always agree.
Exit codes: 0 on success, 1 on failure — suitable for git hooks and scripts.
When --file is not given and no pipeline name is provided, TinyCI searches in order:
tiny_ci.exs(project root).tiny_ci/pipeline.exs
Named pipelines live in .tiny_ci/<name>.exs or nested as .tiny_ci/<dir>/<name>.exs.
Pipeline files use a flat, declarative DSL. No defmodule, no use statements — just
top-level directives. Files are parsed into a controlled AST rather than compiled as
arbitrary Elixir modules.
Optional. Sets the pipeline name. Defaults to the filename stem (deploy.exs → :deploy).
name :my_pipelineThe env directive declares environment variables that are automatically inherited by steps. It can be used at the pipeline level (all stages and steps inherit) or inside a stage block (only steps in that stage inherit). Step-level env: values take precedence and override inherited ones.
# Pipeline-level: available to every step
env "MIX_ENV": "test"
env "DATABASE_URL": "postgres://localhost/mydb"
stage :test do
# Stage-level: only steps in this stage inherit these
env "NODE_ENV": "test"
step :unit, cmd: "mix test"
# MIX_ENV, DATABASE_URL, and NODE_ENV are all available here
step :assets, cmd: "npm test"
# Override a specific key for one step
step :assets_prod, cmd: "npm run build", env: %{"NODE_ENV" => "production"}
endMultiple variables can be declared on one line or across multiple env calls — they are merged in declaration order:
env "APP": "myapp", "REGION": "us-east-1"--dry-run shows declared env vars at the pipeline and stage level.
By default, stages run sequentially. When any stage declares needs:, the pipeline switches to DAG execution: independent stages run in parallel while dependent stages wait for their prerequisites.
stage :name, mode: :parallel do
# steps...
end| Option | Default | Description |
|---|---|---|
:mode |
:parallel |
How steps within the stage execute — :parallel or :serial |
:needs |
[] |
List of stage names that must complete successfully before this stage starts |
:when |
(always run) | Condition expression; stage is skipped when it evaluates to falsy |
:working_dir |
(pipeline root) | Default working directory for all steps in this stage |
:matrix |
[] |
Keyword list of variable names to value lists; stage runs once per combination |
:max_parallel |
(unlimited) | Maximum number of matrix runs executing at the same time |
:allow_failure |
false |
When true, a failing matrix combination does not fail the parent stage |
The needs: option declares explicit dependencies between stages. Stages without needs: at the same level run in parallel; stages with needs: wait for all listed stages to pass.
# build and lint run in parallel (no dependencies between them)
stage :build do
step :compile, cmd: "mix compile"
end
stage :lint do
step :format, cmd: "mix format --check-formatted"
step :credo, cmd: "mix credo"
end
# test waits for build and lint to both succeed
stage :test, needs: [:build, :lint] do
step :unit, cmd: "mix test"
end
# deploy waits for test
stage :deploy, needs: [:test], when: branch() == "main" do
step :release, cmd: "mix release"
endExecution topology for the above:
Level 1 (parallel): :build :lint
Level 2: :test ← waits for both
Level 3: :deploy ← waits for test
Failure propagation: if a stage fails, all stages that needs: it (directly or transitively) are automatically skipped. Independent stages at the same level still run.
Cycle detection: circular dependencies (a needs b, b needs a) are caught at parse time with a descriptive error — the pipeline will not start.
--dry-run shows the dependency graph grouped by level, with [needs: ...] shown for each dependent stage.
The matrix: option replicates a stage across multiple variable combinations. TinyCI computes the cartesian product of all values, starts one run per combination (in parallel by default), and groups results under the parent stage name in the summary.
stage :test, matrix: [elixir: ["1.17", "1.18"], otp: ["26", "27"]] do
step :unit, cmd: "mix test"
endThe above generates four parallel runs:
| Combination | Env vars injected |
|---|---|
elixir=1.17, otp=26 |
ELIXIR=1.17 OTP=26 |
elixir=1.17, otp=27 |
ELIXIR=1.17 OTP=27 |
elixir=1.18, otp=26 |
ELIXIR=1.18 OTP=26 |
elixir=1.18, otp=27 |
ELIXIR=1.18 OTP=27 |
Each step in the stage receives its combination's values as uppercased environment variables (ELIXIR, OTP). The same values are also written into the pipeline store so module steps can read them via ctx.store.
Limiting concurrency — use max_parallel: to cap how many runs execute simultaneously:
stage :test,
matrix: [elixir: ["1.17", "1.18"], otp: ["26", "27"]],
max_parallel: 2 do
step :unit, cmd: "mix test"
endAllowing partial failure — by default any failing combination marks the entire stage as failed. Set allow_failure: true to let the pipeline continue even when some combinations fail:
stage :compatibility,
matrix: [os: ["ubuntu", "macos", "windows"]],
allow_failure: true do
step :smoke, cmd: "./run_smoke_test.sh"
endReporter output — each combination is shown as a sub-row under the stage:
✓ test — passed (3.2s)
✓ [elixir=1.17, otp=26] (0.8s)
✓ unit (0.8s)
✓ [elixir=1.17, otp=27] (0.9s)
✓ unit (0.9s)
✓ [elixir=1.18, otp=26] (0.7s)
✓ unit (0.7s)
✓ [elixir=1.18, otp=27] (0.8s)
✓ unit (0.8s)
--dry-run lists all generated combinations without executing anything.
Each step is a shell command or a module callback.
# Shell command
step :test, cmd: "mix test", timeout: 60_000, env: %{"MIX_ENV" => "test"}
# Module step — module must be pre-compiled and available on the load path
step :deploy, module: MyApp.Deploy do
set :region, "us-east-1"
set :replicas, 3
end| Option | Description |
|---|---|
:cmd |
Shell command to execute |
:module |
Module implementing execute(config, context) |
:timeout |
Max execution time in ms; step fails if exceeded |
:env |
Map of environment variables merged into the shell environment |
:allow_failure |
When true, step can fail without failing the stage |
:when |
Condition expression; step is skipped when it evaluates to falsy |
:working_dir |
Directory to run the command in (string path) |
:retry |
Number of times to retry on failure (e.g. retry: 3 = up to 3 retries) |
:retry_delay |
Milliseconds to wait between retry attempts (default: no delay) |
The :when option is supported on both stages and steps. It accepts a boolean expression built from these primitives:
| Expression | Description |
|---|---|
branch() |
Current git branch name (string) |
env("VAR") |
Value of environment variable, or nil if unset |
file_changed?("glob") |
true if any file matching the glob changed since last commit |
Combine with standard boolean operators: and, or, not, ==, !=.
Stage-level conditions skip the entire stage when not met:
stage :deploy, when: branch() == "main" do
step :release, cmd: "mix release"
end
stage :test, when: file_changed?("lib/**") or file_changed?("test/**") do
step :unit, cmd: "mix test"
endStep-level conditions skip individual steps within a running stage, leaving the rest of the stage unaffected:
stage :check do
step :unit, cmd: "mix test"
step :dialyzer, cmd: "mix dialyzer", when: branch() == "main"
step :audit, cmd: "mix deps.audit", when: env("CI") != nil
endA skipped step is reported with a ○ icon in the summary and does not affect the stage outcome. --dry-run shows which steps would be skipped before any execution.
The working_dir: option sets the directory a shell command runs in. It can be set on a stage (applies to all steps) or on an individual step (overrides the stage value).
# Stage-level: all steps run inside frontend/
stage :frontend, working_dir: "frontend" do
step :install, cmd: "npm install"
step :build, cmd: "npm run build"
step :test, cmd: "npm test", working_dir: "frontend/packages/core"
end
# Step-level only
stage :check do
step :mix_test, cmd: "mix test"
step :js_lint, cmd: "eslint src", working_dir: "assets"
endRelative paths are resolved from the directory containing the pipeline file. Absolute paths are used as-is. If the directory does not exist, the step fails immediately with a clear error before any command is run. --dry-run shows the resolved path for each step.
The cache: option skips a step and restores its output directories when the nominated key file (e.g. mix.lock) has not changed since the last run. On the first run (cache miss) the step executes normally and its output is saved; subsequent runs with the same key are served from cache.
stage :install do
# Skip `mix deps.get` when mix.lock hasn't changed
step :deps, cmd: "mix deps.get",
cache: [paths: ["deps", "_build"], key: "mix.lock"]
endpaths:— list of directories/files to cache and restore (relative to step working dir or project root)key:— path to the file whose SHA-256 hash is used as the cache key (relative to project root)- Cache is stored at
~/.cache/tiny_ci/<project_id>/<key_hash>/ - A cache hit restores the directories before the step runs and skips the command; the reporter shows
[cache hit] - A cache miss runs the step and saves the directories afterward; the reporter shows
[cache miss] --dry-runshows[cache: key=mix.lock, paths=[deps, _build]]in the step plan--no-cachebypasses all cache lookups for the current runmix tiny_ci.cache cleanremoves all cache entries for the current project:
mix tiny_ci.cache clean
mix tiny_ci.cache clean --root /path/to/projectThe artifact: option declares build outputs that a step produces. After the step completes successfully, the declared paths are copied to a per-run storage directory so they survive the build and can be inspected or referenced by downstream steps.
stage :build, mode: :serial do
step :compile, cmd: "mix release",
artifact: [name: "release", paths: ["_build/prod/rel"]]
end
stage :package, mode: :serial do
# Access the artifact path from the store
step :bundle, module: MyApp.Package do
set :source, store(:artifact_release)
end
endname:— string identifier for this artifact (used as the directory name and store key)paths:— list of paths (relative to the step's working directory or project root) to copyrequired:— whentrue, a missing path fails the step; whenfalse(default) a warning is printed and the step still passes- Artifacts are stored at
~/.local/share/tiny_ci/artifacts/<project_id>/<run_id>/<name>/ - Each run gets an isolated subdirectory (
<YYYYMMDD_HHMMSS>_<commit7>) so runs never overwrite each other - After a step with
artifact:completes, the artifact's storage path is written to the pipeline store under the keyartifact_<name>— downstream module steps can read it viactx.store.artifact_releaseand shell steps can usestore(:artifact_release)in theirenv: --dry-runshows[artifact: name=..., paths=[...], dest=...]in the step plan--artifacts-dir DIRoverrides the base storage location for the current runmix tiny_ci.run --list-artifactslists artifacts from the most recent run
# Show artifacts from the last run
mix tiny_ci.run --list-artifacts
# Store artifacts in a custom directory
mix tiny_ci.run --artifacts-dir /tmp/ci-artifactsThe retry: option retries a failed step automatically, useful for flaky network calls, intermittent package downloads, or external service timeouts.
stage :test do
# Retry up to 3 times on failure
step :flaky_test, cmd: "mix test --seed random", retry: 3
# Retry with a 2-second delay between attempts
step :fetch_deps, cmd: "mix deps.get", retry: 2, retry_delay: 2000
endretry: N— retry up to N times; total max attempts = N + 1retry_delay: N— wait N milliseconds between attempts (default: no delay)- Each attempt is logged with its number (e.g.
[attempt 2/3]) allow_failure: trueexhausts all retries before allowing the failuretimeout:applies per attempt, not across all attempts combined--dry-runshows[retry: N]and[retry_delay: Nms]in the step plan- The summary reports
passed on attempt Norfailed after N attemptswhen retries were used
Hooks run after the pipeline completes. Shell command hooks and module hooks are both supported.
# Shell command hook
on_success :notify, cmd: "say 'Build passed'"
on_failure :alert, cmd: "curl -X POST $SLACK_WEBHOOK_URL -d '{\"text\":\"Build failed\"}'"
# Module hook — module must be pre-compiled and available on the load path
on_success :slack, module: MyApp.SlackNotifier do
set :channel, "#deploys"
end
on_failure :slack, module: MyApp.SlackNotifier do
set :channel, "#alerts"
endHook failures are logged to stderr but do not change the pipeline exit code.
Module steps implement execute/2; module hooks implement run/2. Both receive the
config keyword list (from set/2 calls) and the pipeline context map:
defmodule MyApp.Deploy do
def execute(config, context) do
region = Keyword.fetch!(config, :region)
branch = context.branch
# deploy logic...
:ok # or {:error, reason}
end
end
defmodule MyApp.SlackNotifier do
def run(config, context) do
emoji = if context.pipeline_result == :on_success, do: "✅", else: "❌"
message = "#{emoji} *#{context.branch}* — pipeline #{context.pipeline_result}"
{_output, exit_code} =
System.cmd("curl", [
"-s", "-o", "/dev/null",
"-X", "POST", config[:webhook_url],
"-H", "Content-Type: application/json",
"-d", ~s({"channel":"#{config[:channel]}","text":"#{message}"})
])
if exit_code == 0, do: :ok, else: {:error, :curl_failed}
end
endModule steps return :ok or {:ok, map} to merge data into the pipeline store.
Module hooks return :ok or {:error, reason}.
Note: Module steps and hooks must be pre-compiled and available on the Elixir load path before TinyCI runs. They cannot be defined inside the
.exspipeline file.
The pipeline store is a key-value map that accumulates data across steps and stages within a single pipeline run. It lets earlier steps produce values that later steps consume.
A module step writes to the store by returning {:ok, map} from execute/2:
defmodule MyApp.BuildImage do
def execute(_config, _ctx) do
tag = "myapp:#{System.get_env("GIT_SHA", "latest")}"
# ... build the image ...
{:ok, %{image_tag: tag}} # merged into the store
end
endShell steps cannot write to the store.
Module steps read prior values from ctx.store:
defmodule MyApp.PushImage do
def execute(_config, ctx) do
tag = ctx.store.image_tag # written by an earlier step
{_out, 0} = System.cmd("docker", ["push", tag])
:ok
end
endShell steps do not receive store values automatically. Declare exactly
which keys you need using store(:key) in the step's env: option:
stage :build do
step :tag_image, module: MyApp.BuildImage # writes image_tag to store
end
stage :deploy do
step :push,
cmd: "docker push $IMAGE_TAG",
env: %{"IMAGE_TAG" => store(:image_tag)}
step :notify,
cmd: "echo Deployed $IMAGE_TAG to production",
env: %{"IMAGE_TAG" => store(:image_tag)}
endOnly the keys you explicitly reference are exposed. Everything else in the store stays invisible to the shell environment — so a step that writes a computed auth token cannot accidentally leak it to unrelated steps.
The store is local to a pipeline run. It starts empty, accumulates values left to right across steps and top to bottom across stages, and is discarded when the run ends.
Stage 1 step A writes {image_tag: "myapp:abc"}
Stage 1 step B sees store = %{image_tag: "myapp:abc"}
Stage 2 step C sees store = %{image_tag: "myapp:abc"} ← carries forward
Stage 2 step D writes {pushed: true}
Stage 3 step E sees store = %{image_tag: "myapp:abc", pushed: true}
In parallel stages, all steps start with the same store snapshot; their outputs are merged after all steps finish, so two parallel steps writing the same key results in an arbitrary winner. Avoid writing the same key from parallel steps.
The same store(:key) syntax works in hook env: options:
on_success :deploy_notify,
cmd: "echo Deployed $TAG to production",
env: %{"TAG" => store(:image_tag)}Hooks receive TINY_CI_RESULT, TINY_CI_BRANCH, and TINY_CI_COMMIT
automatically — store values are only injected when you ask for them.
There is no built-in mechanism to share data between separate mix tiny_ci.run
invocations. Use the filesystem or environment variables as the bridge:
# pipeline: build
stage :package do
step :write_tag, cmd: "echo myapp:$(git rev-parse --short HEAD) > .tiny_ci_tag"
end
# pipeline: deploy (run separately, e.g. after build)
stage :push do
step :deploy, cmd: "docker push $(cat .tiny_ci_tag)"
endEvery pipeline run builds a context map from the git environment:
%{
branch: "main", # current git branch
commit: "a1b2c3d...", # full commit SHA
changed_files: ["lib/..."], # files changed since last commit
store: %{}, # accumulated data from module steps
timestamp: ~U[...] # UTC timestamp
}Module hooks also receive :pipeline_result (:on_success or :on_failure).
Pipeline files are validated against an allowlist of permitted constructs before execution:
name,stage,step,on_success,on_failure,set- Stage options:
:mode,:needs,:when,:working_dir,:matrix,:max_parallel,:allow_failure - Step options:
:cmd,:module,:timeout,:env,:allow_failure,:when,:working_dir,:retry,:retry_delay,:cache,:artifact - Condition expressions:
branch(),env/1,file_changed?/1,==,!=,and,or,not,if/else
Constructs outside this list (e.g. defmodule, System.cmd, File.read) are
rejected at load time with a descriptive error.
Organize multiple pipelines in .tiny_ci/:
.tiny_ci/
ci.exs # mix tiny_ci.run ci
deploy.exs # mix tiny_ci.run deploy
jobs/
nightly.exs # mix tiny_ci.run jobs/nightly
mix tiny_ci.run --list # shows: ci, deploy, jobs/nightly
mix tiny_ci.run ci
mix tiny_ci.run jobs/nightly --dry-runEvery phase of a pipeline run emits a typed event struct. These events are the foundation for the event log, run history CLI, and web dashboard (coming in Phase 1).
All events live under TinyCI.Events.* and share two mandatory fields:
| Field | Type | Description |
|---|---|---|
run_id |
String.t() |
Unique identifier for the pipeline run |
timestamp |
DateTime.t() |
UTC wall-clock time the event occurred |
| Struct | Emitted when |
|---|---|
PipelineStarted |
A pipeline run begins |
PipelineCompleted |
A pipeline run finishes (status: :passed | :failed) |
StageStarted |
A stage begins executing |
StageSkipped |
A stage is skipped (condition or filter) |
StageCompleted |
A stage finishes |
StepStarted |
A step begins within a stage |
StepSkipped |
A step is skipped (condition) |
StepOutputLine |
One line of step output (streaming mode) |
StepRetrying |
A step is about to be retried after failure |
StepCompleted |
A step finishes |
MatrixRunStarted |
One matrix combination begins |
MatrixRunCompleted |
One matrix combination finishes |
HookStarted |
A pipeline hook begins |
HookCompleted |
A pipeline hook finishes |
All structs implement Jason.Encoder. Atoms are encoded as strings; DateTime
values are encoded as ISO 8601 strings. Keyword-list combination fields on
matrix events are encoded as JSON objects with string keys.
alias TinyCI.Events.StageCompleted
event = %StageCompleted{
run_id: "20240115_103000_main_abc1234",
timestamp: DateTime.utc_now(),
stage: :test,
status: :passed,
duration_ms: 1234
}
Jason.encode!(event)
# => {"run_id":"20240115_103000_main_abc1234","timestamp":"2024-01-15T10:30:00.000000Z",
# "stage":"test","status":"passed","duration_ms":1234}lib/
mix/tasks/
tiny_ci.run.ex # CLI entry point (mix tiny_ci.run)
tiny_ci/
application.ex # OTP application / task supervisor
context.ex # Git context builder
discovery.ex # Pipeline file discovery
dry_run.ex # --dry-run plan printer
dsl/
condition_eval.ex # Condition expression evaluator
interpreter.ex # DSL file parser → PipelineSpec
validator.ex # AST allowlist validator
dag.ex # DAG level computation and cycle detection
dsl.ex # Macro-based DSL (internal use)
events.ex # Typed event vocabulary (14 structs)
executor.ex # Stage/step execution engine
hooks.ex # Hook runner
matrix.ex # Matrix combination generator and helpers
matrix_run_result.ex # MatrixRunResult struct
output.ex # Command output streaming
pipeline_spec.ex # PipelineSpec struct
reporter.ex # Summary and output formatting
tiny_ci.ex # Step and Stage struct definitions
step_result.ex # StepResult struct
stage_result.ex # StageResult struct
test/
mix/tasks/
tiny_ci_run_test.exs # Mix task integration tests
tiny_ci/
context_test.exs
discovery_test.exs
dsl/
condition_eval_test.exs
interpreter_test.exs
validator_test.exs
dsl_test.exs
events_test.exs
executor_test.exs
integration_test.exs
reporter_test.exs
mix test # run full suite
mix format # format code
mix compile --warnings-as-errors # check for warnings
mix credo # static analysis- Core execution — serial and parallel stage modes, fail-fast pipeline, conditional stages
- Git context — automatic branch/commit detection passed through the pipeline
- CLI —
mix tiny_ci.runwith discovery,--file,--root,--list, named pipelines, proper exit codes - Generic config —
set key, valuefor module step and hook configuration - Output — live streaming with per-step prefixes in parallel mode, buffered fallback in non-TTY
- Robustness — step timeouts,
--dry-run,allow_failuresteps - Richer conditions —
branch(),env/1,file_changed?/1with boolean combinators - Hooks —
on_success/on_failurepipeline hooks (shell and module-based) - Step data passing — pipeline store for sharing data between module steps
- Custom DSL — declarative pipeline format with an allowlist validator
- Stage dependencies (DAG) —
needs:for fan-out/fan-in topologies with parallel independent stages, transitive skip propagation, and cycle detection at parse time - Matrix builds —
matrix:option for cartesian-product parallel stage runs with env var injection,max_parallel:concurrency cap, andallow_failure:for partial tolerance - Event structs — 14 typed event structs covering every execution boundary (pipeline, stage, step, matrix, hooks), each with
run_id,timestamp, andJason.Encodersupport - Dependency caching —
cache: [paths: [...], key: "file"]skips steps on hash-keyed hits, stores at~/.cache/tiny_ci/,--no-cacheflag,mix tiny_ci.cache cleanto purge - Artifact persistence —
artifact: [name: "build", paths: [...]]copies declared outputs to~/.local/share/tiny_ci/artifacts/<project>/<run_id>/, injects path into pipeline store,required: truefails the step if paths are absent,--artifacts-diroverride,--list-artifactsto inspect
- Secrets management —
secret "MY_KEY"reading from env or a local secrets file, with value masking in output - Watch mode —
mix tiny_ci.run --watchto re-run on file changes