Skip to content

YAML workflows & compatibility

TOVIO reads your existing GitHub Actions workflows as they are — it imports them verbatim and runs the supported subset on a runner you connect (see the compatibility ladder below for what that subset is). Point it at a repo that already has .github/workflows/*.yml files and it imports each one verbatim into a structural model, maps its triggers onto TOVIO change events, and classifies every job and step for compatibility — all before the workflow runs for the first time. There is nothing new to author to get a first pipeline green; if it runs on GitHub Actions today, it is your starting point here. The import is one-way: TOVIO reads your YAML and executes it, it does not round-trip changes back to GitHub.

If you'd rather define pipelines in typed TypeScript instead of YAML, that front-end compiles down to the same structural model and flows through the identical path — see the SDK & tovio ci compile section. Everything in this section applies equally to both front-ends.

Which files are picked up

A file is registered as a workflow only when its repo-relative path is .github/workflows/<file>.yml or .github/workflows/<file>.yaml, sitting directly in that directory. Nested files (.github/workflows/nested/ci.yml), local action definitions (.github/actions/thing/action.yml), and files elsewhere in the tree are not workflows and are skipped.

Each registered file is content-addressed by the blake3: hash of its raw bytes. A one-byte edit changes that address and re-registers the workflow deterministically, so a stale registration is never executed. The workflow's label comes from its name: field, or the file stem if there is none (.github/workflows/build.yml → build).

Parsing is a structural read only — it extracts the shape needed for trigger-mapping and compatibility classification. It does not evaluate ${{ }} expressions or step semantics at import time. A file that isn't valid UTF-8, isn't YAML, isn't a mapping, or has neither on: triggers nor jobs: fails with TVO-CI-001, and so do three shape refusals that run before the loader builds a tree: a file over the 1 MiB import ceiling, a document nested or listed past its bounds, and any YAML anchor/alias (which GitHub Actions does not support either). That failure is non-fatal: the importer reports it and skips that one file rather than aborting the whole import.

Understood fields

TOVIO reads this subset of the GitHub Actions schema:

Field Where Notes
name workflow The workflow label; falls back to the file stem.
on: workflow Triggers and their filters — see the mapping table below.
jobs.<id> workflow Each entry is one job, in document order.
runs-on job A string, a list, or a {group, labels} map are all flattened to labels. Only Linux labels are supported (see the ladder).
needs job Job dependencies — the DAG edges.
if job Captured as the job condition.
strategy.matrix job Simple scalar axes (e.g. node: [18, 20]) fan the job out into multiple instances. include:/exclude:-only matrices are flagged as present but have no fan-out axis.
environment job Names a deployment environment — the gate target. See the deploy gates section.
steps job Each step is a run: shell step or a uses: action step.
run: / uses: / name: / shell: step The step shape the compatibility ladder classifies.
with: / env: step Flattened to string scalars and carried into the model — a non-scalar value (a list, a nested map) is dropped. A step's env: overlays the job environment at execution. They are modeled so a ${{ secrets.* }} reference riding an action input or a step environment is visible to the runner's clearance gate rather than silently lost at parse — and what the gate then does with it is fail the step closed: this runner can neither inject a secret into an action input nor evaluate a step-env expression, so it declines rather than run with the secret dropped.

Anything outside this set is not modeled by the front half — it is neither parsed into the structural model nor a parse error; it is simply not part of what the front half reasons about. That includes permissions:, concurrency:, and env: at the workflow or job level (only a step's env: is read).

Triggers: on: → TOVIO change events

TOVIO has no pull requests or pushes — it has changes. Your on: block is mapped onto TOVIO change events. The TriggerEvent a workflow maps onto is one of exactly three, whose serialized dotted names match the events the Forge emits:

GitHub on: TOVIO result TriggerEvent (dotted name)
pull_request Runs when a proposal is opened or amended ChangeProposed (change.proposed) + ChangeAmended (change.amended)
push Never fires. Mapped, but nothing schedules a run for a land ChangeLanded (change.landed)
workflow_dispatch Never fires. Recorded, but there is no dispatch surface behind it (not a change event — sets manual_dispatch)
schedule (cron:) Never fires. Cron expressions are collected, but there is no scheduler (not a change event — collects cron expressions)
pull_request_target, workflow_run, release, repository_dispatch, issue/label/… Unsupported in v1 (reported, never silently dropped)

Unsupported triggers ride along in the compat report with a reason. For example, pull_request_target reports runs untrusted code with a privileged token — no safe TOVIO equivalent; workflow_run, repository_dispatch, and release report has no TOVIO change-event equivalent in v1.

One YAML gotcha is handled for you: a bare unquoted on: key can be scanned as a boolean under YAML 1.1 (the "Norway problem"). TOVIO looks the key up both ways, so on: push is detected regardless of parser schema.

Branch, path, and tag filters

Each trigger's filters decide whether a workflow actually runs for a given change. A filtered-out workflow does not run and does not post a pending check — it is simply absent, not stuck. Filter evaluation applies the branch and path filters:

  • branches / branches-ignore — matched against the target lane. Ignore wins: a lane matching branches-ignore is excluded even if branches would admit it. An empty branches means "any lane."
  • paths / paths-ignore — matched against the change's changed-path set, with GitHub semantics. paths admits only if at least one changed file matches a pattern. paths-ignore admits only if at least one changed file is not matched by any ignore pattern (so a change touching only ignored paths is excluded; a change touching a mix runs).

tags and tags-ignore are parsed into the trigger spec but are captured fields, not part of the change-event filter evaluation in the front half.

Glob patterns use a GitHub-subset matcher:

  • ** matches any run of characters including / (so src/** matches src/a/b/c.rs, and **/*.rs matches a/b/c.rs).
  • * matches any run not crossing / (so feature/* matches feature/x but not feature/x/y, and *.js matches index.js but not src/index.js).
  • ? matches exactly one non-/ character.

The [], {}, +, and inline leading-! forms are not implemented and simply won't match — the conservative direction for a required check.

on:
  pull_request:
    branches: [main, 'release/**']
    paths:
      - 'src/**'
      - '*.toml'
  push:
    branches: [main]
    paths-ignore:
      - 'docs/**'
  schedule:
    - cron: '0 6 * * 1'
  workflow_dispatch:

Here the pull_request job runs on proposals into main or any release/* lane, but only when a src/ file or a top-level .toml changed. The push entry is parsed and its filters recorded, but it never fires — nothing schedules a run for a land — so that job does not execute. The schedule and workflow_dispatch entries are parsed and reported, but neither fires: there is no cron scheduler and no dispatch surface, so a workflow triggered only by those never runs.

The compatibility ladder

Every step is classified into one of four CompatClass values. Classification fails toward unsupported when it cannot prove a step is supported — it never claims a step is runnable that it can't back.

One caveat on shimmed: it means TOVIO recognizes the action, not that the shim does the work the upstream action would. actions/checkout and the four setup-* actions currently classify as shimmed while doing nothing (see the shim set), so a workflow made only of those plus run: steps reports fully_supported: true and will still not behave as it does on GitHub.

Class What it is How it runs
native A run: step Executed directly as a shell script. This is the Tier-1 path. If a step has both run: and uses:, the run: is what executes.
shimmed A first-party action TOVIO reimplements natively Backed by a TOVIO primitive instead of fetching the upstream action. Carries the resolved action name and its kind. See the shim set below.
container A docker://… action Needs a Docker-in-Docker runner tier (Tier-4).
unsupported An arbitrary marketplace action, a local action, or a step with neither run: nor uses: Cannot run; carries a reason.

The unsupported reasons are specific:

  • A generic owner/repo@ref action → non-shimmed action \owner/repo@ref` requires a Tier-3 (JavaScript) or Tier-4 (container) runner`. Without fetching it, TOVIO can't prove whether it's a JS or container action, so it's classified conservatively.
  • A local action (./… or /…) → local action \./path` requires a Tier-3 (composite/JS action) runner`.
  • A step with neither key → step has neither \run:` nor `uses:``.

Beyond per-step classes, a job can be marked not runnable as a whole (runner_supported: false):

  • A runs-on label containing windows or macos → unsupported; only Linux runners are supported.
  • A job-level uses: (a reusable workflow call) → unsupported in v1.

And some conditions are non-fatal advisories attached to a job's notes:

  • container: or services: → requires a full-DinD runner (Tier-4).
  • strategy.matrix → fans out into multiple job instances.
  • A run: step calling api.github.com directly → flagged, because GITHUB_TOKEN authenticates TOVIO, not GitHub.

The shim set

These are the exact first-party actions TOVIO implements natively. Matching is version-independent and by prefix — actions/checkout@v4, actions/checkout@<sha>, and any other ref all resolve to the same shim:

Action (any @version) kind Backed by
actions/checkout checkout The step itself does nothing — it succeeds on the assertion that the workspace already holds the change's tree. The runner crate ships the primitive that would make that assertion true (prepare_workdir: materialize the job's pinned commit from the content-addressed store, cone-scoped, re-hashed on read, so a re-run is byte-identical or fails), but it is designed to run before the job's steps and no shipped runner calls it — the distributable BYO binary runs the job in whatever directory it was launched in. So today the pinned-commit determinism that primitive carries does not apply on the CI path: materializing the reviewed commit is the operator's job, done before the runner starts.
actions/cache cache Tars the keyed paths into the Forge blob plane under the run's trust scope — written to its own proposal's scope, read from that scope then the target lane's, never a repository-wide namespace. Keyed by key/restore-keys; R2 on TOVIO Cloud, <root>/blobs on a native Forge, a local directory for an offline runner. Advisory: a miss never fails, and the entry is saved only after the job succeeds.
actions/upload-artifact upload-artifact Packs the path: files into one deterministic tar and publishes it to the run's namespace with a real size, a BLAKE3 digest and a retention-days (default 90). A published name is never replaced.
actions/download-artifact download-artifact Fetches one artifact from the run's namespace and unpacks it under path. A missing artifact fails the step (TVO-CI-013) — never an empty directory.
actions/setup-node setup-toolchain Installs nothing today. The step succeeds and your run: steps use whatever Node is already on the runner's PATH; node-version: and its siblings are ignored, and $GITHUB_PATH is not written. Bake the toolchain into your runner image.
actions/setup-python setup-toolchain Installs nothing today — resolves Python from the runner's PATH; the requested version is ignored.
actions/setup-java setup-toolchain Installs nothing today — resolves Java from the runner's PATH; the requested version is ignored.
actions/setup-go setup-toolchain Installs nothing today — resolves Go from the runner's PATH; the requested version is ignored.

A workflow built from run: steps plus these shims classifies as fully compatible, and executes through a provisioned runner with no marketplace fetch — there is no hosted executor, so that means a customer-operated (BYO) runner you connect yourself. Note the rows above whose step body does nothing: a workflow relying on the checkout step to produce its tree, or on setup-* to install a toolchain, will not behave as it does on GitHub. This is the common shape:

name: CI
on: [pull_request]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4      # shimmed
      - uses: actions/setup-node@v4    # shimmed
      - run: npm ci                    # native
      - run: npm test                  # native

Bringing your own tool

Because a run: step is native and executes as a plain shell script, any build tool you already use runs with no special support from TOVIO — Dagger, make, just, a shell script, whatever. Invoke it from a run: step:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: make lint test
      - run: just build
      - run: dagger call test --source=.

TOVIO schedules the job and runs the step; the tool inside is opaque to it. This holds from either front-end — TOVIO does not adopt a third-party engine as its orchestrator, so bringing one in as an in-job tool never changes how the pipeline itself is scheduled or gated.

Checking your own workflow

To see exactly how your workflows classify — every job and step's CompatClass, the mapped and unsupported triggers, and each job's advisory notes — run:

tovio ci check

It produces the compat report before a workflow first runs. Add --json for the machine-stable shape (each step carries a flattened class discriminator plus its fields, e.g. {"class":"shimmed","action":"actions/checkout","kind":"checkout"}) so you can gate on it in tooling. A workflow's fully_supported is true only when every job is runner_supported, every step is anything but unsupported, and the workflow declares no unsupported trigger — a pull_request_target alone is enough to take it out of the green.

Last reviewed September 9, 2026

Suggest an improvement to this page Not for security reports — see disclosure