Skip to content

CI usage

CI with ixt is simple: commit ixt.toml and ixt.lock, check the lock, run ixt tool apply --locked --strict, then prune the cache at the end.

ixt gives CI one toolchain path for Python packages, npm packages, and GitHub/GitLab release binaries:

  • one declarative file: ixt.toml;
  • one generated exact-resolution file: ixt.lock;
  • one materialization command: ixt tool apply --locked --strict --remove --yes;
  • lazy runtimes: reuse uv/bun when present, bootstrap only what is missing;
  • cached binary resolution: learned GitHub asset patterns and downloaded archives live in $IXT_CACHE_HOME;
  • portable binary metadata: ixt asset-index export can turn learned GitHub release asset knowledge into a reusable CI artifact.

This means the optimized CI path is not "install everything globally". It is:

  1. restore $IXT_CACHE_HOME and uv's cache/tool dirs;
  2. install the ixt command with uv tool install ixt-cli;
  3. preload asset_index.json if one exists;
  4. run ixt lock check;
  5. run ixt tool apply --locked --strict --remove --yes;
  6. run the job using the shims in $IXT_HOME/installed/bin;
  7. export a fresh asset_index.json;
  8. run ixt cache prune so the cache stays bounded.

Minimal job

Use this when you just want ixt in one CI job.

image: ghcr.io/astral-sh/uv:python3.13-trixie-slim

variables:
  IXT_HOME: "$CI_PROJECT_DIR/.ixt/home"
  IXT_CACHE_HOME: "$CI_PROJECT_DIR/.ixt/cache"
  UV_CACHE_DIR: "$CI_PROJECT_DIR/.ixt/uv/cache"
  UV_TOOL_DIR: "$CI_PROJECT_DIR/.ixt/uv/tools"
  UV_TOOL_BIN_DIR: "$CI_PROJECT_DIR/.ixt/uv/bin"

cache:
  key: ixt-tools
  paths:
    - .ixt/cache/
    - .ixt/uv/
    - .ixt/asset_index.json

before_script:
  - apt-get update -qq
  - apt-get install -y -qq --no-install-recommends git ca-certificates
  - export PATH="$UV_TOOL_BIN_DIR:$IXT_HOME/installed/bin:$PATH"
  - uv tool install ixt-cli
  - test ! -f .ixt/asset_index.json || export IXT_ASSET_INDEX="$PWD/.ixt/asset_index.json"
  - ixt lock check
  - ixt tool apply --locked --strict --remove --yes

lint:
  script:
    - ruff check .
  after_script:
    - export PATH="$UV_TOOL_BIN_DIR:$IXT_HOME/installed/bin:$PATH"
    - mkdir -p .ixt
    - ixt asset-index export > .ixt/asset_index.json || true
    - ixt cache prune || true

ixt lock check verifies that the committed ixt.lock still matches the committed ixt.toml using only local files; it never checks registries or release APIs for a newer version. ixt tool apply --locked --strict then installs missing tools, re-links changed shims, updates injections/exposure, and removes tools not in the manifest because --remove --yes is set.

Do not cache $IXT_HOME/installed/envs. Rebuild installed tools from ixt.lock; cache the downloads and resolver metadata instead.


Optimized workflow

For a larger pipeline, split tool resolution into a warmup job and reuse the learned asset index in downstream jobs.

stages:
  - tools
  - test

.ixt:
  image: ghcr.io/astral-sh/uv:python3.13-trixie-slim
  variables:
    IXT_HOME: "$CI_PROJECT_DIR/.ixt/home"
    IXT_CACHE_HOME: "$CI_PROJECT_DIR/.ixt/cache"
    UV_CACHE_DIR: "$CI_PROJECT_DIR/.ixt/uv/cache"
    UV_TOOL_DIR: "$CI_PROJECT_DIR/.ixt/uv/tools"
    UV_TOOL_BIN_DIR: "$CI_PROJECT_DIR/.ixt/uv/bin"
  cache:
    key: ixt-tools
    paths:
      - .ixt/cache/
      - .ixt/uv/
      - .ixt/asset_index.json
  before_script:
    - apt-get update -qq
    - apt-get install -y -qq --no-install-recommends git ca-certificates
    - export PATH="$UV_TOOL_BIN_DIR:$IXT_HOME/installed/bin:$PATH"
    - uv tool install ixt-cli
    - test ! -f .ixt/asset_index.json || export IXT_ASSET_INDEX="$PWD/.ixt/asset_index.json"

tools:warmup:
  extends: .ixt
  stage: tools
  script:
    - ixt lock check
    - ixt tool apply --locked --strict --remove --yes
    - ixt asset-index export > .ixt/asset_index.json
    - ixt cache prune
    - ixt cache info
  artifacts:
    paths:
      - .ixt/asset_index.json
    expire_in: 1 week

lint:
  extends: .ixt
  stage: test
  needs:
    - job: tools:warmup
      artifacts: true
  script:
    - export IXT_ASSET_INDEX="$PWD/.ixt/asset_index.json"
    - export IXT_GITHUB_API=never
    - ixt lock check
    - ixt tool apply --locked --strict --remove --yes
    - ruff check .
  after_script:
    - export PATH="$UV_TOOL_BIN_DIR:$IXT_HOME/installed/bin:$PATH"
    - mkdir -p .ixt
    - ixt asset-index export > .ixt/asset_index.json || true
    - ixt cache prune || true

What this buys you:

Step What ixt optimizes
ixt lock check Fails early if ixt.toml and ixt.lock diverge.
ixt tool apply --locked --strict --remove --yes Rebuilds the job toolchain from ixt.lock, across Python, npm, and binary tools.
$IXT_CACHE_HOME cache Reuses downloads, learned GitHub asset patterns, and resolver metadata between jobs.
ixt asset-index export Publishes portable GitHub binary asset metadata for downstream jobs or later pipelines.
IXT_ASSET_INDEX Preloads that metadata before binary resolution starts.
IXT_GITHUB_API=never Forbids the GitHub Releases API fallback when the asset index/cache should be enough.
ixt cache prune Keeps old binary downloads bounded without touching installed tools or metadata needed for resolution.

IXT_ASSET_INDEX and IXT_GITHUB_API=never are performance and network-policy tools, not reproducibility guarantees. Reproducibility comes from ixt.lock plus --locked --strict. IXT_GITHUB_API=never only disables api.github.com release API fallback; latest redirects and direct release downloads still work.

Binary downloads are keyed by release tag and exact URL. After upgrading from 0.9.0, an existing flat download is deliberately ignored once and fetched into the safe cache namespace; subsequent jobs can reuse that exact identity.

Asset index details


ixt.toml and ixt.lock in CI

ixt.toml stays human-authored. Use exact pins when the project wants to declare that a tool must stay on one release:

[tools]
"@pypi:ruff" = { version = "==0.13.0" }
"@npm:@biomejs/biome" = { version = "==1.9.4" }
"@gh:sharkdp/fd" = { version = "==10.2.0" }

Use ranges only when the job intentionally accepts compatible updates:

[tools]
"@pypi:ruff" = { version = ">=0.13,<0.14" }

ixt.lock freezes the current resolution, including tools declared with ranges or no version constraint. Update it intentionally:

ixt lock write
ixt lock diff
git add ixt.toml ixt.lock

In CI, always check and replay the committed lock:

ixt lock check
ixt tool apply --locked --strict --remove --yes

Runtime behavior

Base CI image Recommendation ixt reuses ixt may bootstrap
ghcr.io/astral-sh/uv:* Recommended default uv, Python bun if npm backend is used
Ubuntu minimal Install standalone uv, then ixt uv after install bun if npm backend is used
python:* OK for wheel/pip validation Python/pip uv, then bun if needed
node:* Use only for real Node/npm jobs node/npm uv if Python backend is used
oven/bun:* Good for npm-heavy jobs bun uv if Python backend is used
Binary-only job No external runtime needed none none

ixt first looks for a valid runtime on PATH. If a backend needs a runtime that is missing, ixt bootstraps only that runtime.


Cache maintenance

Cache these:

Path Why
$IXT_CACHE_HOME Downloaded archives, learned asset patterns, metadata, resolve cache.
UV_CACHE_DIR Wheels and metadata used by uv.
UV_TOOL_DIR The uv tool install ixt-cli environment.

Do not cache these in normal CI:

Path Why
$IXT_HOME/installed/envs Rebuild from ixt.toml; envs are job-local state.
$IXT_HOME/installed/runtimes Recreate quickly from cache when needed.

Recommended end-of-job cleanup:

ixt asset-index export > .ixt/asset_index.json
ixt cache prune
# If your CI cache, runner, or image layer keeps $IXT_HOME/installed/runtimes:
ixt runtime prune
ixt cache info

ixt cache prune keeps the newest two indexed download artifacts per binary repository by default: current + previous. It does not touch installed tools, shims, config, runtimes, or metadata needed for future resolution.

Use ixt runtime prune when $IXT_HOME/installed/runtimes is part of what survives the job: a broad CI cache, a long-lived runner, or a Docker image layer. It removes unused ixt-managed runtimes; later installs can bootstrap them again.


Docker multi-stage images

For a final Docker image that already contains ixt-installed tools, build the tool layer once and copy only $IXT_HOME/installed into the final image.

FROM ghcr.io/astral-sh/uv:python3.13-trixie-slim AS tools

ENV IXT_HOME=/opt/ixt \
    IXT_CACHE_HOME=/var/cache/ixt \
    PATH="/opt/ixt/installed/bin:${PATH}"

RUN uv tool install ixt-cli
WORKDIR /tmp
COPY ixt.toml ixt.lock ./
RUN ixt lock check
RUN ixt tool apply --locked --strict --remove --yes
RUN ixt cache prune
RUN ixt runtime prune

FROM ghcr.io/astral-sh/uv:python3.13-trixie-slim

ENV IXT_HOME=/opt/ixt \
    PATH="/opt/ixt/installed/bin:${PATH}"

COPY --from=tools /opt/ixt/installed /opt/ixt/installed

The final stage needs $IXT_HOME/installed/bin and $IXT_HOME/installed/envs. $IXT_CACHE_HOME, uv caches, build temp files, and $IXT_HOME/config stay in the build stage.

For binary-only images, the final stage can be a smaller base such as debian:trixie-slim, as long as the extracted binaries' shared-library requirements are satisfied. Do not use the binary-only pattern for Python tools unless the final image also preserves or repairs the venv's runtime paths.


Diagnostics

Use these at the start of a CI job when debugging the environment:

ixt environment --sizes
ixt cache info
ixt doctor --no-network

Use resolver verbosity only when you need to understand network/cache behavior:

ixt tool upgrade --all -v    # compact resolver summary
ixt tool upgrade --all -vv   # per-package resolver traces

For runtime bootstrap regressions in this repo, use the manual Docker matrix:

./tests/e2e/runtime_matrix.sh --case all

The matrix checks that each backend bootstraps only what it needs.