Skip to content

Repository files navigation

TinyCI

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.

Use trusted repositories and dependencies. The DSL parser and action sandbox are not a complete boundary for untrusted builds; see Reliability and trust.

Quick Start

  1. Create a tiny_ci.exs file 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
  1. Run it:
mix tiny_ci.run

Usage

mix 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
--base REF Ref or SHA that file_changed? diffs against (default: detected — see Conditions)
--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
--events FILE Write NDJSON to FILE; - reserves stdout for events and cannot be combined with --output json
--attest FILE Write a signed provenance attestation for the run
--signing-key PATH Ed25519 private key used by --attest
--break SPEC Pause at a boundary — before:STAGE[.STEP] / after:STAGE[.STEP]. Repeatable
--break-timeout MS Auto-resolve a breakpoint after MS; required when no terminal can answer
--break-timeout-action ACT abort (default) or continue on timeout
--debug-serial Force serial scheduling while breakpoints are armed

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 pipelines

Filtering Stages (--filter)

Run 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 :deploy

Stages 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.

Machine-Readable Output (--output json)

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.

Hook diagnostics and filtered-dependency warnings go to stderr. duration_ms is measured wall time, not the sum of concurrently executing stage durations. An aborted run has status "aborted" and a nonzero exit code. Failures also exit nonzero when the runner itself is invoked with MIX_ENV=test.

# 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.

Pipeline Discovery

When --file is not given and no pipeline name is provided, TinyCI searches in order:

  1. tiny_ci.exs (project root)
  2. .tiny_ci/pipeline.exs

Named pipelines live in .tiny_ci/<name>.exs or nested as .tiny_ci/<dir>/<name>.exs.

DSL Reference

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.

name

Optional. Sets the pipeline name. Defaults to the filename stem (deploy.exs → :deploy).

name :my_pipeline

Environment Variables

The 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"}
end

Multiple 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.

Secrets

The secret directive names a secret the pipeline needs. Every declared secret is resolved before any step runs, injected into every step's and hook's environment, and its value is masked in managed command output, results, and events.

secret :SLACK_WEBHOOK_URL
secret "DEPLOY_TOKEN"

stage :deploy do
  step :ship, cmd: "curl -H \"Authorization: Bearer $DEPLOY_TOKEN\" https://..."
end

on_failure :alert, cmd: "curl -X POST -d 'build failed' $SLACK_WEBHOOK_URL"

secret is only valid at the top level of the file; names may be atoms or strings.

Sources. Each name is looked up in order, first hit wins:

  1. an explicit provider map (what a server-side secrets store plugs into),
  2. the process environment,
  3. .tiny_ci/secrets under the project root.

A declared secret found in none of them fails the run with exit code 1 and lists every missing name; with --dry-run it is a warning and the plan still prints (Secrets: NAME, NAME — names only, never values).

The .tiny_ci/secrets file is one KEY=value per line. # as the first non-blank character starts a comment; blank lines are ignored; a leading export is stripped; matching single or double quotes around the value are removed (no escapes, no interpolation); a line without = is an error. The file must be gitignored — tiny_ci warns when it is not.

Precedence. Secrets are the lowest environment layer: a pipeline-, stage-, or step-level env of the same name wins, so the pipeline's explicit configuration is always authoritative.

Masking guarantees. A secret value appearing in step output is replaced by *** in the streaming and buffered console, in StepResult.output and therefore --output json, in every event before it reaches any sink (--events NDJSON, custom sinks, the provenance collector), in breakpoint payloads, in failed-hook output, and in signed attestations. One mechanism (TinyCI.Redaction) is applied at three choke points — command output, result creation, and event dispatch — so nothing downstream needs to know about secrets.

Limitations.

  • Only literal occurrences are masked. A value that appears transformed (base64, URL-encoded, split across lines) is not recognised.
  • Values shorter than 4 bytes are never masked; a 1–3 byte "secret" would rewrite ordinary text.
  • Console lines are masked as complete lines, so a secret containing a newline is only masked in the captured output.
  • Ordinary module IO is captured, then redacted and reported after the callback returns. Direct writes to named devices (including IO.puts(:stderr, ...)), arbitrary Logger handlers, module-load callbacks, and independently spawned processes can bypass capture. Do not treat redaction as a security boundary.

Stages

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

Stage Dependencies (DAG)

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"
end

Execution 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.

Matrix Builds

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"
end

The 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.

Matrix values do not install toolchains or select operating systems. Combinations share the working tree; use commands that do not race on mutable build outputs. Published artifacts have a separate destination per combination.

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"
end

Allowing 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"
end

Reporter 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.

Steps

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)

Steps and hooks require exactly one cmd: or module: target. Stage names must be unique, and step names must be unique within their stage. set values may be nested literal maps, lists, tuples, numbers, strings, atoms, or store(:key) references. References resolve immediately before invocation; a missing required config reference fails clearly rather than passing AST to the action. Unsupported calls and calculations are rejected during validation. A module with no set block receives [].

Conditions

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") Effective pipeline/stage/step environment value, falling back to the host environment; nil if unset
file_changed?("glob") true if any file matching the glob changed on this branch since it diverged from the base ref, or is uncommitted — see below

Combine with standard boolean operators: and, or, not, ==, !=.

Conditions use truthiness: only false and nil skip execution. An explicit when: nil skips, while omitting when: runs unconditionally. The and, or, and not condition operators also accept truthy/falsy operands.

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"
end

How changed files are computed

file_changed? matches against context.changed_files, the sorted union of:

  1. every file changed between merge-base(base, HEAD) and HEAD — what this branch has committed since it diverged from its base;
  2. the dirty tree: unstaged edits, staged changes, and untracked files that are not ignored by .gitignore.

The base ref is the first of these that resolves to a commit:

  1. --base REF on the command line, or the TINY_CI_BASE_REF environment variable;
  2. @{upstream} — the branch's tracking ref;
  3. the remote's default branch (refs/remotes/origin/HEAD);
  4. the first existing of origin/main, origin/master, main, master;
  5. HEAD~1.

If the resolved base is the same commit as HEAD (you are on main with everything pushed), it falls back to HEAD~1, so running the pipeline right after a push still sees that push's files. On an initial commit with no remote there is no base at all: every tracked file counts as changed. --dry-run prints the resolved base and the merge-base SHA it diffed from. All git commands run in the project root (--root), not the current directory.

Step-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
end

A 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.

Working Directory

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, mode: :serial, 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"
end

Relative paths are resolved from the project root (--root, or the directory where the CLI was invoked). That root is also the default shell-step and shell-hook working directory. Absolute paths are used as-is. If the directory does not exist, the step fails before any command runs. --dry-run shows explicit working-directory overrides.

Dependency Caching

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"]
end
  • paths: — 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-run shows [cache: key=mix.lock, paths=[deps, _build]] in the step plan
  • --no-cache bypasses all cache lookups for the current run

The executor hashes the key file together with the command/module, effective environment, working directory, matrix combination, cached paths, and runner runtime identity. Old content-only entries are not reused by the executor. A hit is checked and restored under the same lock. Module actions may restore files, but still execute so their store writes are not lost. The nominated key file must still cover the relevant source inputs; this is not a hermetic build cache or proof that arbitrary external state is unchanged.

Atomicity. An entry is either complete or absent. A save copies into a staging directory and publishes it with a single rename, so an interrupted save never produces a hit. Savers and restorers of the same key serialise on a filesystem lock that works across OS processes, so parallel matrix combinations, DAG stages, and concurrent runs cannot interleave. Copies clone blocks where the filesystem supports it (cp -c on APFS, --reflink=auto on Linux) and fall back to a plain copy otherwise.

Eviction. The cache is pruned after every save and on demand: entries unused for more than max_age_days are removed, then least-recently-used entries until the total is under max_bytes. Defaults are 30 days and 5 GiB, overridable with TINY_CI_CACHE_MAX_AGE_DAYS / TINY_CI_CACHE_MAX_BYTES or the :tiny_ci application env keys :cache_max_age_days / :cache_max_bytes.

mix tiny_ci.cache clean                       # remove this project's entries
mix tiny_ci.cache clean --root /path/to/project
mix tiny_ci.cache prune                       # apply the default limits now
mix tiny_ci.cache prune --max-bytes 1073741824 --max-age-days 7
mix tiny_ci.cache stats                       # entries, size, projects

Artifact Persistence

The 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
end
  • name: — 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 copy
  • required: — when true, a missing path fails the step; when false (default) a warning is printed and the step still passes
  • Artifacts are stored at ~/.local/share/tiny_ci/artifacts/<project_id>/<run_id>/<name>/
  • Run directories use <YYYYMMDD_HHMMSS>_<commit7>_<random128> to distinguish concurrent runs of the same commit. Matrix artifacts use additional per-combination subdirectories.
  • Artifact names and paths must be relative and cannot contain ..; escaping symlinks and directory cycles are rejected before copying.
  • After a step with artifact: completes, the artifact's storage path is written to the pipeline store under the key artifact_<name> — downstream module steps can read it via ctx.store.artifact_release and shell steps can use store(:artifact_release) in their env:
  • --dry-run shows [artifact: name=..., paths=[...], dest=...] in the step plan
  • --artifacts-dir DIR overrides the base storage location for the current run
  • mix tiny_ci.run --list-artifacts lists 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-artifacts

Step Retries

The 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
end
  • retry: N — retry up to N times; total max attempts = N + 1
  • retry_delay: N — wait N milliseconds between attempts (default: no delay)
  • Each attempt is logged with its number (e.g. [attempt 2/3])
  • allow_failure: true exhausts all retries before allowing the failure
  • timeout: applies per attempt, not across all attempts combined
  • --dry-run shows [retry: N] and [retry_delay: Nms] in the step plan
  • The summary reports passed on attempt N or failed after N attempts when retries were used

Hooks

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"
end

Hook failures are logged to stderr but do not change the pipeline exit code.

Hooks receive the completed run's store writes, including writes made before a failure. Module-hook configuration resolves like action configuration; an explicit timeout: terminates a hung callback. Third-party module hooks are refused rather than executed inline (the action sandbox currently supports execute/2, not hook run/2). Hook diagnostics and captured module-hook output always use stderr.

Module Steps and Hooks

Module steps implement the TinyCI.Action behaviour (execute/2); module hooks implement run/2. Both receive the config keyword list (from set/2 calls) and the pipeline context (%TinyCI.Context{}):

defmodule MyApp.Deploy do
  use TinyCI.Action

  @impl TinyCI.Action
  def execute(config, context) do
    region = Keyword.fetch!(config, :region)
    branch = context.branch

    # deploy logic...
    :ok        # or {:ok, map} to write to the store, or {:error, reason}
  end

  # Optional — advertises identity and the capabilities the action needs.
  @impl TinyCI.Action
  def metadata do
    %TinyCI.Action.Metadata{name: "my_app.deploy", version: "1.0.0", capabilities: [:network]}
  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
end

Module steps return :ok or {:ok, map} to merge data into the pipeline store. Module hooks return :ok or {:error, reason}.

Scaffold a new action (module + passing test stub) with the generator:

mix tiny_ci.gen.action MyApp.Deploy
# creates lib/my_app/deploy.ex and test/my_app/deploy_test.exs

The loader verifies, before any step runs, that each module: step implements TinyCI.Action (exports execute/2) and each module hook exports run/2, failing with a descriptive error otherwise. See docs/actions.md for the full contract.

Deprecated: modules that export execute/2 without use TinyCI.Action still run by convention, but adopting the behaviour is recommended so the compiler verifies your callbacks via @impl.

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 .exs pipeline file.

Third-party actions & the lockfile

Third-party actions are ordinary Hex dependencies declared in your mix.exs, so mix.lock is the action lockfile — every action is pinned to an exact version and checksum, with no mutable-tag supply-chain risk.

Before any step runs, mix tiny_ci.run verifies every module: action against the lockfile and fails closed: an action whose package is absent from mix.lock, or whose loaded version has drifted from the locked one, aborts the run with a descriptive error. Your own (first-party) modules need no pin.

mix tiny_ci.actions.audit          # print the resolved action tree (package, version, checksum, status)

The lockfile covers the BEAM automation layer, not binaries pulled by cmd:/actions (apt, Docker images, npm) — pin those with their own ecosystems. See docs/actions.md.

Signed run attestations (provenance)

A run can emit a signed attestation of exactly what executed — pipeline, commit, per-step outcome/duration, and every action at its locked version/checksum — as a tamper-evident, in-toto/SLSA-style record.

mix tiny_ci.attest.gen_key --out ci_key                       # one-time keypair
mix tiny_ci.run --attest run.att.json --signing-key ci_key    # run + attest
mix tiny_ci.attest.verify run.att.json --key ci_key.pub       # verify (fails if modified)

"What ran" is sourced from the event stream and action versions/checksums from the lockfile, so the attestation ties what was pinned to what actually ran. Signing is pluggable (Ed25519 local keypair by default). See docs/provenance.md.

Discovering actions (registry)

The registry is a curated index over the action packages you already depend on — small and verifiable, not a vast marketplace. A package self-identifies by listing its action modules in its :tiny_ci_actions application env, so the index builds programmatically from installed deps.

mix tiny_ci.actions.search deploy                 # find actions by name/summary
mix tiny_ci.actions.search --capability network   # filter by blast radius
mix tiny_ci.actions.index --out actions.json      # generate a static JSON index

Each result surfaces the package, version, declared capabilities (blast radius), and a review tier (verified / community / unreviewed). Tiers come from a checked-in curated overlay merged onto the scan. See docs/action-registry.md.

Sandboxed execution of third-party actions

Because the BEAM is not a security boundary, third-party module actions (code shipped by a dependency) run inside an OS sandbox with only the capabilities they declared, while your own first-party code runs inline. Driver selection is automatic and fails closed — inline execution refuses untrusted code, and the sandbox refuses to run if no OS backend is available.

An action's TinyCI.Action.Metadata capabilities (:network, :filesystem_read/write, :env_read, :process_spawn), intersected with the run's grants, become a deny-by-default policy the OS enforces. Config and a sanitized context cross the boundary as plain serialized data (no shared PIDs/handles); the {:ok, store_delta} result merges back exactly as an inline step's would, with granted secrets masked.

The backend is chosen per host: macOS Seatbelt (sandbox-exec) or Linux Bubblewrap (bwrap); an OCI-container backend for CI fits the same contract. Confinement extends to native code and subprocesses, so a NIF or System.cmd/3 that tries to reach the network or write outside its grant is blocked. See docs/sandbox.md.

Breakpoints (execution control)

Pause a run at a step or stage boundary, inspect the live store and resolved environment, and take a command — instead of editing the .exs, re-pushing, and waiting.

mix tiny_ci.run --break before:deploy              # pause before the deploy stage
mix tiny_ci.run --break after:test.unit            # pause on a specific step's result
mix tiny_ci.run --break before:deploy --debug-serial
⏸  breakpoint before deploy.push
   stage:  deploy
   step:   push
   wd:     /repo
   git:    main @ 4f1c8a2b
   store:
     image_tag = "v1.4.2"
   env:
     MIX_ENV = "prod"
(tiny_ci) set image_tag v1.4.3
   store.image_tag = "v1.4.3" — run marked divergent (not attestable)
(tiny_ci) retry

Commands are continue, skip, retry (re-runs the body, bypassing the cache), abort, and set KEY VALUE. Because every step and DAG stage runs in its own process, pausing one branch does not freeze the independent ones — use --debug-serial when you want predictable stepping instead.

Editing the store, or forcing a skip or retry, marks the run divergent: it emits run_diverged into the event stream and --attest refuses to sign it, because it records what an operator made happen rather than what the pipeline does. An abort is reported as aborted rather than failed, so a JSON consumer can tell "somebody stopped this" from "the code is broken".

--break is refused outright when nothing can answer the prompt (non-TTY, or --output json) unless --break-timeout is given, so a stray breakpoint can never hang CI. The control surface is transport-agnostic — the terminal REPL is just one subscriber — so an editor debugger or web UI drives the same protocol. See docs/execution-control.md.

Sharing Data Between Steps

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.

Writing to the store (module steps)

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
end

Shell steps cannot write to the store.

Reading from the store (module steps)

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
end

Reading from the store (shell steps)

Shell 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)}
end

Only 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.

Scope

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.

Hooks and the store

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.

Sharing between pipelines

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)"
end

Pipeline Context

Every 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).

DSL Allowlist

Pipeline files are validated against an allowlist of permitted constructs before execution:

  • name, env, secret, 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.

This allowlist is described once, machine-readably, in TinyCI.DSL.Spec — every directive, option key (with its type), and condition primitive. The validator derives its permitted-key set from the Spec, and the language server's completion and hover read from the same source, so the three can never drift.

Language Server (diagnostics, completion, hover)

A full editing experience runs live in your editor. tiny_ci_lsp/ is a separate package (it depends on core, never the other way around) that implements a Language Server Protocol server over stdio:

  • Diagnostics — a disallowed construct, unknown option, dependency cycle, or syntax error is underlined at the offending range and clears when you fix it.
  • Completion — context-aware suggestions: directives at the right scope, stage/step/hook option keys inside their calls, and branch() / env(...) / file_changed?(...) inside a when: condition.
  • Hover — a one-line description plus an example for the symbol under the cursor.
  • Go-to-definition — jump from a needs: atom to its stage declaration, or from a module: alias to the module's source.
  • Flow diagnostics — undefined needs: targets and dependency cycles inline, plus pipeline-store dataflow warnings (store(:k) read with no writer; the same key written by steps that may run in parallel).

It never executes your file. Diagnostics call the same controlled-AST path the runner uses (TinyCI.DSL.Interpreter.diagnose_string/2), so the in-editor message is identical to what mix tiny_ci.run prints at load time. Completion and hover read from TinyCI.DSL.Spec, the same source the validator's allowlist derives from. Cursor context is determined by walking the AST (via Code.Fragment.container_cursor_to_quoted/1), not by matching text.

cd tiny_ci_lsp
mix deps.get
mix escript.build      # produces ./tiny_ci_lsp — point your editor at it

The server binary alone does nothing — your editor needs a client to launch it. A ready-to-build VS Code extension lives in editors/vscode/ (npm install, then F5 or package a .vsix); Neovim's built-in LSP needs only a few lines of config. See docs/lsp.md for both, plus architecture.

Multiple Pipelines

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-run

Event System

Every 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

Event Types

Struct Emitted when
PipelineStarted A pipeline run begins
PipelineCompleted A pipeline run finishes (status: :passed | :failed | :aborted)
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
CacheLookup A cached step resolves its cache key (hit or miss)
BreakpointHit Execution pauses at an armed breakpoint, carrying the inspectable boundary
BreakpointResumed A paused boundary is released by a control command
RunDiverged Manual control altered the run (set_store, forced skip/retry)

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}

Project Structure

lib/
  mix/tasks/
    tiny_ci.run.ex        # CLI entry point (mix tiny_ci.run)
  tiny_ci/
    application.ex        # OTP application / task + control-registry supervisor
    context.ex            # Git context builder
    control.ex            # Execution control: checkpoint / subscribe / resume
    control/
      breakpoint.ex       # --break grammar: parse, format, match, validate
      console.ex          # Terminal REPL driver for paused boundaries
      server.ex           # Per-run control plane (armed breaks, paused sessions)
      session.ex          # A paused boundary and its inspectable payload
    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
    events.ex             # Typed event vocabulary
    executor.ex           # Stage/step execution engine
    executor/
      env.ex              # Resolves pipeline ⊕ stage ⊕ step env for a step
    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
    control/
      breakpoint_test.exs
      console_test.exs
      server_test.exs
      session_test.exs
    control_integration_test.exs
    discovery_test.exs
    dsl/
      condition_eval_test.exs
      interpreter_test.exs
      validator_test.exs
    events_test.exs
    executor_test.exs
    integration_test.exs
    reporter_test.exs

Development

mix test                           # run full suite
mix format                         # format code
mix compile --warnings-as-errors   # check for warnings
mix credo                          # static analysis

Roadmap

The project roadmap lives in ROADMAP.md; per-task implementation plans live in .tasks/. The feature list above describes what exists today.

About

A simple CI runner with declarative pipeline DSL written in Elixir

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages