YAML Compatibility

Local CI aims to run real GitHub Actions workflows locally. Below is current support against the official workflow syntax.

Supported⚠️PartialNot supported🟡Ignored (no-op)🚫Not planned

Workflow-Level Keys

Top-level keys that control when and how a workflow runs — triggers, environment defaults, and concurrency.

KeyStatusNotes
name
run-name🟡Parsed but not displayed anywhere
on (push, pull_request)Branch and path filters are evaluated when using --all
on.<event>.branches / branches-ignoreBranch-name glob filters; --all only runs workflows whose filter matches the current branch (main for non-PR runs).
on.<event>.paths / paths-ignorePath-pattern filters. With --all, a workflow is skipped if none of the changed files match paths: or all of them match paths-ignore:.
on.<event>.tags / tags-ignoreTag-pattern filters for push events are not evaluated — --all treats a tagged run as an untagged push. Workflows gated only by tag patterns will run when you don't expect them to.
on.<event>.types🟡Activity-type filters (e.g. pull_request.types: [opened, synchronize]) are parsed but not applied — --all runs the workflow regardless of event sub-type.
on.workflow_dispatch.inputs🟡workflow_dispatch itself is not simulated, so its declared inputs are parsed but unreachable — the CLI cannot inject them.
on.workflow_call.inputs.* (type / required / default)Reusable workflows resolve caller-side with: values against the callee's inputs: declarations, honoring default: when a caller omits an input.
on.workflow_call.outputs.*.valueReusable-workflow callees can expose outputs.<name>.value: ${{ jobs.<id>.outputs.<name> }}, and callers read them the same way as a normal needs.*.outputs.*.
on (schedule, workflow_dispatch)🟡Accepted without error, but Local CI does not simulate event triggers — workflows must be run manually
on (workflow_call)Local reusable workflows (uses: ./.github/workflows/...) are inlined into the caller's dependency graph. Remote refs are fetched from GitHub (requires --github-token or LOCAL_CI_GITHUB_TOKEN). inputs:/outputs: passing and nested reusable workflows are supported
on (other events)🟡Covers every GitHub-Actions webhook event Local CI does not simulate (issues, issue_comment, label, pull_request_review, release, registry_package, status, deployment, deployment_status, fork, watch, star, discussion, milestone, project, repository_dispatch, check_run, check_suite, workflow_run, etc.). The parser accepts the event name but the run is not triggered — the workflow only ever runs when invoked manually via pnpm local-ci run.
envWorkflow-level env is propagated to all steps
defaults.run.shellNon-bash shells (sh, python, pwsh) are invoked via a parse-time heredoc wrap because the runner ignores inputs.shell for script steps. bash is the default.
defaults.run.working-directoryPassed through to the runner
permissions🟡Accepted but not enforced — the mock GITHUB_TOKEN has full access
concurrency🚫Concurrency groups are a GitHub-side queuing and cancellation mechanism. Local CI has no persistent server to track group state across runs, so this cannot be implemented locally

Job-Level Keys

Per-job configuration — runner selection, dependencies, conditional execution, containers, and service sidecars.

KeyStatusNotes
jobs.<id>Multiple jobs in a single workflow
jobs.<id>.name
jobs.<id>.needsJobs are sorted topologically into dependency waves
jobs.<id>.ifsuccess(), failure(), always(), cancelled(), ==/!=, &&/||, !, needs.*.outputs.*, needs.*.result
jobs.<id>.runs-on⚠️ubuntu-* / linux (and unspecified self-hosted / custom labels) run in a Linux container — the default image is minimal, see runner-image.md to add system tools. macos-* runs in a real macOS VM on Apple Silicon hosts with tart + sshpass installed; other hosts skip the job with a reason. windows-* is not yet supported and skips on every host. When a label declares a larger runner spec (e.g. ubuntu-latest-8-cores) than the local machine provides, the job is tagged degraded and a warning is printed before it starts — execution is not blocked, only annotated.
jobs.<id>.environment🟡Accepted but not enforced — environment protection rules are GitHub-side only. Object form (environment.name / environment.url) is also accepted and ignored.
jobs.<id>.permissions🟡Accepted but not enforced — job-level permission scopes have no effect because the mock GITHUB_TOKEN has full access to the local API emulation.
jobs.<id>.env
jobs.<id>.defaults.runshell and working-directory
jobs.<id>.outputsResolved after each job completes and accumulated across dependency waves
jobs.<id>.timeout-minutesNot implemented. Local CI's pause-on-failure model is the intended way to handle long-running steps — a hard timeout would destroy the container state that makes local debugging possible
jobs.<id>.continue-on-errorNot implemented. Local CI pauses on failure so you can inspect and fix the container in place; continue-on-error would skip past failures and discard that debugging opportunity
jobs.<id>.concurrency🚫See workflow-level concurrency above
jobs.<id>.container⚠️Short and long form; image, env, ports, volumes honored. Within options:, only --env/-e and --label/-l are forwarded to the runner container; other Docker flags (--privileged, --user, --network, --cap-add, --workdir, etc.) are silently ignored because they tend to clash with local-ci's own container orchestration.
jobs.<id>.container.credentialsRegistry username / password credentials are expanded from workflow contexts and passed to Docker only when a missing private image must be pulled.
jobs.<id>.servicesSidecar containers with image, env, ports, and options
jobs.<id>.services.*.credentialsEach service can provide registry username / password credentials. Expressions are expanded before Docker pulls a missing image.
jobs.<id>.uses (reusable workflows)Local refs (./) are expanded inline. Remote refs are fetched from GitHub (requires --github-token or LOCAL_CI_GITHUB_TOKEN; public repos may work without auth). with: (inputs) and secrets: pass-through are supported
jobs.<id>.secrets🚫Local CI cannot access GitHub's secret storage. Secrets are resolved from a .env.local-ci file at the project root or from shell environment variables as fallback. --github-token / LOCAL_CI_GITHUB_TOKEN auto-populates secrets.GITHUB_TOKEN. All are injected as ${{ secrets.* }} expressions

Strategy / Matrix

Matrix builds and failure behavior — how jobs are multiplied across configurations and when to stop on errors.

KeyStatusNotes
strategy.matrixCartesian product of all array values is fully expanded
strategy.matrix.includeNot implemented. The matrix parser only processes array-valued keys; include entries (which are objects) are silently dropped. Adding support would require post-processing the Cartesian product
strategy.matrix.excludeNot implemented — same reason as include. exclude entries are objects and are dropped by the array-only parser
strategy.fail-fastSetting fail-fast: false allows remaining matrix jobs to continue after a failure
strategy.max-parallelNot implemented. Parallelism is controlled by Local CI's host-level concurrency limiter (based on CPU count), not per-workflow job limits

Step-Level Keys

Individual step configuration — shell commands, action references, conditional execution, and environment variables.

KeyStatusNotes
steps[*].id
steps[*].nameExpression expansion in names
steps[*].if⚠️The condition is passed to the official runner binary, which evaluates it at runtime. Limitation: steps.*.outputs.cache-hit and similar outputs resolve to an empty string at parse time because prior steps have not yet run when the workflow is parsed
steps[*].runMultiline shell scripts with ${{ }} expression expansion
steps[*].usesPublic actions are downloaded via the GitHub API
steps[*].uses (local ./)Local actions (e.g. uses: ./.github/actions/my-action) are resolved from the workspace
steps[*].uses (docker://…)Docker-image action references (uses: docker://alpine:3.19) are not resolved — local-ci treats every uses: as a repository action. The step fails with an action-not-found error rather than running the image.
steps[*].withExpression expansion in values
steps[*].envExpression expansion in values
steps[*].working-directory
steps[*].shellSee defaults.run.shell above — the same heredoc-wrap mechanism applies.
steps[*].continue-on-errorNot implemented — see jobs.<id>.continue-on-error above for the reasoning
steps[*].timeout-minutesNot implemented — see jobs.<id>.timeout-minutes above for the reasoning

Expressions (`${{ }}`)

The ${{ }} expression language — built-in functions, context variables, and operators available in workflow YAML.

KeyStatusNotes
hashFiles(...)SHA-256 of matching files; supports multiple glob patterns. Descends into dotted directories (e.g. .github/) when the pattern asks for them.
format(...)Template substitution with recursive expression expansion
matrix.*
secrets.*Resolved from .env.local-ci at the project root, with shell environment variables as fallback. --github-token auto-populates secrets.GITHUB_TOKEN
vars.*Repository / organization / environment variables. Resolved from --var NAME=VALUE CLI flags or JSON supplied via --var-file <path> / --var-file -; unresolved vars.* references fail the run at pre-flight with a list of every missing name.
env.*The merged step environment (workflow-level → job-level → step-level, with step taking precedence) is available as env.* in with: inputs, run: scripts, name:, and env: value expressions. Not yet supported in if: conditions (the expression is evaluated by the runner, not the parser). Note: env.* references inside an env: block (one env var referencing another declared in the same block) are not evaluated — only the already-computed merged value is exposed.
inputs.*Reusable-workflow and (nominally) workflow_dispatch input values. Inside a called workflow, inputs.<name> resolves to the caller's with: value or the declared default:.
runner.osLinux for container-based jobs, macOS for jobs routed to the macOS VM
runner.archX64 for container-based jobs, ARM64 for macOS VM jobs (Apple Silicon)
runner.name, runner.temp, runner.tool_cache, runner.debug, runner.environmentOnly runner.os and runner.arch are populated; the rest of the runner.* context resolves to an empty string. Workflows that read runner.tool_cache (common in tool-setup actions) will see an empty value — the actions themselves typically fall back to the RUNNER_TOOL_CACHE env var which the runner does set.
github.shaReal commit SHA from HEAD. When the working tree is dirty, a synthetic tree SHA is computed so that expressions that hash-seed from github.sha still differ between working-tree states.
github.repository, github.repository_ownerDerived from the git remote origin URL (owner/repo and owner).
github.ref, github.ref_name, github.head_ref, github.base_ref, github.actor, github.run_id, github.run_number, and other github.* fields⚠️Returned as static defaults: ref_name / head_ref default to main, base_ref is empty, actor is the repo owner, run_id / run_number are 1, api_url / server_url point at Local CI's local API emulation. Anything not in this row or one of the ones above resolves to an empty string — that covers action_path, workflow_ref, workflow_sha, triggering_actor, retention_days, secret_source, token, graphql_url, job, run_attempt, ref_protected, ref_type, env, path, and the rest of the context documented by GitHub.
github.event.*⚠️All event payload fields return empty strings — no real webhook event is triggered locally
strategy.job-total, strategy.job-index
steps.*.outputs.*⚠️Always resolves to an empty string at parse time — the producing step has not run yet and the runner does not re-evaluate ${{ }} inside run: script bodies. Use needs.*.outputs.* for cross-job values instead.
steps.*.conclusion / steps.*.outcomeStep result states are not exposed — neither conclusion nor outcome resolves against a meaningful value. Workflows that branch on prior-step status via these context variables will always evaluate as if the step didn't produce a result.
job.* (job.status, job.container.id, job.services.*)The per-job runtime context is not populated. Expressions that read job.status etc. resolve to empty strings.
* object-filter operatorArray-of-object filtering like github.event.commits.*.author.name is not implemented — the entire expression resolves to an empty string.
needs.*.outputs.*Resolved after dependency jobs complete. The needs context is built from actual job outputs and passed into subsequent job evaluation
Boolean/comparison operators==, !=, <, >, <=, >=, &&, ||, !, parentheses. Case-insensitive string comparison. Numeric coercion follows GitHub Actions semantics: empty string and null become 0, numeric-looking strings are parsed, anything else becomes NaN and compares false.
toJSON, fromJSONtoJSON emits 2-space-indented JSON matching GitHub Actions' pretty-printed output, so hashFiles/diff uses of toJSON(x) are reproducible.
contains(search, item)Case-insensitive substring for strings; element membership for JSON arrays (e.g. contains(fromJSON('["a","b"]'), 'b')). The * object-filter form (contains(labels.*.name, 'bug')) is not supported — see the * object-filter row.
startsWith(searchString, searchValue)Case-insensitive.
endsWith(searchString, searchValue)Case-insensitive.
joinJoins JSON arrays with a separator (default , )
success(), failure(), always(), cancelled()⚠️success() / failure() / always() are evaluated against the accumulated job status in dependency waves. cancelled() always returns false locally — there is no cancellation signal for Local CI to observe, so steps gated by if: cancelled() never run.
Type literals (true, false, null, numbers)Boolean, null, and numeric literals supported in expressions

GitHub API Features (DTU Mock)

Interactions with GitHub's API — action downloads, common actions (cache, checkout, setup-*), artifacts, and tokens.

KeyStatusNotes
Action downloadsAction tarballs are resolved and downloaded from github.com
actions/cache⚠️Cache is stored on the local filesystem via bind-mount, giving ~0 ms round-trip on cache hits. Scoping is by cache key only — GitHub's ref-based isolation (a branch cannot read another branch's cache, default branch is a fallback) is not implemented. If two workflows on different refs share a key they share the cache.
actions/checkoutThe workspace is rsynced into the container with clean: false to preserve local changes
actions/setup-node, actions/setup-python, etc.Tool setup actions run natively inside the runner container
actions/upload-artifact / download-artifactArtifacts are stored on the local filesystem
GITHUB_TOKEN⚠️Not injected automatically. Pass --github-token or set LOCAL_CI_GITHUB_TOKEN to make it available as secrets.GITHUB_TOKEN. GitHub API calls from the runner are answered locally by Local CI's API emulation layer. OIDC ID-token issuance (permissions.id-token: write → federated cloud auth) is not implemented — actions that exchange the OIDC token with AWS/GCP/Vault will fail.
Workflow commands (::set-output::, ::error::, etc.)Handled by the official runner binary